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
+52 -52
View File
@@ -7,7 +7,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>quiXzoom — Launch Status</title>
<style>
/* ── iOS System Font Stack ───────────────────────────────────────────── */
/* iOS System Font Stack */
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
:root {
--ios-blue: #007AFF;
@@ -33,7 +33,7 @@
padding-bottom: env(safe-area-inset-bottom, 20px);
}
/* ── Status Bar ──────────────────────────────────────────────────────── */
/* Status Bar */
.status-bar {
display: flex;
align-items: center;
@@ -46,7 +46,7 @@
.status-bar .time { font-variant-numeric: tabular-nums; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
/* ── Navigation Bar ──────────────────────────────────────────────────── */
/* Navigation Bar */
.nav-bar {
padding: 6px 16px 12px;
display: flex;
@@ -66,18 +66,18 @@
font-size: 12px;
font-weight: 600;
padding: 5px 10px;
border-radius: 12px;
border-radius: var(--radius-md);
letter-spacing: 0.3px;
}
.launch-badge.warning { background: var(--ios-orange); }
/* ── Scroll Container ────────────────────────────────────────────────── */
/* Scroll Container */
.scroll { overflow-y: auto; padding: 0 16px 32px; }
/* ── Hero Card ───────────────────────────────────────────────────────── */
/* Hero Card */
.hero-card {
background: linear-gradient(135deg, #FF6D00 0%, #FF3D00 100%);
border-radius: 22px;
border-radius: var(--radius-xl);
padding: 24px;
color: white;
margin-bottom: 20px;
@@ -89,7 +89,7 @@
position: absolute;
right: -30px; top: -30px;
width: 140px; height: 140px;
border-radius: 50%;
border-radius: var(--radius-full);
background: rgba(255,255,255,0.12);
}
.hero-card::after {
@@ -97,7 +97,7 @@
position: absolute;
right: 30px; top: 40px;
width: 80px; height: 80px;
border-radius: 50%;
border-radius: var(--radius-full);
background: rgba(255,255,255,0.08);
}
.hero-label {
@@ -125,7 +125,7 @@
}
.countdown-unit {
background: rgba(255,255,255,0.22);
border-radius: 14px;
border-radius: var(--radius-lg);
padding: 12px 16px;
text-align: center;
backdrop-filter: blur(4px);
@@ -143,7 +143,7 @@
letter-spacing: 0.5px;
}
/* ── Section Header ──────────────────────────────────────────────────── */
/* Section Header */
.section-header {
font-size: 20px;
font-weight: 700;
@@ -151,7 +151,7 @@
margin: 24px 0 10px;
}
/* ── Stats Row ───────────────────────────────────────────────────────── */
/* Stats Row */
.stats-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -160,7 +160,7 @@
}
.stat-card {
background: var(--ios-card);
border-radius: 18px;
border-radius: var(--radius-lg);
padding: 18px 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
@@ -183,10 +183,10 @@
font-weight: 500;
}
/* ── Progress Bar ────────────────────────────────────────────────────── */
/* Progress Bar */
.readiness-card {
background: var(--ios-card);
border-radius: 18px;
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
@@ -205,14 +205,14 @@
.readiness-label { font-size: 15px; font-weight: 500; color: var(--ios-gray); }
.progress-track {
background: var(--ios-gray6);
border-radius: 8px;
border-radius: var(--radius-md);
height: 10px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
border-radius: 8px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--ios-green) 0%, #30D158 100%);
transition: width 1.2s cubic-bezier(0.16,1,0.3,1);
}
@@ -221,7 +221,7 @@
color: var(--ios-gray);
}
/* ── Cities ──────────────────────────────────────────────────────────── */
/* Cities */
.cities-row {
display: flex;
flex-wrap: wrap;
@@ -230,7 +230,7 @@
}
.city-chip {
background: var(--ios-card);
border-radius: 20px;
border-radius: var(--radius-xl);
padding: 8px 14px;
font-size: 14px;
font-weight: 500;
@@ -241,14 +241,14 @@
}
.city-chip .dot {
width: 8px; height: 8px;
border-radius: 50%;
border-radius: var(--radius-full);
background: var(--ios-green);
}
/* ── Checklist ───────────────────────────────────────────────────────── */
/* Checklist */
.checklist-group {
background: var(--ios-card);
border-radius: 18px;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
margin-bottom: 20px;
@@ -263,7 +263,7 @@
.check-item:last-child { border-bottom: none; }
.check-icon {
width: 32px; height: 32px;
border-radius: 50%;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
@@ -289,14 +289,14 @@
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: 8px;
border-radius: var(--radius-md);
letter-spacing: 0.2px;
flex-shrink: 0;
}
.check-badge.done { background: rgba(52,199,89,0.15); color: #27a745; }
.check-badge.pending { background: rgba(255,149,0,0.15); color: #c87000; }
/* ── Footer ──────────────────────────────────────────────────────────── */
/* Footer */
.footer {
text-align: center;
font-size: 13px;
@@ -305,16 +305,16 @@
}
.footer strong { color: var(--ios-label); }
/* ── Skeleton / loading ──────────────────────────────────────────────── */
/* Skeleton / loading */
.skeleton {
background: linear-gradient(90deg, #e8e8e8 25%, #f5f5f5 50%, #e8e8e8 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
border-radius: var(--radius-md);
}
@keyframes shimmer { to { background-position: -200% 0; } }
/* ── Dark Mode ───────────────────────────────────────────────────────── */
/* Dark Mode */
@media (prefers-color-scheme: dark) {
:root {
--ios-bg: #000000;
@@ -326,11 +326,11 @@
.skeleton { background: linear-gradient(90deg, #2a2a2a 25%, #383838 50%, #2a2a2a 75%); background-size: 200% 100%; }
}
/* ── Pulse dot ───────────────────────────────────────────────────────── */
/* Pulse dot */
.live-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
border-radius: var(--radius-full);
background: var(--ios-green);
box-shadow: 0 0 0 0 rgba(52,199,89,0.5);
animation: pulse 2s infinite;
@@ -343,7 +343,7 @@
100%{ box-shadow: 0 0 0 0 rgba(52,199,89,0); }
}
/* ── Refresh button ──────────────────────────────────────────────────── */
/* Refresh button */
.refresh-btn {
display: flex;
align-items: center;
@@ -353,7 +353,7 @@
background: var(--ios-blue);
color: white;
border: none;
border-radius: 14px;
border-radius: var(--radius-lg);
padding: 16px;
font-size: 16px;
font-weight: 600;
@@ -438,22 +438,22 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="section-header">Live-statistik</div>
<div class="stats-row">
<div class="stat-card">
<span class="icon">📍</span>
<span class="icon"></span>
<span class="val" id="stat-missions"></span>
<span class="lbl">Uppdrag aktiva</span>
</div>
<div class="stat-card">
<span class="icon"></span>
<span class="icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M9 2L5 9H8L7 14L11 7H8L9 2Z" fill="#f59e0b"/></svg></span>
<span class="val" id="stat-zoomers"></span>
<span class="lbl">Zoomers aktiva</span>
</div>
<div class="stat-card">
<span class="icon">🏙️</span>
<span class="icon"></span>
<span class="val" id="stat-cities"></span>
<span class="lbl">Städer täckta</span>
</div>
<div class="stat-card">
<span class="icon">📅</span>
<span class="icon"></span>
<span class="val" id="stat-days"></span>
<span class="lbl">Dagar kvar</span>
</div>
@@ -485,7 +485,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
<!-- Checklist: Done -->
<div class="section-header">Klart </div>
<div class="section-header">Klart <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></div>
<div class="checklist-group" id="done-list">
<div class="check-item">
<div class="check-icon done skeleton" style="background:transparent"></div>
@@ -520,7 +520,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div><!-- /scroll -->
<script>
// ── Clock ──────────────────────────────────────────────────────────────
// Clock
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent =
@@ -530,7 +530,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
updateClock();
setInterval(updateClock, 30000);
// ── Countdown ─────────────────────────────────────────────────────────
// Countdown
function updateCountdown() {
const launch = new Date('2026-06-15T07:00:00+02:00');
const now = new Date();
@@ -545,8 +545,8 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
updateCountdown();
setInterval(updateCountdown, 60000);
// ── Build checklist item ───────────────────────────────────────────────
const ICONS = { done: '', pending: '⏳' };
// Build checklist item
const ICONS = { done: '<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>', pending: '⏳' };
function buildItem(c) {
const div = document.createElement('div');
div.className = 'check-item';
@@ -561,7 +561,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
return div;
}
// ── Load data ──────────────────────────────────────────────────────────
// Load data
async function loadData() {
try {
// Use a dummy JWT for auth (dev mode)
@@ -580,7 +580,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
const data = await readRes.json();
const stats = statsRes.ok ? await statsRes.json() : null;
// ── Stats cards
// Stats cards
const activeMissions = stats ? (stats.missions.open + stats.missions.active) : data.summary.done;
const activeZoomers = stats ? (stats.zoomers.available + stats.zoomers.busy) : 3;
document.getElementById('stat-missions').textContent = activeMissions;
@@ -588,7 +588,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
document.getElementById('stat-cities').textContent = data.summary.cities;
document.getElementById('stat-days').textContent = data.days_to_launch;
// ── Readiness
// Readiness
const pct = data.readiness_pct;
document.getElementById('readiness-pct').textContent = pct + '%';
document.getElementById('done-count').textContent = data.summary.done;
@@ -602,17 +602,17 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
document.getElementById('progress-fill').style.width = pct + '%';
}, 100);
// ── Go badge
// Go badge
const badge = document.getElementById('go-badge');
if (data.launch_go) {
badge.textContent = '🚀 GO';
badge.textContent = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2L10 8L14 9L8 14L6 8L2 7L8 2Z" fill="#666"/></svg> GO';
badge.className = 'launch-badge';
} else {
badge.textContent = '⏳ ' + data.days_to_launch + 'd kvar';
badge.className = 'launch-badge warning';
}
// ── Cities
// Cities
const citiesRow = document.getElementById('cities-row');
citiesRow.innerHTML = '';
(data.summary.cities_list || []).forEach(city => {
@@ -622,7 +622,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
citiesRow.appendChild(chip);
});
// ── Checklist
// Checklist
const doneList = document.getElementById('done-list');
const pendingList = document.getElementById('pending-list');
doneList.innerHTML = '';
@@ -639,16 +639,16 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
if (doneList.children.length === 0)
doneList.innerHTML = '<div class="check-item" style="justify-content:center;color:var(--ios-gray);font-size:14px;padding:20px;">Inga klara ännu</div>';
if (pendingList.children.length === 0)
pendingList.innerHTML = '<div class="check-item" style="justify-content:center;color:var(--ios-green);font-size:14px;padding:20px;">Allt klart! 🎉</div>';
pendingList.innerHTML = '<div class="check-item" style="justify-content:center;color:var(--ios-green);font-size:14px;padding:20px;">Allt klart! </div>';
// ── Timestamp
// Timestamp
document.getElementById('last-updated').textContent =
'Uppdaterat ' + new Date().toLocaleTimeString('sv-SE', { hour:'2-digit', minute:'2-digit' });
} catch (err) {
console.error('Launch readiness load error:', err);
document.getElementById('readiness-sub').textContent = '⚠️ Kunde inte ladda data — kontrollera API';
document.getElementById('go-badge').textContent = '⚠️ Fel';
document.getElementById('readiness-sub').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> Kunde inte ladda data — kontrollera API';
document.getElementById('go-badge').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> Fel';
document.getElementById('go-badge').className = 'launch-badge warning';
}
}