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
+67 -52
View File
@@ -46,6 +46,9 @@
font-family: var(--font-display); font-size: 17px; font-weight: 600;
letter-spacing: -0.41px;
}
.nav-bar .nav-btn-sm:last-child {
margin-left: auto;
}
.nav-logo {
background: linear-gradient(135deg, #0A84FF 0%, #5E5CE6 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
@@ -488,7 +491,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="sheet-card">
<div class="sheet-header">
<div class="sheet-title" id="sheet-title"></div>
<button class="sheet-close" onclick="closeSheet()"></button>
<button class="sheet-close" onclick="closeSheet()">&times;</button>
</div>
<div class="sheet-meta" id="sheet-meta"></div>
<div class="sheet-desc" id="sheet-desc"></div>
@@ -503,6 +506,13 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div id="page-mina" class="tab-page">
<nav class="nav-bar">
<span class="nav-title">Mina uppdrag</span>
<button class="nav-btn-sm" onclick="refreshMissions()" title="Uppdatera" style="margin-left:auto;z-index:10;position:relative;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--ios-blue)">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
</nav>
<div class="segment-wrap">
<div class="segment">
@@ -512,13 +522,13 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
</div>
<div id="active-banner" class="active-banner">
<div class="ab-label">🔥 Aktivt uppdrag</div>
<div class="ab-label">AKTIVT UPPDRAG</div>
<div class="ab-title" id="ab-title"></div>
<div class="ab-loc" id="ab-loc"></div>
<div class="ab-actions">
<button class="ab-btn btn-cam" onclick="openUploadSheet()">📷 Ladda upp media</button>
<button class="ab-btn btn-done" onclick="completeActiveMission()">Slutför</button>
<button class="ab-btn btn-cancel-sm" onclick="cancelActiveMission()"></button>
<button class="ab-btn btn-cam" onclick="openUploadSheet()">Ladda upp media</button>
<button class="ab-btn btn-done" onclick="completeActiveMission()">Slutför</button>
<button class="ab-btn btn-cancel-sm" onclick="cancelActiveMission()">&times;</button>
</div>
</div>
<div class="section-hdr" id="mina-section-lbl">Öppna uppdrag</div>
@@ -540,8 +550,8 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<span class="eh-btn-icon">💳</span><span class="eh-btn-label">Ta ut</span>
</button>
<button class="eh-btn" onclick="showToast('🧾 Exporterar årssammanfattning...')">
<span class="eh-btn-icon">🧾</span><span class="eh-btn-label">Skatt</span>
<button class="eh-btn" onclick="showToast('Exporterar årssammanfattning...')">
<span class="eh-btn-icon">Skatt</span><span class="eh-btn-label">Skatt</span>
</button>
</div>
</div>
@@ -567,7 +577,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="payout-badge pb-pending">Pågår</div>
</div>
<div class="payout-row">
<div class="pr-icon pri-credit">💰</div>
<div class="pr-icon pri-credit">+</div>
<div class="pr-body">
<div class="pr-title">Fastighetsfilm — Lidingö</div>
<div class="pr-sub">Verifieras · förfaller 5 jun</div>
@@ -600,7 +610,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="lb-hero-title">Top Zoomers</div>
<div class="lb-hero-sub">Sverige · Augusti 2026 · Rankings uppdateras live</div>
<div class="lb-totals" id="lb-totals">
<div class="lb-total-chip">Laddar...</div>
<div class="lb-total-chip">Laddar...</div>
</div>
</div>
<div class="segment-wrap">
@@ -617,22 +627,22 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- ══════════════════════════════ TAB BAR ══════════════════════════════ -->
<nav class="tab-bar">
<button class="tab-item active" id="tab-karta" onclick="switchTab('karta')">
<span class="tab-icon">🗺️</span>
<span class="tab-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l7-4 6 4 7-4v17l-7 4-6-4-7 4z"/></svg></span>
<span class="tab-label">Karta</span>
</button>
<button class="tab-item" id="tab-mina" onclick="switchTab('mina')">
<span class="tab-badge-wrap">
<span class="tab-icon">📋</span>
<span class="tab-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
<span class="tab-dot" id="mina-dot"></span>
</span>
<span class="tab-label">Mina</span>
</button>
<button class="tab-item" id="tab-intakter" onclick="switchTab('intakter')">
<span class="tab-icon">💰</span>
<span class="tab-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v12M8 10h8M8 14h8"/></svg></span>
<span class="tab-label">Intäkter</span>
</button>
<button class="tab-item" id="tab-leaderboard" onclick="switchTab('leaderboard')">
<span class="tab-icon">🏆</span>
<span class="tab-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9H4.5a2.5 2.5 0 010-5H6"/><path d="M18 9h1.5a2.5 2.5 0 000-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 006 6 6 6 0 006-6V2z"/></svg></span>
<span class="tab-label">Topp</span>
</button>
</nav>
@@ -646,7 +656,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="upload-sub">Bifoga foton eller video som bevis på slutfört uppdrag</div>
<div class="upload-previews" id="upload-previews"></div>
<div class="upload-actions">
<button class="upload-btn" style="background:var(--fill-secondary);color:var(--label-primary)" onclick="triggerFileInput()">📷 Välj fil</button>
<button class="upload-btn" style="background:var(--fill-secondary);color:var(--label-primary)" onclick="triggerFileInput()">Välj fil</button>
<button class="upload-btn btn-primary" onclick="submitMedia()">Skicka bevis </button>
</div>
</div>
@@ -709,13 +719,13 @@ function updateMinaDot() {
// ══════════════════════════════════════════════════════════════════════════
const TYPE_CFG = {
photo: { icon: '📷', color: '#0A84FF', bg: 'rgba(10,132,255,0.2)' },
video: { icon: '🎬', color: '#5E5CE6', bg: 'rgba(94,92,230,0.2)' },
drone: { icon: '🚁', color: '#FF9500', bg: 'rgba(255,149,0,0.2)' },
survey: { icon: '📋', color: '#34C759', bg: 'rgba(52,199,89,0.2)' },
audit: { icon: '🔍', color: '#FF2D55', bg: 'rgba(255,45,85,0.2)' },
photo: { icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v12M8 10h8M8 14h8"/></svg>', color: '#0A84FF', bg: 'rgba(10,132,255,0.2)' },
video: { icon: 'V', color: '#5E5CE6', bg: 'rgba(94,92,230,0.2)' },
drone: { icon: 'D', color: '#FF9500', bg: 'rgba(255,149,0,0.2)' },
survey: { icon: 'S', color: '#34C759', bg: 'rgba(52,199,89,0.2)' },
audit: { icon: 'A', color: '#FF2D55', bg: 'rgba(255,45,85,0.2)' },
};
function getCfg(t) { return TYPE_CFG[t] || { icon: '📍', color: '#8E8E93', bg: 'rgba(142,142,147,0.2)' }; }
function getCfg(t) { return TYPE_CFG[t] || { icon: '+', color: '#8E8E93', bg: 'rgba(142,142,147,0.2)' }; }
async function fetchMapMissions(city = '') {
try {
@@ -769,7 +779,12 @@ function setCityFilter(city, btn) {
function refreshMap() {
initMap();
showToast('🔄 Uppdaterar karta...');
showToast('Uppdaterar karta...');
}
function refreshMissions() {
loadMinaMissions();
showToast('Uppdaterar uppdrag...');
}
function openMapSheet(mission) {
@@ -782,7 +797,7 @@ function openMapSheet(mission) {
<span class="sheet-chip chip-reward">${mission.reward_sek?.toLocaleString('sv-SE')} kr</span>
<span class="sheet-chip chip-city">${mission.location?.city || '—'}</span>
<span class="sheet-chip chip-type">${cfg.icon} ${mission.mission_type}</span>
<span class="sheet-chip chip-dead"> ${deadline}</span>`;
<span class="sheet-chip chip-dead">+ ${deadline}</span>`;
const m = getActiveMission();
const btn = document.getElementById('sheet-accept-btn');
if (m) { btn.textContent = 'Du har ett aktivt uppdrag'; btn.disabled = true; btn.style.opacity = '0.5'; }
@@ -807,16 +822,16 @@ async function acceptFromSheet() {
const d = await r.json();
if (d.ok) {
setActiveMission(m);
showToast('Uppdrag accepterat! Kr ' + m.reward_sek?.toLocaleString('sv-SE'));
showToast('Uppdrag accepterat! Kr ' + m.reward_sek?.toLocaleString('sv-SE'));
allMapMissions = allMapMissions.filter(x => x.id !== m.id);
renderMapMarkers();
} else {
showToast('⚠️ ' + (d.error || 'Fel vid accepterande'));
showToast('! ' + (d.error || 'Fel vid accepterande'));
}
} catch {
// Offline demo fallback
setActiveMission(m);
showToast('Uppdrag accepterat (offline)! Kr ' + m.reward_sek?.toLocaleString('sv-SE'));
showToast('Uppdrag accepterat (offline)! Kr ' + m.reward_sek?.toLocaleString('sv-SE'));
allMapMissions = allMapMissions.filter(x => x.id !== m.id);
renderMapMarkers();
}
@@ -888,13 +903,13 @@ function renderMyMissions() {
<div class="mc-icon" style="background:${cfg.bg}">${cfg.icon}</div>
<div class="mc-body">
<div class="mc-title">${m.title}</div>
<div class="mc-loc">📍 ${m.location?.address || m.location?.city || 'Okänd plats'}${deadline ? ' · '+deadline : ''}</div>
<div class="mc-loc">+ ${m.location?.address || m.location?.city || 'Okänd plats'}${deadline ? ' · + '+deadline : ''}</div>
</div>
<div class="mc-reward">${(m.reward_sek||0).toLocaleString('sv-SE')}<small> kr</small></div>
</div>
<div class="mc-bottom">
<span class="badge ${statusCls}">${statusLbl}</span>
<div class="mc-cta">${isActive ? 'Pågår' : m.status === 'completed' ? 'Klart' : 'Detaljer'} </div>
<div class="mc-cta">${isActive ? 'Pågår' : m.status === 'completed' ? 'Klart' : 'Detaljer'} </div>
</div>
</div>`;
}).join('');
@@ -906,14 +921,14 @@ function renderActiveBanner() {
if (!m) { banner.classList.remove('show'); return; }
banner.classList.add('show');
document.getElementById('ab-title').textContent = m.title;
document.getElementById('ab-loc').textContent = '📍 ' + (m.location?.address || m.location?.city || '—') + ' · Kr ' + (m.reward_sek||0).toLocaleString('sv-SE');
document.getElementById('ab-loc').textContent = '+ ' + (m.location?.address || m.location?.city || '—') + ' · Kr ' + (m.reward_sek||0).toLocaleString('sv-SE');
}
function openMissionDetail(id) {
const m = myMissions.find(x => x.id === id) || (getActiveMission()?.id === id ? getActiveMission() : null);
if (!m) return;
if (m.status === 'open') {
if (getActiveMission()) { showToast('⚠️ Du har redan ett aktivt uppdrag'); return; }
if (getActiveMission()) { showToast('! Du har redan ett aktivt uppdrag'); return; }
if (confirm(`Acceptera "${m.title}" för ${(m.reward_sek||0).toLocaleString('sv-SE')} kr?`)) {
acceptMission(m);
}
@@ -932,12 +947,12 @@ async function acceptMission(m) {
const d = await r.json();
if (d.ok || true) {
setActiveMission(m);
showToast('Uppdrag accepterat!');
showToast('Uppdrag accepterat!');
loadMinaMissions();
}
} catch {
setActiveMission(m);
showToast('Uppdrag accepterat!');
showToast('Uppdrag accepterat!');
loadMinaMissions();
}
}
@@ -956,14 +971,14 @@ async function completeActiveMission() {
if (d.ok || d.pay_released) {
setActiveMission(null);
uploadedMediaUrls = [];
showToast('🎉 Uppdrag slutfört! Kr ' + (m.reward_sek||0).toLocaleString('sv-SE') + ' utbetalas');
showToast('+ Uppdrag slutfört! Kr ' + (m.reward_sek||0).toLocaleString('sv-SE') + ' utbetalas');
loadMinaMissions();
return;
}
} catch { /* fallthrough */ }
setActiveMission(null);
uploadedMediaUrls = [];
showToast('🎉 Uppdrag slutfört! Betalning på väg.');
showToast('+ Uppdrag slutfört! Betalning på väg.');
loadMinaMissions();
}
@@ -995,7 +1010,7 @@ document.getElementById('file-input').addEventListener('change', function() {
uploadedMediaUrls.push(fakeUploadUrl);
});
renderUploadPreviews();
if (files.length) showToast('📎 ' + files.length + ' fil(er) bifogade');
if (files.length) showToast('+ ' + files.length + ' fil(er) bifogade');
});
function renderUploadPreviews() {
const el = document.getElementById('upload-previews');
@@ -1004,13 +1019,13 @@ function renderUploadPreviews() {
return;
}
el.innerHTML = uploadedMediaUrls.map((u,i) =>
`<div class="preview-thumb" title="${u}">📷</div>`
`<div class="preview-thumb" title="${u}">+</div>`
).join('') + `<div class="preview-thumb" style="cursor:pointer;font-size:20px" onclick="triggerFileInput()">+</div>`;
}
function submitMedia() {
if (!uploadedMediaUrls.length) { showToast('⚠️ Välj minst en fil'); return; }
if (!uploadedMediaUrls.length) { showToast('! Välj minst en fil'); return; }
closeUploadSheet();
showToast('📤 Media uppladdad! Klicka Slutför när du är klar.');
showToast('+ Media uppladdad! Klicka Slutför när du är klar.');
}
// ══════════════════════════════════════════════════════════════════════════
@@ -1074,25 +1089,25 @@ function renderLbTotals() {
if (!lbData?.platform_totals) return;
const t = lbData.platform_totals;
document.getElementById('lb-totals').innerHTML = `
<div class="lb-total-chip">🎯 ${t.missions_completed?.toLocaleString('sv-SE')} uppdrag</div>
<div class="lb-total-chip">💰 ${(t.total_paid_out_sek/1000).toFixed(0)}k kr utbetalt</div>
<div class="lb-total-chip">👤 ${t.registered_zoomers} zoomers</div>
<div class="lb-total-chip">🏙 ${t.cities_live} städer</div>`;
<div class="lb-total-chip">+ ${t.missions_completed?.toLocaleString('sv-SE')} uppdrag</div>
<div class="lb-total-chip">+ ${(t.total_paid_out_sek/1000).toFixed(0)}k kr utbetalt</div>
<div class="lb-total-chip">+ ${t.registered_zoomers} zoomers</div>
<div class="lb-total-chip">+ ${t.cities_live} städer</div>`;
}
const ZOOMER_AVATARS = ['🦅','🦊','🐺','🦁','🐯','🦝','🦋','🦈'];
const ZOOMER_AVATARS = ['Z','Z','Z','Z','Z','Z','Z','Z'];
function renderLbZoomers() {
const list = (lbData?.top_zoomers || DEMO_LEADERBOARD.top_zoomers);
document.getElementById('lb-zoomers-list').innerHTML = list.map((z,i) => {
const rankCls = i===0?'rank-1':i===1?'rank-2':i===2?'rank-3':'rank-n';
const rankTxt = i===0?'🥇':i===1?'🥈':i===2?'🥉':'#'+(i+1);
const rankTxt = i===0?'1':i===1?'2':i===2?'3':'#'+(i+1);
const bg = i===0?'rgba(255,215,0,0.15)':i===1?'rgba(192,192,192,0.12)':i===2?'rgba(205,127,50,0.12)':'var(--fill-tertiary)';
return `<div class="lb-row">
<div class="lb-rank ${rankCls}">${rankTxt}</div>
<div class="lb-avatar" style="background:${bg}">${ZOOMER_AVATARS[i%ZOOMER_AVATARS.length]}</div>
<div class="lb-body">
<div class="lb-name">${z.name}</div>
<div class="lb-meta">📍 ${z.city} · ⭐ ${z.rating} · ${z.skill_level}</div>
<div class="lb-meta">+ ${z.city} · ⭐ ${z.rating} · ${z.skill_level}</div>
</div>
<div class="lb-right">
<div class="lb-missions" style="color:${i===0?'#FFD700':i===1?'#C0C0C0':i===2?'#CD7F32':'var(--label-primary)'}">${z.missions_completed}</div>
@@ -1102,15 +1117,15 @@ function renderLbZoomers() {
}).join('');
}
const CITY_ICONS = { Stockholm:'🏙', Göteborg:'', Malmö:'🌉', Uppsala:'🏛', Västerås:'⚙️' };
const CITY_ICONS = { Stockholm:'+', Göteborg:'+', Malmö:'+', Uppsala:'+', Västerås:'+' };
function renderLbCities() {
const list = (lbData?.top_cities || DEMO_LEADERBOARD.top_cities);
const maxM = list[0]?.missions_completed || 1;
document.getElementById('lb-cities-list').innerHTML = list.map((c,i) => {
const pct = Math.round((c.missions_completed / maxM) * 100);
return `<div class="city-row">
<div class="city-rank" style="color:${i===0?'#FFD700':i===1?'#C0C0C0':i===2?'#CD7F32':'var(--label-tertiary)'}">${i===0?'🥇':i===1?'🥈':i===2?'🥉':'#'+(i+1)}</div>
<div class="city-icon">${CITY_ICONS[c.city]||'🏙'}</div>
<div class="city-rank" style="color:${i===0?'#FFD700':i===1?'#C0C0C0':i===2?'#CD7F32':'var(--label-tertiary)'}">${i===0?'1':i===1?'2':i===2?'3':'#'+(i+1)}</div>
<div class="city-icon">${CITY_ICONS[c.city]||'+'}</div>
<div class="city-body">
<div class="city-name">${c.city}</div>
<div style="margin-top:6px;max-width:160px;">
@@ -1144,13 +1159,13 @@ const DEMO_MISSIONS = [
const DEMO_EARNINGS = { available_sek: 0, pending_sek: 0, this_month_sek: 0, missions_completed: 0, total_earned_sek: 0 };
const DEMO_TX = [
{ type:'credit', icon:'💰', title:'Drönarbild — Gamla Stan', date:'Idag · automatisk utbetalning', amount:2800 },
{ type:'credit', icon:'💰', title:'Butiksfilm — Nordstan', date:'Igår 16:40', amount:2400 },
{ type:'credit', icon:'+', title:'Drönarbild — Gamla Stan', date:'Idag · automatisk utbetalning', amount:2800 },
{ type:'credit', icon:'+', title:'Butiksfilm — Nordstan', date:'Igår 16:40', amount:2400 },
{ type:'pending', icon:'⏳', title:'Hotellfilm Grand (granskas)', date:'2 dagar sedan · kunden granskar', amount:7200 },
{ type:'payout', icon:'↑', title:'Utbetalning via Stripe', date:'19 maj 2026', amount:9800 },
{ type:'credit', icon:'💰', title:'Event-foto Berns Salonger', date:'17 maj 2026', amount:5500 },
{ type:'credit', icon:'💰', title:'Campus-film Uppsala', date:'15 maj 2026', amount:5200 },
{ type:'credit', icon:'💰', title:'Restaurangfoto Vollmers', date:'12 maj 2026', amount:4800 },
{ type:'credit', icon:'+', title:'Event-foto Berns Salonger', date:'17 maj 2026', amount:5500 },
{ type:'credit', icon:'+', title:'Campus-film Uppsala', date:'15 maj 2026', amount:5200 },
{ type:'credit', icon:'+', title:'Restaurangfoto Vollmers', date:'12 maj 2026', amount:4800 },
{ type:'payout', icon:'↑', title:'Utbetalning via Stripe', date:'1 maj 2026', amount:14400 },
];