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
+42 -42
View File
@@ -35,7 +35,7 @@
overflow: hidden;
}
/* ── Header ─────────────────────────────────────────────── */
/* Header */
#header {
position: fixed;
top: 0; left: 0; right: 0;
@@ -72,7 +72,7 @@
#refresh-btn {
width: 32px; height: 32px;
border-radius: 50%;
border-radius: var(--radius-full);
background: var(--ios-blue);
border: none;
cursor: pointer;
@@ -84,7 +84,7 @@
#refresh-btn svg { width: 16px; height: 16px; fill: white; }
/* ── Search/Filter Bar ─────────────────────────────────── */
/* Search/Filter Bar */
#search-bar {
position: fixed;
top: calc(var(--safe-top) + 60px);
@@ -118,7 +118,7 @@
height: 36px;
background: var(--ios-white);
border: none;
border-radius: 10px;
border-radius: var(--radius-md);
padding: 0 10px 0 32px;
font-size: 15px;
font-family: inherit;
@@ -133,7 +133,7 @@
height: 36px;
background: var(--ios-white);
border: none;
border-radius: 10px;
border-radius: var(--radius-md);
padding: 0 10px;
font-size: 13px;
font-family: inherit;
@@ -143,7 +143,7 @@
min-width: 100px;
}
/* ── Map ────────────────────────────────────────────────── */
/* Map */
#map {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
@@ -160,14 +160,14 @@
width: 36px !important;
height: 36px !important;
line-height: 36px !important;
border-radius: 10px !important;
border-radius: var(--radius-md) !important;
}
/* ── Custom Pins ─────────────────────────────────────────── */
/* Custom Pins */
.mission-pin {
width: 36px;
height: 36px;
border-radius: 50%;
border-radius: var(--radius-full);
border: 2.5px solid white;
display: flex;
align-items: center;
@@ -184,9 +184,9 @@
.pin-assigned { background: var(--ios-orange); }
.pin-completed { background: var(--ios-green); }
/* ── Popup ─────────────────────────────────────────────── */
/* Popup */
.leaflet-popup-content-wrapper {
border-radius: 14px !important;
border-radius: var(--radius-lg) !important;
box-shadow: 0 8px 30px rgba(0,0,0,0.18) !important;
padding: 0 !important;
overflow: hidden;
@@ -212,7 +212,7 @@
letter-spacing: 0.5px;
color: var(--ios-blue);
background: rgba(0,122,255,0.1);
border-radius: 6px;
border-radius: var(--radius-sm);
padding: 3px 8px;
margin-bottom: 8px;
}
@@ -233,7 +233,7 @@
.popup-meta-item {
background: var(--ios-gray6);
border-radius: 10px;
border-radius: var(--radius-md);
padding: 8px 10px;
}
@@ -256,7 +256,7 @@
gap: 4px;
font-size: 12px;
font-weight: 600;
border-radius: 20px;
border-radius: var(--radius-xl);
padding: 4px 10px;
margin-top: 10px;
}
@@ -280,7 +280,7 @@
gap: 5px;
font-size: 11px;
font-weight: 700;
border-radius: 8px;
border-radius: var(--radius-md);
padding: 4px 9px;
letter-spacing: 0.2px;
}
@@ -290,7 +290,7 @@
.risk-L4 { background: rgba(255,59,48,0.13); color: #a01a10; }
.risk-hint { font-size: 11px; color: var(--ios-label2); line-height: 1.3; flex: 1; text-align: right; }
/* ── Bottom Sheet ─────────────────────────────────────── */
/* Bottom Sheet */
#bottom-sheet {
position: fixed;
bottom: 0; left: 0; right: 0;
@@ -299,13 +299,13 @@
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
border-top: 0.5px solid var(--ios-separator);
border-radius: 20px 20px 0 0;
border-radius: var(--radius-xl) 20px 0 0;
padding: 12px 16px calc(var(--safe-bottom) + 12px);
}
#sheet-handle {
width: 36px; height: 4px;
border-radius: 2px;
border-radius: var(--radius-sm);
background: var(--ios-label3);
margin: 0 auto 14px;
}
@@ -319,7 +319,7 @@
.stat-card {
flex: 1;
background: var(--ios-white);
border-radius: 14px;
border-radius: var(--radius-lg);
padding: 12px 14px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
@@ -358,10 +358,10 @@
.legend-dot {
width: 10px; height: 10px;
border-radius: 50%;
border-radius: var(--radius-full);
}
/* ── Loading Indicator ────────────────────────────────── */
/* Loading Indicator */
#loading-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
@@ -381,7 +381,7 @@
width: 40px; height: 40px;
border: 3px solid var(--ios-gray5);
border-top-color: var(--ios-blue);
border-radius: 50%;
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
}
@@ -392,7 +392,7 @@
color: var(--ios-label2);
}
/* ── Urban Intelligence Layer ──────────────────────── */
/* Urban Intelligence Layer */
#ui-toggle {
position: fixed;
top: calc(var(--safe-top) + 110px);
@@ -402,7 +402,7 @@
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
border: 0.5px solid var(--ios-separator);
border-radius: 12px;
border-radius: var(--radius-md);
padding: 8px 12px;
display: flex;
align-items: center;
@@ -416,27 +416,27 @@
transition: opacity 0.2s;
}
#ui-toggle:active { opacity: 0.7; }
#ui-dot { width:10px;height:10px;border-radius:50%;background:var(--ios-blue);flex-shrink:0; }
#ui-dot { width:10px;height:10px;border-radius: var(--radius-full);background:var(--ios-blue);flex-shrink:0; }
#ui-toggle.off #ui-dot { background: var(--ios-gray2); }
.ui-popup { font-family: -apple-system,BlinkMacSystemFont,'SF Pro Text',sans-serif; min-width:220px; }
.ui-popup-header { display:flex;align-items:center;justify-content:space-between;margin-bottom:8px; }
.ui-popup-title { font-size:13px;font-weight:700;letter-spacing:-0.2px;color:#1c1c1e; }
.ui-confidence { font-size:11px;color:#8e8e93;background:#f2f2f7;border-radius:6px;padding:2px 7px;font-weight:500; }
.ui-confidence { font-size:11px;color:#8e8e93;background:#f2f2f7;border-radius: var(--radius-sm);padding:2px 7px;font-weight:500; }
.ui-scores { display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px; }
.ui-score-item { background:#f2f2f7;border-radius:8px;padding:6px 8px; }
.ui-score-item { background:#f2f2f7;border-radius: var(--radius-md);padding:6px 8px; }
.ui-score-label { font-size:10px;color:#8e8e93;font-weight:500;margin-bottom:2px; }
.ui-score-bar { height:4px;border-radius:2px;background:#e5e5ea;margin-bottom:2px; }
.ui-score-fill { height:100%;border-radius:2px; }
.ui-score-bar { height:4px;border-radius: var(--radius-sm);background:#e5e5ea;margin-bottom:2px; }
.ui-score-fill { height:100%;border-radius: var(--radius-sm); }
.ui-score-val { font-size:12px;font-weight:700;color:#1c1c1e; }
.ui-contradiction { border-radius:8px;padding:7px 10px;font-size:12px;line-height:1.5;margin-bottom:8px; }
.ui-contradiction { border-radius: var(--radius-md);padding:7px 10px;font-size:12px;line-height:1.5;margin-bottom:8px; }
.ui-contradiction.low { background:rgba(52,199,89,0.12);color:#1c7a3a; }
.ui-contradiction.medium { background:rgba(255,149,0,0.12);color:#a05a00; }
.ui-contradiction.high { background:rgba(255,59,48,0.12);color:#a01a10; }
.ui-contradiction-label { font-weight:700;display:block;margin-bottom:2px; }
.ui-assessment { font-size:11px;color:#8e8e93;border-top:0.5px solid #e5e5ea;padding-top:6px;line-height:1.4; }
/* ── Refresh pulse ────────────────────────────────────── */
/* Refresh pulse */
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(0,122,255,0.4); }
70% { box-shadow: 0 0 0 8px rgba(0,122,255,0); }
@@ -545,13 +545,13 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
<script>
// ── Config ────────────────────────────────────────────────
// Config
const API_URL = '/zoomer/missions?limit=100';
const REFRESH_INTERVAL = 30000;
const SWEDEN_CENTER = [62.0, 16.5];
const INITIAL_ZOOM = 5;
const PIN_EMOJI = { open: '📍', assigned: '🔵', completed: '' };
const PIN_EMOJI = { open: '', assigned: '', completed: '<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>' };
const STATUS_COLOR = { open: '#007AFF', assigned: '#FF9500', completed: '#34C759' };
const STATUS_LABEL = { open: 'Öppen', assigned: 'Pågående', completed: 'Klar' };
@@ -573,12 +573,12 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
return 'L1';
}
// ── State ─────────────────────────────────────────────────
// State
let allMissions = [];
let markers = {};
let refreshTimer = null;
// ── Map Init ──────────────────────────────────────────────
// Map Init
const map = L.map('map', {
center: SWEDEN_CENTER,
zoom: INITIAL_ZOOM,
@@ -594,7 +594,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
// Subtle attribution
L.control.attribution({ position: 'bottomright', prefix: '© OSM' }).addTo(map);
// ── Marker creation ────────────────────────────────────────
// Marker creation
function createPinIcon(status) {
const color = STATUS_COLOR[status] || '#8E8E93';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="44" viewBox="0 0 36 44">
@@ -636,7 +636,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
</div>
<div class="popup-status-badge ${statusClass}">
<span></span> ${STATUS_LABEL[m.status] || m.status}
<span></span> ${STATUS_LABEL[m.status] || m.status}
</div>
<div class="popup-risk">
<span class="risk-badge risk-${rl}">${RISK_LEVELS[rl].label}</span>
@@ -649,7 +649,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Load Missions ─────────────────────────────────────────
// Load Missions
async function loadMissions(manual = false) {
const btn = document.getElementById('refresh-btn');
btn.classList.add('loading');
@@ -677,7 +677,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
document.getElementById('loading-overlay').classList.add('hidden');
} catch (e) {
console.error('Failed to load missions:', e);
document.getElementById('loading-text').textContent = 'Kunde inte ladda missioner ⚠️';
document.getElementById('loading-text').textContent = 'Kunde inte ladda missioner <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>';
} finally {
btn.classList.remove('loading');
// Schedule next refresh
@@ -735,7 +735,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
: `${totalReward}`;
}
// ── Urban Intelligence Layer ──────────────────────────
// Urban Intelligence Layer
// Demo zones — production: fetch from /api/urban-intelligence/zones
const UI_ZONES = [
@@ -765,7 +765,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
darkred: {color:'#8B0000',fillOpacity:0.30,weight:2.5,dash:'4 3'}
};
const C_LABELS = {low:'Contradiction Score: Låg',medium:'Contradiction Score: Medel',high:'Contradiction Score: HÖG ⚠️'};
const C_LABELS = {low:'Contradiction Score: Låg',medium:'Contradiction Score: Medel',high:'Contradiction Score: HÖG <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>'};
let uiGroup = L.layerGroup().addTo(map);
let uiOn = true;
@@ -813,7 +813,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
tog.onclick = () => { uiOn=!uiOn; tog.classList.toggle('off',!uiOn); renderUI(); };
document.body.appendChild(tog);
// ── Start ─────────────────────────────────────────────────
// Start
loadMissions();
</script>
</body>