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
+27 -27
View File
@@ -31,7 +31,7 @@
.logo { font-size: 18px; font-weight: 700; letter-spacing: -0.5px; }
.logo span { color: var(--accent); }
.badge-live { background: var(--accent); color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 6px; border-radius: 4px; letter-spacing: 0.5px; animation: pulse 2s infinite; }
padding: 2px 6px; border-radius: var(--radius-sm); letter-spacing: 0.5px; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.6} }
/* Stats bar */
@@ -55,7 +55,7 @@
}
.filter-btn {
background: var(--surface); color: var(--text2); border: 1px solid var(--border);
border-radius: 8px; padding: 5px 14px; font-size: 13px; cursor: pointer; transition: all 0.15s;
border-radius: var(--radius-md); padding: 5px 14px; font-size: 13px; cursor: pointer; transition: all 0.15s;
font-family: var(--font);
}
.filter-btn:hover { border-color: rgba(255,255,255,0.2); color: var(--text); }
@@ -65,26 +65,26 @@
#map { flex: 1; }
/* Popup styling */
.leaflet-popup-content-wrapper { background: #F2F2F7; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
.leaflet-popup-content-wrapper { background: #F2F2F7; border: 1px solid rgba(255,255,255,0.12); border-radius: var(--radius-md); box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
.leaflet-popup-tip { background: #F2F2F7; }
.leaflet-popup-content { color: #1c1c1e; margin: 0; padding: 0; }
.popup { padding: 14px; min-width: 220px; }
.popup-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
.popup-row { display: flex; justify-content: space-between; font-size: 12px; color: #555; margin-bottom: 4px; }
.popup-row span:last-child { color: #f5f5f7; }
.popup-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; margin-top: 8px; }
.popup-badge { display: inline-block; padding: 2px 8px; border-radius: var(--radius-sm); font-size: 10px; font-weight: 600; text-transform: uppercase; margin-top: 8px; }
.badge-urgent { background: rgba(255,59,48,0.2); color: #ff3b30; }
.badge-normal { background: rgba(52,199,89,0.15); color: #34c759; }
.badge-pending { background: rgba(255,159,10,0.15); color: #ff9f0a; }
/* Status dot */
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 5px; }
.status-dot { width: 8px; height: 8px; border-radius: var(--radius-full); display: inline-block; margin-right: 5px; }
.dot-active { background: var(--accent2); animation: glow 1.5s infinite; }
.dot-pending { background: #ff9f0a; }
@keyframes glow { 0%,100%{box-shadow:0 0 0 0 rgba(52,199,89,0.4)} 50%{box-shadow:0 0 0 4px rgba(52,199,89,0)} }
/* Connection status */
.conn-badge { font-size: 11px; padding: 3px 8px; border-radius: 6px; }
.conn-badge { font-size: 11px; padding: 3px 8px; border-radius: var(--radius-sm); }
.conn-connected { background: rgba(52,199,89,0.2); color: var(--accent2); }
.conn-disconnected { background: rgba(255,59,48,0.2); color: var(--accent); }
.conn-connecting { background: rgba(255,159,10,0.2); color: #ff9f0a; }
@@ -133,7 +133,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="header">
<div class="header-left">
<div class="logo">qui<span>X</span>zoom</div>
<span class="badge-live"> LIVE</span>
<span class="badge-live"> LIVE</span>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span id="conn-status" class="conn-badge conn-connecting">Ansluter…</span>
@@ -171,7 +171,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ── Map init ─────────────────────────────────────────────────────────────
// Map init
const map = L.map('map', {
center: [7.9519, 98.2849],
zoom: 12,
@@ -184,7 +184,7 @@ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
// ── State ────────────────────────────────────────────────────────────────
// State
let missionMarkers = {};
let zoomerMarkers = {};
let currentFilter = 'all';
@@ -192,12 +192,12 @@ let currentMissions = [];
let currentZoomers = [];
let clientCount = 0;
// ── Icons ────────────────────────────────────────────────────────────────
// Icons
function missionIcon(priority, status) {
const color = status === 'urgent' || priority === 'urgent' ? '#ff3b30' : status === 'completed' ? '#636366' : '#ff9f0a';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40">
<path d="M16 0C7.16 0 0 7.16 0 16c0 12 16 24 16 24s16-12 16-24C32 7.16 24.84 0 16 0z" fill="${color}" opacity="0.95"/>
<text x="16" y="21" text-anchor="middle" font-size="14" fill="white">📷</text>
<text x="16" y="21" text-anchor="middle" font-size="14" fill="white"><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></text>
</svg>`;
return L.divIcon({ html: svg, iconSize: [32, 40], iconAnchor: [16, 40], popupAnchor: [0, -40], className: '' });
}
@@ -215,7 +215,7 @@ function zoomerIcon(name) {
return L.divIcon({ html: svg, iconSize: [28, 28], iconAnchor: [14, 14], popupAnchor: [0, -14], className: '' });
}
// ── Filter ───────────────────────────────────────────────────────────────
// Filter
function setFilter(filter) {
currentFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
@@ -223,7 +223,7 @@ function setFilter(filter) {
renderMissions(currentMissions);
}
// ── Render missions ───────────────────────────────────────────────────────
// Render missions
function renderMissions(missions) {
currentMissions = missions;
// Remove old markers
@@ -237,14 +237,14 @@ function renderMissions(missions) {
const marker = L.marker([mission.lat, mission.lng], { icon }).addTo(map);
const priorityBadge = mission.priority === 'urgent'
? '<span class="popup-badge badge-urgent">🔴 Urgent</span>'
? '<span class="popup-badge badge-urgent"> Urgent</span>'
: mission.status === 'pending'
? '<span class="popup-badge badge-pending">⏳ Väntande</span>'
: '<span class="popup-badge badge-normal"> Aktiv</span>';
: '<span class="popup-badge badge-normal"><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> Aktiv</span>';
marker.bindPopup(`
<div class="popup">
<div class="popup-title">📷 ${mission.title}</div>
<div class="popup-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> ${mission.title}</div>
<div class="popup-row"><span>Kund</span><span>${mission.customer}</span></div>
<div class="popup-row"><span>Typ</span><span>${mission.type}</span></div>
<div class="popup-row"><span>Status</span><span><span class="status-dot dot-${mission.status === 'active' ? 'active' : 'pending'}"></span>${mission.status}</span></div>
@@ -257,7 +257,7 @@ function renderMissions(missions) {
});
}
// ── Render zoomers ────────────────────────────────────────────────────────
// Render zoomers
function renderZoomers(zoomers) {
currentZoomers = zoomers;
// Move existing or create new
@@ -271,7 +271,7 @@ function renderZoomers(zoomers) {
<div class="popup">
<div class="popup-title">🟢 ${z.name}</div>
<div class="popup-row"><span>ID</span><span>${z.id}</span></div>
<div class="popup-row"><span>Betyg</span><span> ${z.rating}</span></div>
<div class="popup-row"><span>Betyg</span><span> ${z.rating}</span></div>
<div class="popup-row"><span>Status</span><span>Online</span></div>
</div>
`, { maxWidth: 220 });
@@ -280,7 +280,7 @@ function renderZoomers(zoomers) {
});
}
// ── Stats ─────────────────────────────────────────────────────────────────
// Stats
function updateStats(stats) {
document.getElementById('stat-active').textContent = stats.active_missions ?? '—';
document.getElementById('stat-zoomers').textContent = stats.zoomers_online ?? '—';
@@ -289,7 +289,7 @@ function updateStats(stats) {
document.getElementById('stat-clients').textContent = clientCount;
}
// ── WebSocket ─────────────────────────────────────────────────────────────
// WebSocket
let ws;
let reconnectTimer;
@@ -299,7 +299,7 @@ function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
document.getElementById('conn-status').textContent = ' Ansluten';
document.getElementById('conn-status').textContent = ' Ansluten';
document.getElementById('conn-status').className = 'conn-badge conn-connected';
clearTimeout(reconnectTimer);
};
@@ -322,12 +322,12 @@ function connect() {
delete missionMarkers[d.missionId];
}
// Toast
showToast(` Uppdrag klart: ${d.title}`);
showToast(`<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 klart: ${d.title}`);
}
};
ws.onclose = () => {
document.getElementById('conn-status').textContent = ' Frånkopplad';
document.getElementById('conn-status').textContent = ' Frånkopplad';
document.getElementById('conn-status').className = 'conn-badge conn-disconnected';
reconnectTimer = setTimeout(() => {
document.getElementById('conn-status').textContent = '… Återansluter';
@@ -342,7 +342,7 @@ function connect() {
};
}
// ── Fallback polling (if WS not available) ────────────────────────────────
// Fallback polling (if WS not available)
let pollInterval;
function fallbackPolling() {
if (pollInterval) return;
@@ -361,16 +361,16 @@ function fallbackPolling() {
}, 3000);
}
// ── Toast notification ────────────────────────────────────────────────────
// Toast notification
function showToast(msg) {
const t = document.createElement('div');
t.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1c1c1e;color:#f5f5f7;border:1px solid rgba(255,255,255,0.12);border-radius:10px;padding:10px 20px;font-size:14px;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
t.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1c1c1e;color:#f5f5f7;border:1px solid rgba(255,255,255,0.12);border-radius: var(--radius-md);padding:10px 20px;font-size:14px;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 4000);
}
// ── Check URL params for mission focus ────────────────────────────────────
// Check URL params for mission focus
const urlParams = new URLSearchParams(location.search);
const focusMission = urlParams.get('mission');