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
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>iOS Readiness — quiXzoom</title>
<style>
/* ── iOS 26 / Apple Native — Dark Mode ───────────────────────────────── */
/* iOS 26 / Apple Native — Dark Mode */
:root {
--bg: #000000;
--bg-secondary: #1c1c1e;
@@ -43,7 +43,7 @@
overscroll-behavior-y: contain;
}
/* ── Nav Bar ─────────────────────────────────────────────────────────── */
/* Nav Bar */
.nav-bar {
position: sticky;
top: 0;
@@ -68,13 +68,13 @@
font-weight: 500;
font-family: var(--font);
padding: 6px 12px;
border-radius: 20px;
border-radius: var(--radius-xl);
cursor: pointer;
transition: opacity 0.15s;
}
.refresh-btn:active { opacity: 0.6; }
/* ── Hero Verdict ────────────────────────────────────────────────────── */
/* Hero Verdict */
.verdict-hero {
margin: 20px 16px 0;
border-radius: var(--radius-lg);
@@ -117,7 +117,7 @@
margin-top: 4px;
}
/* ── Stat Grid ───────────────────────────────────────────────────────── */
/* Stat Grid */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -162,7 +162,7 @@
letter-spacing: 0.3px;
}
/* ── Section ─────────────────────────────────────────────────────────── */
/* Section */
.section { margin-top: 28px; }
.section-hdr {
display: flex;
@@ -183,7 +183,7 @@
color: var(--label-tertiary);
}
/* ── List ────────────────────────────────────────────────────────────── */
/* List */
.list {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
@@ -201,7 +201,7 @@
.row-icon {
width: 32px; height: 32px;
border-radius: 8px;
border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 16px;
flex-shrink: 0;
@@ -237,7 +237,7 @@
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
border-radius: var(--radius-xl);
}
.pill-green { background: rgba(48,209,88,0.18); color: var(--green); }
.pill-red { background: rgba(255,69,58,0.18); color: var(--red); }
@@ -245,24 +245,24 @@
.pill-blue { background: rgba(0,122,255,0.18); color: var(--tint); }
.pill-grey { background: var(--fill); color: var(--label-secondary); }
.dot { width: 7px; height: 7px; border-radius: 50%; }
.dot { width: 7px; height: 7px; border-radius: var(--radius-full); }
.dot-green { background: var(--green); }
.dot-red { background: var(--red); }
.dot-yellow { background: var(--yellow); }
.dot-grey { background: var(--label-tertiary); }
/* ── Progress Bar ────────────────────────────────────────────────────── */
/* Progress Bar */
.progress-wrap { padding: 0 16px; }
.progress-bar-bg {
background: var(--fill);
border-radius: 4px;
border-radius: var(--radius-sm);
height: 8px;
overflow: hidden;
margin-top: 8px;
}
.progress-bar-fill {
height: 100%;
border-radius: 4px;
border-radius: var(--radius-sm);
transition: width 0.6s ease;
background: var(--green);
}
@@ -274,7 +274,7 @@
margin-top: 6px;
}
/* ── Blocker ─────────────────────────────────────────────────────────── */
/* Blocker */
.blocker-card {
background: rgba(255,69,58,0.08);
border: 1px solid rgba(255,69,58,0.25);
@@ -292,7 +292,7 @@
.blocker-desc { font-size: 13px; color: var(--label-secondary); margin-top: 3px; }
.blocker-owner { font-size: 12px; color: var(--label-tertiary); margin-top: 4px; }
/* ── Warning ──────────────────────────────────────────────────────────── */
/* Warning */
.warning-card {
background: rgba(255,214,10,0.08);
border: 1px solid rgba(255,214,10,0.25);
@@ -309,7 +309,7 @@
.warning-title { font-size: 15px; font-weight: 600; color: var(--yellow); }
.warning-desc { font-size: 13px; color: var(--label-secondary); margin-top: 3px; }
/* ── Deploy checklist ────────────────────────────────────────────────── */
/* Deploy checklist */
.check-item {
display: flex;
align-items: flex-start;
@@ -320,7 +320,7 @@
.check-item:last-child { border-bottom: none; }
.check-box {
width: 22px; height: 22px;
border-radius: 50%;
border-radius: var(--radius-full);
border: 2px solid var(--separator);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
@@ -335,7 +335,7 @@
.check-title { font-size: 15px; font-weight: 500; color: var(--label); }
.check-sub { font-size: 13px; color: var(--label-secondary); margin-top: 2px; }
/* ── Footer ──────────────────────────────────────────────────────────── */
/* Footer */
.footer {
text-align: center;
padding: 32px 20px 12px;
@@ -343,13 +343,13 @@
color: var(--label-tertiary);
}
/* ── Spinner ──────────────────────────────────────────────────────────── */
/* Spinner */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 16px; height: 16px;
border: 2px solid var(--fill);
border-top-color: var(--tint);
border-radius: 50%;
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
display: inline-block;
}
@@ -393,7 +393,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Nav Bar -->
<div class="nav-bar">
<div class="nav-title">📡 iOS Readiness</div>
<div class="nav-title"> iOS Readiness</div>
<div class="nav-right">
<span class="nav-time" id="navTime"></span>
<button class="refresh-btn" onclick="runCheck()">↻ Refresh</button>
@@ -458,7 +458,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Blockers -->
<div class="section">
<div class="section-hdr">
<span class="section-title">🚨 Kända blockers</span>
<span class="section-title"> Kända blockers</span>
<span class="section-badge" id="blockerCount"></span>
</div>
<div id="blockerList"></div>
@@ -467,7 +467,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Warnings -->
<div class="section">
<div class="section-hdr">
<span class="section-title">⚠️ Varningar</span>
<span class="section-title"><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> Varningar</span>
<span class="section-badge" id="warningCount"></span>
</div>
<div id="warningList"></div>
@@ -497,64 +497,64 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
<script>
// ── Data: Backend Features ───────────────────────────────────────────────
// Data: Backend Features
const FEATURES = [
{ name: 'BankID Authentication', icon: '🔐', status: 'done', note: 'Staging verifierat' },
{ name: 'Zoomer Registration', icon: '👤', status: 'done', note: 'POST /api/quixzoom/zoomers' },
{ name: 'Mission CRUD', icon: '📋', status: 'done', note: 'Full create/read/update' },
{ name: 'Geolocation Matching', icon: '📍', status: 'done', note: 'Radius-sökning aktiv' },
{ name: 'Real-time Push (APNs)', icon: '🔔', status: 'warn', note: 'APNs cert upphör aug 2026' },
{ name: 'Earnings & Payouts', icon: '💰', status: 'done', note: 'Beräkningar OK, utbetalning pending' },
{ name: 'KYC Document Upload', icon: '🪪', status: 'done', note: 'S3 + validering OK' },
{ name: 'Rating & Reviews', icon: '', status: 'done', note: 'Bi-directional ratings' },
{ name: 'Portfolio / Media', icon: '🖼️', status: 'done', note: 'Upload + CDN aktiv' },
{ name: 'Stripe Payouts', icon: '💳', status: 'warn', note: 'Testläge — ej prod-nyckel' },
{ name: 'DAC7 Reporting', icon: '📊', status: 'done', note: 'Automatisk generering OK' },
{ name: 'GDPR / Data Deletion', icon: '🗑️', status: 'pending', note: 'Legal review pågår' },
{ name: 'BankID Authentication', icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="7" width="10" height="7" rx="1" stroke="#666" stroke-width="1.5"/><path d="M5 7V5C5 3.3 6.3 2 8 2C9.7 2 11 3.3 11 5V7" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="10.5" r="1" fill="#666"/></svg>', status: 'done', note: 'Staging verifierat' },
{ name: 'Zoomer Registration', icon: '', status: 'done', note: 'POST /api/quixzoom/zoomers' },
{ name: 'Mission CRUD', icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg>', status: 'done', note: 'Full create/read/update' },
{ name: 'Geolocation Matching', icon: '', status: 'done', note: 'Radius-sökning aktiv' },
{ name: 'Real-time Push (APNs)', icon: '', status: 'warn', note: 'APNs cert upphör aug 2026' },
{ name: 'Earnings & Payouts', icon: '', status: 'done', note: 'Beräkningar OK, utbetalning pending' },
{ name: 'KYC Document Upload', icon: '', status: 'done', note: 'S3 + validering OK' },
{ name: 'Rating & Reviews', icon: '', status: 'done', note: 'Bi-directional ratings' },
{ name: 'Portfolio / Media', icon: '', status: 'done', note: 'Upload + CDN aktiv' },
{ name: 'Stripe Payouts', icon: '', status: 'warn', note: 'Testläge — ej prod-nyckel' },
{ name: 'DAC7 Reporting', icon: '', status: 'done', note: 'Automatisk generering OK' },
{ name: 'GDPR / Data Deletion', icon: '', status: 'pending', note: 'Legal review pågår' },
];
// ── Data: Blockers ───────────────────────────────────────────────────────
// Data: Blockers
const BLOCKERS = [
{
icon: '🔑',
icon: '',
title: 'Stripe Production Keys saknas',
desc: 'Utbetalningar kan ej köras i prod-miljö utan live Stripe-nycklar.',
owner: 'Ägare: Erik / Ops · Deadline: June 1'
},
{
icon: '📱',
icon: '',
title: 'APNs Production Certificate',
desc: 'Push-notiser till iOS kräver prod APNs-certifikat i staging-testet.',
owner: 'Ägare: iOS team · Deadline: May 30'
},
{
icon: '🇸🇪',
icon: '<span class="flag-se">SE</span>',
title: 'BankID Prod-avtal ej signerat',
desc: 'Testmiljö körs mot Sandbox. Prod-avtal med Finansiell ID-Teknik AB krävs.',
owner: 'Ägare: Erik · Deadline: May 28'
},
];
// ── Data: Warnings ───────────────────────────────────────────────────────
// Data: Warnings
const WARNINGS = [
{
icon: '',
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M9 2L5 9H8L7 14L11 7H8L9 2Z" fill="#f59e0b"/></svg>',
title: 'Rate limits ej konfigurerade för iOS',
desc: 'API-rate-limiting behöver tunas för mobila request-mönster.'
},
{
icon: '📦',
icon: '',
title: 'App Store metadata ej uppladdad',
desc: 'Screenshots, beskrivning och metadata saknas i App Store Connect.'
},
{
icon: '🔒',
icon: '',
title: 'SSL-cert auto-renewal ej verifierat',
desc: 'Kontrollera att certbot renew fungerar för amos.wavult.com.'
},
];
// ── Data: Deploy Checklist ───────────────────────────────────────────────
// Data: Deploy Checklist
const DEPLOY_ITEMS = [
{ title: 'API endpoints testade mot iOS simulator', sub: 'Backend', status: 'done' },
{ title: 'BankID Sandbox-integration verifierad', sub: 'Backend', status: 'done' },
@@ -573,7 +573,7 @@ const DEPLOY_ITEMS = [
{ title: 'GDPR Data Deletion verifierad', sub: 'Legal', status: 'pending' },
];
// ── Endpoints att kontrollera ─────────────────────────────────────────────
// Endpoints att kontrollera
const ENDPOINTS = [
{ label: 'Zoomer List', method: 'GET', url: 'https://api.quixzoom.com/api/qz/zoomers', critical: true },
{ label: 'Zoomer Profile', method: 'GET', url: 'https://api.quixzoom.com/api/qz/zoomers/zoomer-001/profile', critical: true },
@@ -584,15 +584,15 @@ const ENDPOINTS = [
{ label: 'Health Check', method: 'GET', url: '/api/health', critical: true },
];
// ── State ─────────────────────────────────────────────────────────────────
// State
let results = {};
// ── Render helpers ────────────────────────────────────────────────────────
// Render helpers
function statusClass(s) {
return s === 'done' ? 'green' : s === 'warn' ? 'yellow' : s === 'fail' ? 'red' : 'grey';
}
function statusIcon(s) {
return s === 'done' ? '' : s === 'warn' ? '!' : s === 'fail' ? '' : '';
return s === 'done' ? '<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>' : s === 'warn' ? '!' : s === 'fail' ? '' : '';
}
function statusPill(s, label) {
const cls = s === 'done' ? 'pill-green' : s === 'warn' ? 'pill-yellow' : s === 'fail' ? 'pill-red' : 'pill-grey';
@@ -600,7 +600,7 @@ function statusPill(s, label) {
return `<span class="status-pill ${cls}">${dot} ${label}</span>`;
}
// ── Render static sections ────────────────────────────────────────────────
// Render static sections
function renderFeatures() {
const el = document.getElementById('featureList');
el.innerHTML = FEATURES.map(f => `
@@ -660,7 +660,7 @@ function renderDeploy() {
el.innerHTML = DEPLOY_ITEMS.map(item => `
<div class="check-item">
<div class="check-box ${item.status === 'done' ? 'done' : item.status === 'fail' ? 'fail' : item.status === 'warn' ? 'warn' : 'pending'}">
${item.status === 'done' ? '' : item.status === 'fail' ? '' : item.status === 'warn' ? '!' : ''}
${item.status === 'done' ? '<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>' : item.status === 'fail' ? '' : item.status === 'warn' ? '!' : ''}
</div>
<div class="check-body">
<div class="check-title">${item.title}</div>
@@ -670,7 +670,7 @@ function renderDeploy() {
`).join('');
}
// ── Check an endpoint ─────────────────────────────────────────────────────
// Check an endpoint
async function checkEndpoint(ep) {
const t0 = Date.now();
try {
@@ -681,7 +681,7 @@ async function checkEndpoint(ep) {
}
}
// ── Main check ────────────────────────────────────────────────────────────
// Main check
async function runCheck() {
// Reset
document.getElementById('verdictHero').className = 'verdict-hero loading';
@@ -721,13 +721,13 @@ async function runCheck() {
okCount++;
const color = ep.critical ? 'green' : 'blue';
iconEl.className = `row-icon ${color}`;
iconEl.textContent = '';
iconEl.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>';
subEl.textContent = `HTTP ${res.status} · ${res.ms}ms`;
rightEl.innerHTML = `<span class="status-pill pill-green"><span class="dot dot-green"></span> OK</span>`;
} else {
failCount++;
iconEl.className = 'row-icon red';
iconEl.textContent = '';
iconEl.textContent = '';
subEl.textContent = res.error ? `Error: ${res.error}` : `HTTP ${res.status} · ${res.ms}ms`;
rightEl.innerHTML = `<span class="status-pill pill-red"><span class="dot dot-red"></span> FEL</span>`;
}
@@ -750,17 +750,17 @@ async function runCheck() {
if (failCount === 0 && hardBlockers === 0) {
hero.className = 'verdict-hero ready';
icon.textContent = '';
icon.textContent = '<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>';
ans.textContent = 'JA — Backend redo!';
sub.textContent = `Alla ${ENDPOINTS.length} API:er OK · Inga blockers · Klar för iOS`;
} else if (criticalFailing > 0 || hardBlockers >= 2) {
hero.className = 'verdict-hero not-ready';
icon.textContent = '🚫';
icon.textContent = '';
ans.textContent = 'NEJ — Ej release-redo';
sub.textContent = `${criticalFailing} kritiska API-fel · ${hardBlockers} blockers kvar`;
} else {
hero.className = 'verdict-hero partial';
icon.textContent = '⚠️';
icon.textContent = '<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>';
ans.textContent = 'DELVIS — Åtgärder krävs';
sub.textContent = `API:er OK · ${hardBlockers} blockers kvar att lösa`;
}
@@ -775,7 +775,7 @@ function updateTime() {
document.getElementById('footerTime').textContent = now.toLocaleString('sv-SE');
}
// ── Init ──────────────────────────────────────────────────────────────────
// Init
renderFeatures();
renderBlockers();
renderWarnings();