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
+49 -49
View File
@@ -52,7 +52,7 @@
/* Hero Card */
.hero-card {
background: var(--background-secondary);
border-radius: 16px;
border-radius: var(--radius-lg);
margin: 16px 0;
overflow: hidden;
border: 0.5px solid var(--separator);
@@ -62,7 +62,7 @@
padding: 16px;
}
.hero-icon {
width: 52px; height: 52px; border-radius: 12px;
width: 52px; height: 52px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 26px; flex-shrink: 0;
}
@@ -103,7 +103,7 @@
}
.section-card {
background: var(--background-secondary);
border-radius: 14px; overflow: hidden;
border-radius: var(--radius-lg); overflow: hidden;
border: 0.5px solid var(--separator);
}
.section-row {
@@ -122,7 +122,7 @@
font-size: 15px; line-height: 1.55;
color: var(--label-primary); padding: 14px 16px;
background: var(--background-secondary);
border-radius: 14px;
border-radius: var(--radius-lg);
border: 0.5px solid var(--separator);
}
@@ -133,7 +133,7 @@
position: relative;
}
.step-num {
width: 28px; height: 28px; border-radius: 50%;
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: 14px; font-weight: 700; flex-shrink: 0;
@@ -146,9 +146,9 @@
/* Photo Section */
.photo-wrap { padding: 14px 16px; }
.photo-grid { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.photo-thumb { width: 80px; height: 80px; border-radius: 10px; object-fit: cover; border: 0.5px solid var(--separator); }
.photo-thumb { width: 80px; height: 80px; border-radius: var(--radius-md); object-fit: cover; border: 0.5px solid var(--separator); }
.photo-add-btn {
width: 80px; height: 80px; border-radius: 10px;
width: 80px; height: 80px; border-radius: var(--radius-md);
background: var(--fill-secondary);
border: 1.5px dashed var(--ios-blue);
display: flex; flex-direction: column;
@@ -170,7 +170,7 @@
}
.action-btn {
flex: 1; padding: 14px;
border: none; border-radius: 14px;
border: none; border-radius: var(--radius-lg);
font-family: var(--font-text); font-size: 16px; font-weight: 700;
cursor: pointer; transition: transform 0.1s, opacity 0.1s;
letter-spacing: -0.2px;
@@ -184,7 +184,7 @@
/* Badge */
.status-badge {
display: inline-block; padding: 4px 10px; border-radius: 20px;
display: inline-block; padding: 4px 10px; border-radius: var(--radius-xl);
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
}
.badge-open { background: rgba(52,199,89,0.15); color: var(--ios-green); }
@@ -195,10 +195,10 @@
/* Loader */
.loader { display:flex; justify-content:center; padding:80px; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { width:32px; height:32px; border-radius:50%; border:3px solid var(--fill-secondary); border-top-color:var(--ios-blue); animation:spin 0.8s linear infinite; }
.spinner { width:32px; height:32px; border-radius: var(--radius-full); border:3px solid var(--fill-secondary); border-top-color:var(--ios-blue); animation:spin 0.8s linear infinite; }
/* Toast */
.toast { position:fixed; bottom:calc(env(safe-area-inset-bottom,34px)+100px); left:50%; transform:translateX(-50%) translateY(20px); background:var(--background-secondary); border:0.5px solid var(--separator); border-radius:20px; padding:10px 18px; font-size:14px; font-weight:500; box-shadow:0 8px 32px rgba(0,0,0,0.3); opacity:0; transition:all 0.3s; z-index:9999; white-space:nowrap; }
.toast { position:fixed; bottom:calc(env(safe-area-inset-bottom,34px)+100px); left:50%; transform:translateX(-50%) translateY(20px); background:var(--background-secondary); border:0.5px solid var(--separator); border-radius: var(--radius-xl); padding:10px 18px; font-size:14px; font-weight:500; box-shadow:0 8px 32px rgba(0,0,0,0.3); opacity:0; transition:all 0.3s; z-index:9999; white-space:nowrap; }
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
/* Icons bg */
@@ -211,7 +211,7 @@
.gps-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 20px;
padding: 4px 10px; border-radius: var(--radius-xl);
background: rgba(52,199,89,0.15); color: var(--ios-green);
font-size: 12px; font-weight: 600;
}
@@ -318,14 +318,14 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
function missionTypeConfig(type) {
const map = {
facade_scan: { icon:'🏢', cls:'icon-facade', label:'Fasadscanning' },
roof_scan: { icon:'🏠', cls:'icon-roof', label:'Takscanning' },
road_inspection: { icon:'🛣️', cls:'icon-road', label:'Väginspekt.' },
interior_scan: { icon:'🏚️', cls:'icon-interior', label:'Interi ör' },
vegetation_survey: { icon:'🌳', cls:'icon-vegetation', label:'Vegetationssurv.' },
inspection: { icon:'🔍', cls:'icon-inspection', label:'Inspektion' },
facade_scan: { 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 5H7M6 8H7M6 11H7M9 5H10M9 8H10M9 11H10" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg>', cls:'icon-facade', label:'Fasadscanning' },
roof_scan: { icon:'', cls:'icon-roof', label:'Takscanning' },
road_inspection: { icon:'', cls:'icon-road', label:'Väginspekt.' },
interior_scan: { icon:'', cls:'icon-interior', label:'Interi ör' },
vegetation_survey: { icon:'', cls:'icon-vegetation', label:'Vegetationssurv.' },
inspection: { icon:'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="5" stroke="#666" stroke-width="2"/><path d="M11 11L14 14" stroke="#666" stroke-width="2" stroke-linecap="round"/></svg>', cls:'icon-inspection', label:'Inspektion' },
};
return map[type] || { icon:'📍', cls:'icon-inspection', label: type };
return map[type] || { icon:'', cls:'icon-inspection', label: type };
}
function getInstructions(type) {
@@ -369,7 +369,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution:'© OpenStreetMap' }).addTo(map);
const icon = L.divIcon({
html: `<div style="width:40px;height:40px;border-radius:50%;background:var(--ios-blue);display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:0 2px 10px rgba(0,0,0,0.4);border:2.5px solid white;">${missionTypeConfig(mission.mission_type).icon}</div>`,
html: `<div style="width:40px;height:40px;border-radius: var(--radius-full);background:var(--ios-blue);display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:0 2px 10px rgba(0,0,0,0.4);border:2.5px solid white;">${missionTypeConfig(mission.mission_type).icon}</div>`,
className:'', iconSize:[40,40], iconAnchor:[20,20]
});
L.marker([lat, lon], {icon}).addTo(map).openPopup();
@@ -395,7 +395,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="hero-icon ${cfg.cls}">${cfg.icon}</div>
<div class="hero-info">
<div class="hero-title">${mission.title}</div>
<div class="hero-address">📍 ${mission.address || 'Okänd plats'}</div>
<div class="hero-address"> ${mission.address || 'Okänd plats'}</div>
<div style="margin-top:6px">${statusBadge(mission.status)}</div>
</div>
</div>
@@ -409,7 +409,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="stat-label">Uppdragstyp</div>
</div>
<div class="stat-cell">
<div class="stat-value gps-chip ${!userLat ? 'searching' : ''}">${!userLat ? '📡 GPS' : '📍 ' + distText}</div>
<div class="stat-value gps-chip ${!userLat ? 'searching' : ''}">${!userLat ? ' GPS' : ' ' + distText}</div>
<div class="stat-label">Avstånd</div>
</div>
</div>
@@ -424,7 +424,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="section-title">Information</div>
<div class="section-card">
<div class="section-row">
<div class="row-icon">📅</div>
<div class="row-icon"></div>
<div class="row-content">
<div class="row-title">Skapat</div>
<div class="row-sub">${formatDate(mission.created_at)}</div>
@@ -432,7 +432,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
${mission.accepted_at ? `
<div class="section-row">
<div class="row-icon"></div>
<div class="row-icon"><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="row-content">
<div class="row-title">Accepterat</div>
<div class="row-sub">${formatDate(mission.accepted_at)}</div>
@@ -440,7 +440,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>` : ''}
${mission.submitted_at ? `
<div class="section-row">
<div class="row-icon">📤</div>
<div class="row-icon"></div>
<div class="row-content">
<div class="row-title">Inskickat</div>
<div class="row-sub">${formatDate(mission.submitted_at)}</div>
@@ -448,14 +448,14 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>` : ''}
${mission.approved_at ? `
<div class="section-row">
<div class="row-icon">🎉</div>
<div class="row-icon"></div>
<div class="row-content">
<div class="row-title">Godkänt & betalt</div>
<div class="row-sub">${formatDate(mission.approved_at)} · Ref: ${mission.payment_ref || '—'}</div>
</div>
</div>` : ''}
<div class="section-row">
<div class="row-icon">🆔</div>
<div class="row-icon"></div>
<div class="row-content">
<div class="row-title">Uppdragsnr</div>
<div class="row-sub" style="font-family:var(--font-mono);font-size:12px">${mission.id}</div>
@@ -480,7 +480,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
${(isMyMission && mission.status === 'assigned') ? `
<div class="section">
<div class="section-title">📷 Dokumentation</div>
<div class="section-title"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg> Dokumentation</div>
<div class="section-card">
<div class="photo-wrap">
<div style="font-size:13px;color:var(--label-secondary);margin-bottom:6px">
@@ -488,7 +488,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
<div class="photo-grid" id="photo-grid">
<div class="photo-add-btn" onclick="document.getElementById('photo-input').click()">
📷<span>Lägg till</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg><span>Lägg till</span>
</div>
</div>
</div>
@@ -508,16 +508,16 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
if (mission.status === 'open') {
bar.innerHTML = `
<button class="action-btn btn-decline" onclick="history.back()"> Avböj</button>
<button class="action-btn btn-accept" onclick="acceptMission()"> Acceptera uppdrag</button>`;
<button class="action-btn btn-decline" onclick="history.back()"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 4L12 12M12 4L4 12" stroke="#ef4444" stroke-width="2" stroke-linecap="round"/></svg> Avböj</button>
<button class="action-btn btn-accept" onclick="acceptMission()"><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> Acceptera uppdrag</button>`;
} else if (mission.status === 'assigned' && isMyMission) {
bar.innerHTML = `
<button class="action-btn btn-camera" onclick="document.getElementById('photo-input').click()">📷</button>
<button class="action-btn btn-complete" onclick="submitWithPhotos()"> Slutför & skicka in</button>`;
<button class="action-btn btn-camera" onclick="document.getElementById('photo-input').click()"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg></button>
<button class="action-btn btn-complete" onclick="submitWithPhotos()"><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> Slutför & skicka in</button>`;
} else if (mission.status === 'submitted') {
bar.innerHTML = `<button class="action-btn btn-disabled">⏳ Väntar på granskning</button>`;
} else if (mission.status === 'approved') {
bar.innerHTML = `<button class="action-btn btn-disabled" style="background:rgba(52,199,89,0.15);color:var(--ios-green)">🎉 Godkänt & betalt</button>`;
bar.innerHTML = `<button class="action-btn btn-disabled" style="background:rgba(52,199,89,0.15);color:var(--ios-green)"> Godkänt & betalt</button>`;
} else {
bar.innerHTML = `<button class="action-btn btn-decline" onclick="history.back()">← Tillbaka</button>`;
}
@@ -538,15 +538,15 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
if (r.ok) {
mission = data;
setActiveMission(data);
toast(' Uppdrag accepterat! Dra till platsen och starta.');
toast('<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> Uppdrag accepterat! Dra till platsen och starta.');
renderContent();
} else {
toast('⚠️ ' + (data.error || 'Kunde inte acceptera'));
if (btn) { btn.textContent = ' Acceptera uppdrag'; btn.disabled = false; }
toast('<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> ' + (data.error || 'Kunde inte acceptera'));
if (btn) { 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> Acceptera uppdrag'; btn.disabled = false; }
}
} catch(e) {
toast('⚠️ Nätverksfel');
if (btn) { btn.textContent = ' Acceptera uppdrag'; btn.disabled = false; }
toast('<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> Nätverksfel');
if (btn) { 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> Acceptera uppdrag'; btn.disabled = false; }
}
}
@@ -565,16 +565,16 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
grid.insertBefore(img, grid.firstChild);
});
toast(`📷 ${uploadedFiles.length} foto valda`);
toast(`<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg> ${uploadedFiles.length} foto valda`);
const bar = document.getElementById('bottom-actions');
bar.innerHTML = `
<button class="action-btn btn-camera" onclick="document.getElementById('photo-input').click()">📷 (${uploadedFiles.length})</button>
<button class="action-btn btn-complete" onclick="submitWithPhotos()"> Skicka in ${uploadedFiles.length} bilder</button>`;
<button class="action-btn btn-camera" onclick="document.getElementById('photo-input').click()"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg> (${uploadedFiles.length})</button>
<button class="action-btn btn-complete" onclick="submitWithPhotos()"><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> Skicka in ${uploadedFiles.length} bilder</button>`;
});
async function submitWithPhotos() {
if (uploadedFiles.length === 0) {
toast('📷 Ta minst ett foto innan du slutför');
toast('<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg> Ta minst ett foto innan du slutför');
return;
}
const btn = document.querySelector('.btn-complete');
@@ -589,16 +589,16 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
if (r.ok) {
mission = data.mission;
setActiveMission(null);
toast('🎉 Inskickat! Väntar på granskning och utbetalning.');
toast(' Inskickat! Väntar på granskning och utbetalning.');
uploadedFiles = [];
setTimeout(() => window.location.href = 'zoomer-app.html', 2000);
} else {
toast('⚠️ ' + (data.error || 'Fel vid inskick'));
if (btn) { btn.textContent = ' Skicka in'; btn.disabled = false; }
toast('<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> ' + (data.error || 'Fel vid inskick'));
if (btn) { btn.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> Skicka in'; btn.disabled = false; }
}
} catch(e) {
toast('⚠️ Nätverksfel — försök igen');
if (btn) { btn.textContent = ' Skicka in'; btn.disabled = false; }
toast('<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> Nätverksfel — försök igen');
if (btn) { btn.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> Skicka in'; btn.disabled = false; }
}
}
@@ -628,7 +628,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
initMap(mission.lat, mission.lon);
renderContent();
} catch(e) {
document.getElementById('main-content').innerHTML = `<div style="padding:40px;text-align:center"><div style="font-size:48px">😵</div><div style="margin-top:12px;color:var(--label-secondary)">Kunde inte ladda uppdrag</div></div>`;
document.getElementById('main-content').innerHTML = `<div style="padding:40px;text-align:center"><div style="font-size:48px"></div><div style="margin-top:12px;color:var(--label-secondary)">Kunde inte ladda uppdrag</div></div>`;
}
}