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 -67
View File
@@ -11,9 +11,9 @@
<title>LandveX — Inspektörsportal</title>
<link rel="stylesheet" >
<style>
/* ═══════════════════════════════════════════════════════
/*
LANDVEX INSPEKTÖRSPORTAL — iOS Native
═══════════════════════════════════════════════════════ */
*/
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-font-smoothing: antialiased; }
@@ -25,7 +25,7 @@ body {
padding-bottom: calc(60px + env(safe-area-inset-bottom));
}
/* ── NAV ── */
/* NAV */
.nav-bar {
position: sticky;
top: 0;
@@ -54,7 +54,7 @@ body {
width: 28px;
height: 28px;
background: linear-gradient(135deg, #0055CC, #007AFF);
border-radius: 7px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
@@ -75,11 +75,11 @@ body {
padding: 3px 7px;
background: rgba(88,86,214,0.15);
color: var(--ios-indigo);
border-radius: 20px;
border-radius: var(--radius-xl);
letter-spacing: 0.3px;
}
/* ── DATE HEADER ── */
/* DATE HEADER */
.date-header {
padding: 16px 16px 4px;
font-size: 13px;
@@ -97,11 +97,11 @@ body {
padding: 2px 16px 16px;
}
/* ── MISSION CARD ── */
/* MISSION CARD */
.mission-card {
margin: 0 16px 12px;
background: var(--grouped-background-secondary);
border-radius: 16px;
border-radius: var(--radius-lg);
border: 0.5px solid var(--separator);
overflow: hidden;
}
@@ -133,7 +133,7 @@ body {
.mission-number {
width: 44px;
height: 44px;
border-radius: 50%;
border-radius: var(--radius-full);
background: linear-gradient(135deg, #007AFF, #0055CC);
display: flex;
align-items: center;
@@ -187,7 +187,7 @@ body {
background: var(--ios-green);
color: white;
border: none;
border-radius: 12px;
border-radius: var(--radius-md);
font-size: 15px;
font-weight: 600;
font-family: var(--font-text);
@@ -203,14 +203,14 @@ body {
background: var(--fill-secondary);
color: var(--ios-blue);
border: none;
border-radius: 12px;
border-radius: var(--radius-md);
font-size: 15px;
font-weight: 600;
font-family: var(--font-text);
cursor: pointer;
}
/* ── FORM SECTIONS ── */
/* FORM SECTIONS */
.form-section {
margin: 24px 16px 0;
}
@@ -224,7 +224,7 @@ body {
.form-card {
background: var(--grouped-background-secondary);
border-radius: 16px;
border-radius: var(--radius-lg);
border: 0.5px solid var(--separator);
overflow: hidden;
}
@@ -291,7 +291,7 @@ body {
.form-textarea::placeholder { color: var(--label-quaternary); }
/* ── CHECKLIST ── */
/* CHECKLIST */
.checklist-item {
display: flex;
align-items: center;
@@ -308,7 +308,7 @@ body {
.check-box {
width: 24px;
height: 24px;
border-radius: 50%;
border-radius: var(--radius-full);
border: 2px solid var(--ios-blue);
flex-shrink: 0;
display: flex;
@@ -336,7 +336,7 @@ body {
.checklist-text.checked { color: var(--label-secondary); text-decoration: line-through; }
/* ── PHOTO UPLOAD ── */
/* PHOTO UPLOAD */
.photo-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -346,7 +346,7 @@ body {
.photo-thumb {
aspect-ratio: 1;
border-radius: 8px;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--fill-secondary);
display: flex;
@@ -387,7 +387,7 @@ body {
background: var(--fill-secondary);
}
/* ── SUBMIT AREA ── */
/* SUBMIT AREA */
.submit-area {
padding: 20px 16px 32px;
}
@@ -398,7 +398,7 @@ body {
background: linear-gradient(135deg, #007AFF, #0055CC);
color: white;
border: none;
border-radius: 14px;
border-radius: var(--radius-lg);
font-size: 17px;
font-weight: 700;
font-family: var(--font-display);
@@ -420,7 +420,7 @@ body {
margin-top: 10px;
}
/* ── PROGRESS BAR ── */
/* PROGRESS BAR */
.progress-wrap {
padding: 0 16px 16px;
}
@@ -436,18 +436,18 @@ body {
.progress-track {
height: 4px;
background: var(--fill-tertiary);
border-radius: 2px;
border-radius: var(--radius-sm);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 2px;
border-radius: var(--radius-sm);
background: var(--ios-blue);
transition: width 0.4s;
}
/* ── TAB BAR ── */
/* TAB BAR */
.tab-bar {
position: fixed;
bottom: 0;
@@ -482,7 +482,7 @@ body {
.tab-label { font-size: 10px; font-weight: 500; color: var(--label-secondary); }
.tab-item.active .tab-label { color: var(--ios-blue); }
/* ── NOTIFICATION DOT ── */
/* NOTIFICATION DOT */
.notif-dot {
position: relative;
}
@@ -498,7 +498,7 @@ body {
font-weight: 700;
width: 16px;
height: 16px;
border-radius: 8px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
@@ -543,7 +543,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<span class="brand-label">LandveX</span>
</div>
<span class="badge-inspector">INSPEKTÖR</span>
<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#5856D6,#AF52DE);display:flex;align-items:center;justify-content:center;color:white;font-size:14px;font-weight:700;">M</div>
<div style="width:32px;height:32px;border-radius: var(--radius-full);background:linear-gradient(135deg,#5856D6,#AF52DE);display:flex;align-items:center;justify-content:center;color:white;font-size:14px;font-weight:700;">M</div>
</div>
</nav>
@@ -556,35 +556,35 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="mission-header">
<div>
<div class="mission-title">E4 Norrtull Avsnitt 12</div>
<div class="mission-sub">🛣️ Ytinspektion · Stockholm</div>
<div class="mission-sub"> Ytinspektion · Stockholm</div>
</div>
<div class="mission-number">1</div>
</div>
<div class="mission-body">
<div class="mission-row">
<div class="mission-row-icon">🕐</div>
<div class="mission-row-icon"></div>
<div class="mission-row-label">Starttid</div>
<div class="mission-row-value">09:00</div>
</div>
<div class="mission-row">
<div class="mission-row-icon"></div>
<div class="mission-row-icon"></div>
<div class="mission-row-label">Beräknad tid</div>
<div class="mission-row-value">90 min</div>
</div>
<div class="mission-row">
<div class="mission-row-icon">📍</div>
<div class="mission-row-icon"></div>
<div class="mission-row-label">Plats</div>
<div class="mission-row-value">E4, km 164166</div>
</div>
<div class="mission-row">
<div class="mission-row-icon">📋</div>
<div class="mission-row-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></div>
<div class="mission-row-label">Typ</div>
<div class="mission-row-value">Standard ytinspektion</div>
</div>
</div>
<div class="mission-actions">
<button class="btn-start" onclick="startMission(1)"> Starta uppdrag</button>
<button class="btn-nav" onclick="navigate('E4 Norrtull')">🗺️ Navigera</button>
<button class="btn-start" onclick="startMission(1)"> Starta uppdrag</button>
<button class="btn-nav" onclick="navigate('E4 Norrtull')"> Navigera</button>
</div>
</div>
@@ -593,23 +593,23 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="mission-header">
<div>
<div class="mission-title">Kungliga Slottet Tak</div>
<div class="mission-sub">🏛️ Takinspektion · Kritisk · Stockholm</div>
<div class="mission-sub"> Takinspektion · Kritisk · Stockholm</div>
</div>
<div class="mission-number" style="background:linear-gradient(135deg,#FF3B30,#CC1100)">2</div>
</div>
<div class="mission-body">
<div class="mission-row">
<div class="mission-row-icon">🕐</div>
<div class="mission-row-icon"></div>
<div class="mission-row-label">Starttid</div>
<div class="mission-row-value">13:00</div>
</div>
<div class="mission-row">
<div class="mission-row-icon"></div>
<div class="mission-row-icon"></div>
<div class="mission-row-label">Beräknad tid</div>
<div class="mission-row-value">120 min</div>
</div>
<div class="mission-row">
<div class="mission-row-icon">📍</div>
<div class="mission-row-icon"></div>
<div class="mission-row-label">Plats</div>
<div class="mission-row-value">Kungliga Slottet, Gamla Stan</div>
</div>
@@ -620,8 +620,8 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
</div>
<div class="mission-actions">
<button class="btn-start" onclick="startMission(2)" style="background:var(--ios-red)"> Starta uppdrag</button>
<button class="btn-nav" onclick="navigate('Kungliga Slottet')">🗺️ Navigera</button>
<button class="btn-start" onclick="startMission(2)" style="background:var(--ios-red)"> Starta uppdrag</button>
<button class="btn-nav" onclick="navigate('Kungliga Slottet')"> Navigera</button>
</div>
</div>
@@ -640,7 +640,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- INSPECTION FORM -->
<div class="form-section" id="inspectionForm" style="display:none;">
<div class="section-title">📋 Inspektionsprotokoll</div>
<div class="section-title"><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> Inspektionsprotokoll</div>
<div class="form-card" style="margin-bottom:12px;">
<div class="form-row">
@@ -663,11 +663,11 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="form-row">
<div class="form-row-label">Väder</div>
<select class="form-row-select">
<option>☀️ Klart</option>
<option>🌥️ Molnigt</option>
<option>🌧️ Regn</option>
<option>❄️ Snö</option>
<option>🌬️ Blåsigt</option>
<option> Klart</option>
<option> Molnigt</option>
<option> Regn</option>
<option> Snö</option>
<option> Blåsigt</option>
</select>
</div>
<div class="form-row">
@@ -675,7 +675,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<select class="form-row-select" id="riskLevel">
<option value="low"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#00C853" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Låg (030)</option>
<option value="medium"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Medium (3165)</option>
<option value="high">🔴 Hög (66100)</option>
<option value="high"> Hög (66100)</option>
</select>
</div>
</div>
@@ -684,27 +684,27 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div style="font-size:13px;font-weight:600;color:var(--label-secondary);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;padding:0 4px;">Checklista</div>
<div class="form-card" style="margin-bottom:12px;">
<div class="checklist-item" onclick="toggleCheck(this)">
<div class="check-box"><span class="check-mark"></span></div>
<div class="check-box"><span class="check-mark"><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></span></div>
<div class="checklist-text">Yttre skador dokumenterade</div>
</div>
<div class="checklist-item" onclick="toggleCheck(this)">
<div class="check-box"><span class="check-mark"></span></div>
<div class="check-box"><span class="check-mark"><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></span></div>
<div class="checklist-text">Strukturell integritet kontrollerad</div>
</div>
<div class="checklist-item" onclick="toggleCheck(this)">
<div class="check-box"><span class="check-mark"></span></div>
<div class="check-box"><span class="check-mark"><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></span></div>
<div class="checklist-text">Dränering och avlopp inspekterade</div>
</div>
<div class="checklist-item" onclick="toggleCheck(this)">
<div class="check-box"><span class="check-mark"></span></div>
<div class="check-box"><span class="check-mark"><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></span></div>
<div class="checklist-text">Korrosion och fuktskador noterade</div>
</div>
<div class="checklist-item" onclick="toggleCheck(this)">
<div class="check-box"><span class="check-mark"></span></div>
<div class="check-box"><span class="check-mark"><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></span></div>
<div class="checklist-text">Säkerhetsrisker bedömda</div>
</div>
<div class="checklist-item" onclick="toggleCheck(this)">
<div class="check-box"><span class="check-mark"></span></div>
<div class="check-box"><span class="check-mark"><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></span></div>
<div class="checklist-text">Jämförelse med föregående inspektion</div>
</div>
</div>
@@ -714,10 +714,10 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="form-card" style="margin-bottom:12px;">
<div class="photo-grid">
<div class="photo-thumb" onclick="addPhoto(this)">
<div class="photo-placeholder">📸</div>
<div class="photo-placeholder"></div>
</div>
<div class="photo-thumb" onclick="addPhoto(this)">
<div class="photo-placeholder">📸</div>
<div class="photo-placeholder"></div>
</div>
<div class="photo-thumb add-photo" onclick="takePhoto()">
<div class="add-icon">+</div>
@@ -745,7 +745,7 @@ Exempel:
<!-- SUBMIT -->
<div class="submit-area">
<button class="submit-btn" onclick="submitReport()">
Skicka inspektionsrapport
<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> Skicka inspektionsrapport
</button>
<div class="submit-note">Rapporten skickas till LandveX och objektägaren. Du kan ej ändra efter inskickning.</div>
</div>
@@ -753,7 +753,7 @@ Exempel:
<!-- PLACEHOLDER WHEN NO FORM -->
<div id="formPlaceholder" style="padding: 32px 16px; text-align: center; color: var(--label-tertiary);">
<div style="font-size: 48px; margin-bottom: 12px;">📋</div>
<div style="font-size: 48px; margin-bottom: 12px;"><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></div>
<div style="font-size: 17px; font-weight: 600; color: var(--label-secondary);">Protokollformulär</div>
<div style="font-size: 14px; margin-top: 6px;">Starta ett uppdrag ovan för att öppna inspektionsformuläret.</div>
</div>
@@ -761,23 +761,23 @@ Exempel:
<!-- TAB BAR -->
<nav class="tab-bar">
<div class="tab-item active">
<div class="tab-icon">📅</div>
<div class="tab-icon"></div>
<div class="tab-label">Uppdrag</div>
</div>
<div class="tab-item" onclick="showForm()">
<div class="tab-icon">📋</div>
<div class="tab-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></div>
<div class="tab-label">Protokoll</div>
</div>
<div class="tab-item">
<div class="tab-icon notif-dot">🔔</div>
<div class="tab-icon notif-dot"></div>
<div class="tab-label">Notiser</div>
</div>
<div class="tab-item">
<div class="tab-icon">📁</div>
<div class="tab-icon"></div>
<div class="tab-label">Arkiv</div>
</div>
<div class="tab-item">
<div class="tab-icon">👤</div>
<div class="tab-icon"></div>
<div class="tab-label">Profil</div>
</div>
</nav>
@@ -803,8 +803,8 @@ function startMission(n) {
// Feedback
const toast = document.createElement('div');
toast.style.cssText = 'position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#F2F2F7;color:white;padding:10px 18px;border-radius:20px;font-size:14px;font-weight:600;z-index:500;';
toast.textContent = ` Uppdrag ${n} startat`;
toast.style.cssText = 'position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#F2F2F7;color:white;padding:10px 18px;border-radius: var(--radius-xl);font-size:14px;font-weight:600;z-index:500;';
toast.textContent = ` Uppdrag ${n} startat`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
@@ -830,7 +830,7 @@ function updateProgress() {
function addPhoto(thumb) {
// Simulate photo capture
const emojis = ['🌉','🏗️','<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>','📐','🔩','🧱'];
const emojis = ['','<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 14H14M4 14V8L8 4L12 8V14" stroke="#666" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 14V10H10V14" stroke="#666" stroke-width="1.5"/></svg>','<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>','','',''];
thumb.innerHTML = `<div style="font-size:32px">${emojis[Math.floor(Math.random()*emojis.length)]}</div>`;
thumb.style.background = 'var(--fill-tertiary)';
}
@@ -842,7 +842,7 @@ function takePhoto() {
const newThumb = document.createElement('div');
newThumb.className = 'photo-thumb';
newThumb.onclick = function() { addPhoto(this); };
const emojis = ['🌉','🏗️','🏛️','📍','🔍','📐'];
const emojis = ['','<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 14H14M4 14V8L8 4L12 8V14" stroke="#666" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 14V10H10V14" stroke="#666" stroke-width="1.5"/></svg>','','','<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>',''];
newThumb.innerHTML = `<div style="font-size:32px">${emojis[Math.floor(Math.random()*emojis.length)]}</div>`;
newThumb.style.background = 'var(--fill-secondary)';
@@ -877,8 +877,8 @@ function submitReport() {
// Feedback
const toast = document.createElement('div');
toast.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:var(--ios-green);color:white;padding:14px 24px;border-radius:20px;font-size:15px;font-weight:600;z-index:500;box-shadow:0 4px 20px rgba(52,199,89,0.4);text-align:center;';
toast.innerHTML = ` Rapport inskickad<br><span style="font-size:12px;opacity:0.85">${objekt}</span>`;
toast.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:var(--ios-green);color:white;padding:14px 24px;border-radius: var(--radius-xl);font-size:15px;font-weight:600;z-index:500;box-shadow:0 4px 20px rgba(52,199,89,0.4);text-align:center;';
toast.innerHTML = `<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> Rapport inskickad<br><span style="font-size:12px;opacity:0.85">${objekt}</span>`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);