feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
@@ -61,18 +61,18 @@
position: relative; z-index: 1;
}
.hero-badge {
background: rgba(255,255,255,0.1); border-radius: 20px; padding: 4px 14px;
background: rgba(255,255,255,0.1); border-radius: var(--radius-xl); padding: 4px 14px;
font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.8);
border: 0.5px solid rgba(255,255,255,0.2);
}
/* TOC */
.toc { margin: 20px 16px; background: var(--background-secondary); border-radius: 14px; overflow: hidden; border: 0.5px solid var(--separator); }
.toc { margin: 20px 16px; background: var(--background-secondary); border-radius: var(--radius-lg); overflow: hidden; border: 0.5px solid var(--separator); }
.toc-title { padding: 14px 16px; font-size: 13px; font-weight: 600; color: var(--label-secondary); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 0.5px solid var(--separator); }
.toc-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 0.5px solid var(--separator); cursor: pointer; text-decoration: none; }
.toc-item:last-child { border-bottom: none; }
.toc-item:active { background: var(--fill-quaternary); }
.toc-num { width: 26px; height: 26px; border-radius: 50%; background: var(--ios-blue); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }
.toc-num { width: 26px; height: 26px; border-radius: var(--radius-full); background: var(--ios-blue); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }
.toc-label { font-size: 15px; font-weight: 500; flex: 1; }
.toc-chevron { color: var(--label-tertiary); font-size: 13px; }
@@ -92,7 +92,7 @@
/* Code block */
.code-block {
margin: 16px 16px 0;
background: #F2F2F7; border-radius: 14px; overflow: hidden;
background: #F2F2F7; border-radius: var(--radius-lg); overflow: hidden;
border: 0.5px solid var(--separator);
}
.code-header {
@@ -102,7 +102,7 @@
border-bottom: 0.5px solid var(--separator);
}
.code-lang { font-size: 12px; color: var(--label-tertiary); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.code-copy { background: none; border: none; color: var(--ios-blue); font-size: 12px; font-weight: 600; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
.code-copy { background: none; border: none; color: var(--ios-blue); font-size: 12px; font-weight: 600; cursor: pointer; padding: 2px 6px; border-radius: var(--radius-sm); }
.code-copy:active { background: var(--fill-secondary); }
pre {
padding: 16px; font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
@@ -119,7 +119,7 @@
/* Info card */
.info-card {
margin: 16px 16px 0;
padding: 14px 16px; border-radius: 14px;
padding: 14px 16px; border-radius: var(--radius-lg);
border-left: 3px solid var(--ios-blue);
background: rgba(0,122,255,0.1);
font-size: 14px; line-height: 1.6; color: var(--label-secondary);
@@ -131,7 +131,7 @@
/* Step list */
.step-list { margin: 16px 16px 0; display: flex; flex-direction: column; gap: 12px; }
.step-item { display: flex; gap: 14px; align-items: flex-start; }
.step-dot { width: 28px; height: 28px; border-radius: 50%; background: var(--ios-blue); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-top: 2px; }
.step-dot { width: 28px; height: 28px; border-radius: var(--radius-full); background: var(--ios-blue); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-top: 2px; }
.step-content { flex: 1; }
.step-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.step-desc { font-size: 14px; color: var(--label-secondary); line-height: 1.5; }
@@ -139,7 +139,7 @@
/* Endpoint table */
.endpoint-card {
margin: 16px 16px 0;
background: var(--background-secondary); border-radius: 14px;
background: var(--background-secondary); border-radius: var(--radius-lg);
overflow: hidden; border: 0.5px solid var(--separator);
}
.endpoint-row {
@@ -148,7 +148,7 @@
}
.endpoint-row:last-child { border-bottom: none; }
.method {
font-size: 11px; font-weight: 800; padding: 3px 8px; border-radius: 6px;
font-size: 11px; font-weight: 800; padding: 3px 8px; border-radius: var(--radius-sm);
text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0;
}
.method.get { background: rgba(52,199,89,0.15); color: var(--ios-green); }
@@ -161,7 +161,7 @@
/* Checklist */
.checklist { margin: 16px 16px 0; display: flex; flex-direction: column; gap: 10px; }
.check-item { display: flex; align-items: flex-start; gap: 10px; }
.check-box { width: 22px; height: 22px; border-radius: 50%; background: rgba(52,199,89,0.15); color: var(--ios-green); display: flex; align-items: center; justify-content: center; font-size: 13px; flex-shrink: 0; margin-top: 1px; }
.check-box { width: 22px; height: 22px; border-radius: var(--radius-full); background: rgba(52,199,89,0.15); color: var(--ios-green); display: flex; align-items: center; justify-content: center; font-size: 13px; flex-shrink: 0; margin-top: 1px; }
.check-text { font-size: 14px; color: var(--label-secondary); line-height: 1.5; }
.check-text strong { color: var(--label-primary); }
@@ -204,7 +204,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</nav>
<div class="guide-hero">
<div class="hero-icon">📱</div>
<div class="hero-icon"></div>
<div class="hero-title">quiXzoom iOS Integration</div>
<div class="hero-sub">Teknisk guide för native iOS-appen<br>SwiftUI · WKWebView · REST API</div>
<div class="hero-badges">
@@ -261,7 +261,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</a>
</div>
<!-- ─────────────────────────── SECTION 1 ─────────────────────────── -->
<!-- SECTION 1 -->
<div class="guide-section" id="s1">
<div class="section-anchor">
<div class="section-num">01 — Arkitektur</div>
@@ -288,34 +288,34 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<pre>
<span class="cm">// QuiXzoom iOS — Projektstruktur</span>
QuiXzoom/
├── App/
├── <span class="fn">QuiXzoomApp</span>.swift <span class="cm">// @main</span>
└── <span class="fn">AppDelegate</span>.swift <span class="cm">// APNs + deep links</span>
├── Features/
├── Auth/
├── <span class="fn">BankIDView</span>.swift
└── <span class="fn">AuthViewModel</span>.swift
├── Missions/
├── <span class="fn">MissionFeedView</span>.swift
├── <span class="fn">MissionMapView</span>.swift <span class="cm">// MapKit</span>
└── <span class="fn">ActiveMissionView</span>.swift
├── Camera/
└── <span class="fn">CameraUploadView</span>.swift
├── Wallet/
└── <span class="fn">WalletView</span>.swift
└── Profile/
└── <span class="fn">ProfileView</span>.swift
├── Services/
├── <span class="fn">APIClient</span>.swift <span class="cm">// REST + JWT</span>
├── <span class="fn">LocationService</span>.swift
└── <span class="fn">PushService</span>.swift
└── DesignSystem/
└── <span class="fn">Tokens</span>.swift <span class="cm">// iOS 18 native tokens</span>
App/
<span class="fn">QuiXzoomApp</span>.swift <span class="cm">// @main</span>
<span class="fn">AppDelegate</span>.swift <span class="cm">// APNs + deep links</span>
Features/
Auth/
<span class="fn">BankIDView</span>.swift
<span class="fn">AuthViewModel</span>.swift
Missions/
<span class="fn">MissionFeedView</span>.swift
<span class="fn">MissionMapView</span>.swift <span class="cm">// MapKit</span>
<span class="fn">ActiveMissionView</span>.swift
Camera/
<span class="fn">CameraUploadView</span>.swift
Wallet/
<span class="fn">WalletView</span>.swift
Profile/
<span class="fn">ProfileView</span>.swift
Services/
<span class="fn">APIClient</span>.swift <span class="cm">// REST + JWT</span>
<span class="fn">LocationService</span>.swift
<span class="fn">PushService</span>.swift
DesignSystem/
<span class="fn">Tokens</span>.swift <span class="cm">// iOS 18 native tokens</span>
</pre>
</div>
</div>
<!-- ─────────────────────────── SECTION 2 ─────────────────────────── -->
<!-- SECTION 2 -->
<div class="guide-section" id="s2">
<div class="section-anchor">
<div class="section-num">02 — Auth</div>
@@ -394,7 +394,7 @@ class</span> <span class="tp">AuthViewModel</span>: <span class="tp">ObservableO
</div>
</div>
<!-- ─────────────────────────── SECTION 3 ─────────────────────────── -->
<!-- SECTION 3 -->
<div class="guide-section" id="s3">
<div class="section-anchor">
<div class="section-num">03 — API</div>
@@ -464,7 +464,7 @@ class</span> <span class="tp">AuthViewModel</span>: <span class="tp">ObservableO
</div>
</div>
<!-- ─────────────────────────── SECTION 4 ─────────────────────────── -->
<!-- SECTION 4 -->
<div class="guide-section" id="s4">
<div class="section-anchor">
<div class="section-num">04 — Push</div>
@@ -516,7 +516,7 @@ class</span> <span class="tp">AuthViewModel</span>: <span class="tp">ObservableO
</div>
</div>
<!-- ─────────────────────────── SECTION 5 ─────────────────────────── -->
<!-- SECTION 5 -->
<div class="guide-section" id="s5">
<div class="section-anchor">
<div class="section-num">05 — Kamera</div>
@@ -528,7 +528,7 @@ class</span> <span class="tp">AuthViewModel</span>: <span class="tp">ObservableO
</div>
<div class="info-card warning">
<strong>⚠️ Privacy Manifest krävs:</strong> Lägg till <code style="font-family:monospace">NSCameraUsageDescription</code>
<strong><svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3L17 17H3L10 3Z" stroke="#f59e0b" stroke-width="2"/><path d="M10 8V12M10 14V14.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/></svg> Privacy Manifest krävs:</strong> Lägg till <code style="font-family:monospace">NSCameraUsageDescription</code>
och <code style="font-family:monospace">NSLocationWhenInUseUsageDescription</code> i Info.plist med
motiveringar på svenska (krävs för App Store Review).
</div>
@@ -585,7 +585,7 @@ class</span> <span class="tp">AuthViewModel</span>: <span class="tp">ObservableO
</div>
</div>
<!-- ─────────────────────────── SECTION 6 ─────────────────────────── -->
<!-- SECTION 6 -->
<div class="guide-section" id="s6">
<div class="section-anchor">
<div class="section-num">06 — Plats</div>
@@ -597,7 +597,7 @@ class</span> <span class="tp">AuthViewModel</span>: <span class="tp">ObservableO
</div>
<div class="info-card success">
<strong> Background modes:</strong> Aktivera <code style="font-family:monospace">location</code>
<strong><svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="#22c55e" stroke-width="2"/><path d="M6 10L9 13L14 7" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Background modes:</strong> Aktivera <code style="font-family:monospace">location</code>
i Background Modes capability för spårning under aktivt uppdrag. Motivera i Privacy Manifest.
</div>
@@ -644,7 +644,7 @@ class</span> <span class="tp">LocationService</span>: <span class="tp">NSObject<
</div>
</div>
<!-- ─────────────────────────── SECTION 7 ─────────────────────────── -->
<!-- SECTION 7 -->
<div class="guide-section" id="s7">
<div class="section-anchor">
<div class="section-num">07 — Deep Links</div>
@@ -668,7 +668,7 @@ class</span> <span class="tp">LocationService</span>: <span class="tp">NSObject<
</div>
</div>
<!-- ─────────────────────────── SECTION 8 ─────────────────────────── -->
<!-- SECTION 8 -->
<div class="guide-section" id="s8">
<div class="section-anchor">
<div class="section-num">08 — Checklista</div>
@@ -676,18 +676,18 @@ class</span> <span class="tp">LocationService</span>: <span class="tp">NSObject<
</div>
<div class="checklist">
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>Privacy Manifest</strong> — PrivacyInfo.xcprivacy med alla API-anledningar deklarerade</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>BankID integration</strong> — Testad mot BankID Test-miljö</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>App Tracking Transparency</strong> — ATT-dialog om analytics används</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>Åldersrating</strong> — Minst 17+ (finansiell tjänst, gig economy)</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>Crashlytics / Sentry</strong> — Felrapportering aktiv</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>GDPR-popup</strong> — Dataskyddspolicy acceptansflöde vid första start</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>Bildkomprimering</strong> — Max 2MB per bild, EXIF-stripning aktiverad</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>Offline-hantering</strong> — Graceful degradation utan nätverksanslutning</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>Dynamic Type</strong> — Alla textstorl. skalar med iOS-inställningar</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>Dark/Light mode</strong> — Testad i båda lägena (primärt: dark mode)</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>Notarization</strong> — App notariserad via Xcode Organizer</div></div>
<div class="check-item"><div class="check-box"></div><div class="check-text"><strong>TestFlight</strong> — Minst 10 externa testare godkänt bygget</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Privacy Manifest</strong> — PrivacyInfo.xcprivacy med alla API-anledningar deklarerade</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>BankID integration</strong> — Testad mot BankID Test-miljö</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>App Tracking Transparency</strong> — ATT-dialog om analytics används</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Åldersrating</strong> — Minst 17+ (finansiell tjänst, gig economy)</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Crashlytics / Sentry</strong> — Felrapportering aktiv</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>GDPR-popup</strong> — Dataskyddspolicy acceptansflöde vid första start</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Bildkomprimering</strong> — Max 2MB per bild, EXIF-stripning aktiverad</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Offline-hantering</strong> — Graceful degradation utan nätverksanslutning</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Dynamic Type</strong> — Alla textstorl. skalar med iOS-inställningar</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Dark/Light mode</strong> — Testad i båda lägena (primärt: dark mode)</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>Notarization</strong> — App notariserad via Xcode Organizer</div></div>
<div class="check-item"><div class="check-box"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="check-text"><strong>TestFlight</strong> — Minst 10 externa testare godkänt bygget</div></div>
</div>
<div style="height:32px;"></div>
@@ -697,7 +697,7 @@ class</span> <span class="tp">LocationService</span>: <span class="tp">NSObject<
function copyCode(btn) {
const pre = btn.closest('.code-block').querySelector('pre');
navigator.clipboard.writeText(pre.innerText || pre.textContent).then(() => {
btn.textContent = ' Kopierat';
btn.textContent = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Kopierat';
setTimeout(() => btn.textContent = 'Kopiera', 2000);
});
}