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
@@ -36,7 +36,7 @@
.nav-title { font-size: 17px; font-weight: 600; }
.nav-date { font-size: 13px; color: var(--label-secondary); margin-top: 2px; }
.nav-avatar {
width: 32px; height: 32px; border-radius: 50%;
width: 32px; height: 32px; border-radius: var(--radius-full);
background: var(--ios-blue); color: white;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 700;
@@ -77,7 +77,7 @@
.mission-card {
margin: 0 16px 10px;
background: var(--grouped-background-secondary);
border-radius: 12px; overflow: hidden;
border-radius: var(--radius-md); overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
@@ -86,7 +86,7 @@
.mission-card:active { transform: scale(0.98); box-shadow: 0 0 0 rgba(0,0,0,0); }
.mission-top { display: flex; align-items: center; padding: 14px 16px; gap: 12px; }
.mission-type-icon {
width: 44px; height: 44px; border-radius: 10px;
width: 44px; height: 44px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 22px; flex-shrink: 0;
}
@@ -99,7 +99,7 @@
.mission-addr { font-size: 13px; color: var(--label-secondary); margin-top: 2px; }
.mission-meta { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.mission-priority {
padding: 2px 8px; border-radius: 6px;
padding: 2px 8px; border-radius: var(--radius-sm);
font-size: 11px; font-weight: 700;
}
.priority-urgent { background: rgba(255,59,48,0.12); color: var(--ios-red); }
@@ -109,7 +109,7 @@
.mission-arrow { color: var(--ios-gray3); font-size: 14px; }
/* Map */
#map { height: 200px; margin: 0 16px 4px; border-radius: 12px; overflow: hidden; }
#map { height: 200px; margin: 0 16px 4px; border-radius: var(--radius-md); overflow: hidden; }
/* Tab bar */
.tab-bar {
@@ -154,11 +154,11 @@
display: flex; justify-content: center; gap: 8px; margin-bottom: 20px;
}
.step-dot {
width: 8px; height: 8px; border-radius: 50%;
width: 8px; height: 8px; border-radius: var(--radius-full);
background: var(--ios-gray4); transition: background 0.3s;
}
.step-dot.done { background: var(--ios-green); }
.step-dot.active { background: var(--ios-blue); width: 20px; border-radius: 4px; }
.step-dot.active { background: var(--ios-blue); width: 20px; border-radius: var(--radius-sm); }
/* Form elements */
.form-section { margin-bottom: 24px; }
@@ -166,7 +166,7 @@
.form-card {
background: var(--grouped-background-secondary);
border-radius: 12px; overflow: hidden;
border-radius: var(--radius-md); overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.form-field {
@@ -194,7 +194,7 @@
}
.check-item:last-child { border-bottom: none; }
.check-box {
width: 24px; height: 24px; border-radius: 50%;
width: 24px; height: 24px; border-radius: var(--radius-full);
border: 2px solid var(--ios-gray3);
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
@@ -213,7 +213,7 @@
}
.photo-thumb {
aspect-ratio: 1; background: var(--fill-tertiary);
border-radius: 8px; display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center;
cursor: pointer; overflow: hidden; position: relative;
transition: opacity 0.15s;
}
@@ -222,7 +222,7 @@
.photo-preview { width: 100%; height: 100%; object-fit: cover; display: none; }
.photo-delete {
position: absolute; top: 4px; right: 4px;
width: 20px; height: 20px; border-radius: 50%;
width: 20px; height: 20px; border-radius: var(--radius-full);
background: rgba(242,242,247,0.5); color: white;
font-size: 12px; display: none; align-items: center; justify-content: center;
cursor: pointer;
@@ -230,7 +230,7 @@
/* Signature canvas */
.sig-wrap {
background: var(--fill-quaternary); border-radius: 10px;
background: var(--fill-quaternary); border-radius: var(--radius-md);
overflow: hidden; position: relative; margin: 0 16px;
}
#sig-canvas {
@@ -245,7 +245,7 @@
.sig-actions { display: flex; gap: 8px; padding: 10px 16px; }
.sig-clear-btn {
flex: 1; background: var(--fill-tertiary); border: none;
border-radius: 10px; padding: 10px; font-size: 14px; font-weight: 500;
border-radius: var(--radius-md); padding: 10px; font-size: 14px; font-weight: 500;
color: var(--ios-red); cursor: pointer; font-family: var(--font-text);
}
@@ -253,7 +253,7 @@
.submit-btn {
display: block; width: calc(100% - 32px); margin: 16px;
background: var(--ios-blue); color: white; border: none;
border-radius: 12px; padding: 15px; font-size: 16px; font-weight: 600;
border-radius: var(--radius-md); padding: 15px; font-size: 16px; font-weight: 600;
cursor: pointer; font-family: var(--font-text);
box-shadow: 0 2px 12px rgba(0,122,255,0.3);
transition: opacity 0.15s;
@@ -266,7 +266,7 @@
display: flex; gap: 12px; padding: 0 16px 16px;
}
.step-btn {
flex: 1; padding: 13px; border-radius: 10px;
flex: 1; padding: 13px; border-radius: var(--radius-md);
border: none; font-size: 15px; font-weight: 600;
cursor: pointer; font-family: var(--font-text);
transition: opacity 0.15s;
@@ -282,7 +282,7 @@
text-align: center;
}
.confirm-icon {
width: 80px; height: 80px; border-radius: 50%;
width: 80px; height: 80px; border-radius: var(--radius-full);
background: rgba(52,199,89,0.15); border: 2px solid var(--ios-green);
display: flex; align-items: center; justify-content: center;
font-size: 40px; margin-bottom: 24px;
@@ -292,12 +292,12 @@
.confirm-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.confirm-report-num {
font-size: 13px; color: var(--label-secondary);
background: var(--fill-tertiary); padding: 6px 16px; border-radius: 20px;
background: var(--fill-tertiary); padding: 6px 16px; border-radius: var(--radius-xl);
font-family: var(--font-mono); margin-bottom: 24px;
}
.confirm-details {
background: var(--grouped-background-secondary);
border-radius: 12px; width: 100%; text-align: left; overflow: hidden;
border-radius: var(--radius-md); width: 100%; text-align: left; overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 24px;
}
.confirm-row {
@@ -309,7 +309,7 @@
.confirm-val { font-weight: 500; }
.confirm-btn {
width: 100%; background: var(--ios-blue); color: white; border: none;
border-radius: 12px; padding: 14px; font-size: 16px; font-weight: 600;
border-radius: var(--radius-md); padding: 14px; font-size: 16px; font-weight: 600;
cursor: pointer; font-family: var(--font-text);
box-shadow: 0 2px 12px rgba(0,122,255,0.3);
}
@@ -443,7 +443,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="nav-bar">
<div class="nav-row">
<div>
<div class="nav-title">🏗 Dagens Uppdrag</div>
<div class="nav-title"> Dagens Uppdrag</div>
<div class="nav-date" id="today-date"></div>
</div>
<div style="display:flex;gap:10px;align-items:center">
@@ -466,7 +466,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="mission-card" onclick="openMission('bro-e45')">
<div class="mission-top">
<div class="mission-type-icon type-bridge">🌉</div>
<div class="mission-type-icon type-bridge"></div>
<div class="mission-info">
<div class="mission-name">Bro E45 — Götaälvbron</div>
<div class="mission-addr">Göteborg, Hamnbanan</div>
@@ -481,7 +481,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="mission-card" onclick="openMission('vag-rv40')">
<div class="mission-top">
<div class="mission-type-icon type-road">🛣</div>
<div class="mission-type-icon type-road"></div>
<div class="mission-info">
<div class="mission-name">Väg RV40 km 142</div>
<div class="mission-addr">BoråsGöteborg, spricköversyn</div>
@@ -496,7 +496,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="mission-card" onclick="openMission('fasad-kungsbacka')">
<div class="mission-top">
<div class="mission-type-icon type-facade">🏢</div>
<div class="mission-type-icon type-facade"><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></div>
<div class="mission-info">
<div class="mission-name">Fasad Kungsbacka centrum</div>
<div class="mission-addr">Storgatan 12, Kungsbacka</div>
@@ -511,7 +511,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="mission-card" onclick="openMission('tak-lindome')">
<div class="mission-top">
<div class="mission-type-icon type-roof">🏚</div>
<div class="mission-type-icon type-roof"></div>
<div class="mission-info">
<div class="mission-name">Tak Lindome industripark</div>
<div class="mission-addr">Lindome, Mölndal</div>
@@ -547,7 +547,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- ==================== CONFIRMATION SCREEN ==================== -->
<div id="screen-confirm" class="screen">
<div class="confirm-screen">
<div class="confirm-icon"></div>
<div class="confirm-icon"><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></div>
<div class="confirm-title">Rapport inskickad!</div>
<div class="confirm-report-num" id="report-num">LVX-2026-0000</div>
<div class="confirm-details">
@@ -569,7 +569,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
</div>
<div class="confirm-row">
<span class="confirm-key">Status</span>
<span class="confirm-val" style="color:var(--ios-green)"> Synkad</span>
<span class="confirm-val" style="color:var(--ios-green)"><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> Synkad</span>
</div>
</div>
<button class="confirm-btn" onclick="goHome()">← Tillbaka till uppdrag</button>
@@ -580,26 +580,26 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- TAB BAR -->
<div class="tab-bar" id="main-tab-bar">
<div class="tab-item active" onclick="switchTab('list')">
<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>
<span>Uppdrag</span>
</div>
<div class="tab-item" onclick="switchTab('map-tab')">
<div class="tab-icon">🗺</div>
<div class="tab-icon"></div>
<span>Karta</span>
</div>
<div class="tab-item" onclick="switchTab('reports')">
<div class="tab-icon">📂</div>
<div class="tab-icon"></div>
<span>Rapporter</span>
</div>
<div class="tab-item" onclick="switchTab('profile')">
<div class="tab-icon">👤</div>
<div class="tab-icon"></div>
<span>Profil</span>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ─── State ───────────────────────────────────────────────────────────────────
// State
const STORAGE_KEY = 'landvex_inspector_state';
let currentMission = null;
let currentStep = 0;
@@ -651,7 +651,7 @@ const CHECKLISTS = {
],
};
// ─── Init ─────────────────────────────────────────────────────────────────────
// Init
document.getElementById('today-date').textContent = new Date().toLocaleDateString('sv-SE', { weekday:'long', year:'numeric', month:'long', day:'numeric' });
// Init map
@@ -661,7 +661,7 @@ Object.entries(MISSIONS).forEach(([id, m]) => {
L.marker([m.lat, m.lng]).addTo(map).bindPopup(`<b>${m.name}</b>`);
});
// ─── Screens ──────────────────────────────────────────────────────────────────
// Screens
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
@@ -691,7 +691,7 @@ function switchTab(tab) {
}
}
// ─── Missions ─────────────────────────────────────────────────────────────────
// Missions
function openMission(id) {
currentMission = id;
currentStep = 0;
@@ -730,10 +730,10 @@ function renderStep() {
<div class="form-field">
<div class="field-label">Objekttyp</div>
<select class="field-input" id="f-type">
<option value="bro" ${m.type==='bro'?'selected':''}>🌉 Bro</option>
<option value="väg" ${m.type==='väg'?'selected':''}>🛣 Väg</option>
<option value="fasad" ${m.type==='fasad'?'selected':''}>🏢 Fasad</option>
<option value="tak" ${m.type==='tak'?'selected':''}>🏚 Tak</option>
<option value="bro" ${m.type==='bro'?'selected':''}> Bro</option>
<option value="väg" ${m.type==='väg'?'selected':''}> Väg</option>
<option value="fasad" ${m.type==='fasad'?'selected':''}><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> Fasad</option>
<option value="tak" ${m.type==='tak'?'selected':''}> Tak</option>
</select>
</div>
<div class="form-field">
@@ -752,10 +752,10 @@ function renderStep() {
<div class="form-field">
<div class="field-label">Väder</div>
<select class="field-input" id="f-weather">
<option>☀️ Soligt</option>
<option>⛅️ Molnigt</option>
<option>🌧 Regn</option>
<option>❄️ Snö</option>
<option> Soligt</option>
<option> Molnigt</option>
<option> Regn</option>
<option> Snö</option>
</select>
</div>
<div class="form-field">
@@ -774,7 +774,7 @@ function renderStep() {
const items = checklist.map(c => `
<div class="check-item" onclick="toggleCheck('${c.id}')">
<div class="check-box ${checklistState[c.id] ? 'checked' : ''}" id="chk-${c.id}">
${checklistState[c.id] ? '' : ''}
${checklistState[c.id] ? '<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>' : ''}
</div>
<div>
<div class="check-text">${c.text}</div>
@@ -801,8 +801,8 @@ function renderStep() {
const photoSlots = Array(6).fill(0).map((_, i) => `
<div class="photo-thumb" id="photo-slot-${i}" onclick="addPhoto(${i})">
<img loading="lazy" loading="lazy" loading="lazy" loading="lazy" class="photo-preview" id="photo-img-${i}" src="" alt="Inspection photo">
<div class="photo-add-icon" id="photo-add-${i}">📷</div>
<div class="photo-delete" id="photo-del-${i}" onclick="deletePhoto(event,${i})"></div>
<div class="photo-add-icon" id="photo-add-${i}"><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></div>
<div class="photo-delete" id="photo-del-${i}" onclick="deletePhoto(event,${i})"></div>
</div>`).join('');
content = `
@@ -833,15 +833,15 @@ function renderStep() {
<div class="form-section-title">Digital signatur</div>
<div class="sig-wrap">
<canvas id="sig-canvas" height="180"></canvas>
<div class="sig-label" id="sig-hint">✍️ Rita din signatur här</div>
<div class="sig-label" id="sig-hint"> Rita din signatur här</div>
</div>
<div class="sig-actions">
<button class="sig-clear-btn" onclick="clearSig()"> Rensa</button>
<button class="sig-clear-btn" onclick="clearSig()"> Rensa</button>
</div>
</div>
</div>
<div style="padding:0 16px">
<button class="submit-btn" id="submit-btn" onclick="submitReport()">📤 Skicka in rapport</button>
<button class="submit-btn" id="submit-btn" onclick="submitReport()"> Skicka in rapport</button>
</div>
<div style="padding:0 16px 16px">
<button class="step-btn step-prev" style="width:100%;margin-top:8px" onclick="prevStep()">← Foton</button>
@@ -871,7 +871,7 @@ function prevStep() {
document.getElementById('form-scroll-area').scrollTop = 0;
}
// ─── Photos ───────────────────────────────────────────────────────────────────
// Photos
let activePhotoSlot = 0;
function addPhoto(slot) {
activePhotoSlot = slot;
@@ -900,7 +900,7 @@ function deletePhoto(e, slot) {
add.style.display = 'flex'; del.style.display = 'none';
}
// ─── Signature ────────────────────────────────────────────────────────────────
// Signature
function initSig() {
sigCanvas = document.getElementById('sig-canvas');
if (!sigCanvas) return;
@@ -932,7 +932,7 @@ function clearSig() {
if (hint) hint.style.opacity = '1';
}
// ─── Submit ───────────────────────────────────────────────────────────────────
// Submit
function submitReport() {
const btn = document.getElementById('submit-btn');
btn.disabled = true; btn.textContent = '⏳ Skickar...';
@@ -960,7 +960,7 @@ function submitReport() {
}, 1500);
}
// ─── Offline state ─────────────────────────────────────────────────────────────
// Offline state
function saveOfflineState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ currentMission, currentStep, checklistState }));