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
+47 -47
View File
@@ -71,7 +71,7 @@ html, body {
-webkit-font-smoothing: antialiased;
}
/* ── Navbar ─────────────────────────────────────────────────────────── */
/* Navbar */
.navbar {
position: fixed;
top: 0; left: 0; right: 0;
@@ -93,7 +93,7 @@ html, body {
.navbar-logo {
width: 28px; height: 28px;
background: linear-gradient(135deg, #007AFF 0%, #5AC8FA 100%);
border-radius: 7px;
border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
font-size: 15px;
flex-shrink: 0;
@@ -113,11 +113,11 @@ html, body {
color: var(--qx-blue);
background: rgba(0,122,255,.1);
padding: 3px 8px;
border-radius: 20px;
border-radius: var(--radius-xl);
letter-spacing: 0;
}
/* ── Layout ─────────────────────────────────────────────────────────── */
/* Layout */
.scroll-content {
padding-top: calc(var(--safe-top) + 52px + 20px);
padding-bottom: calc(var(--safe-bottom) + 40px);
@@ -127,7 +127,7 @@ html, body {
margin: 0 auto;
}
/* ── Section ─────────────────────────────────────────────────────────── */
/* Section */
.section-label {
font-size: 13px;
font-weight: 600;
@@ -140,7 +140,7 @@ html, body {
}
.section-label:first-child { margin-top: 0; }
/* ── Card ─────────────────────────────────────────────────────────── */
/* Card */
.card {
background: var(--bg-card);
border-radius: var(--radius-lg);
@@ -149,7 +149,7 @@ html, body {
margin-bottom: 20px;
}
/* ── Hero summary ─────────────────────────────────────────────────── */
/* Hero summary */
.hero-card {
background: linear-gradient(160deg, #007AFF 0%, #5856D6 100%);
border-radius: var(--radius-xl);
@@ -164,7 +164,7 @@ html, body {
position: absolute;
top: -40px; right: -40px;
width: 180px; height: 180px;
border-radius: 50%;
border-radius: var(--radius-full);
background: rgba(255,255,255,.08);
}
.hero-tag {
@@ -193,7 +193,7 @@ html, body {
}
.hero-stat {
background: rgba(255,255,255,.15);
border-radius: 12px;
border-radius: var(--radius-md);
padding: 12px;
}
.hero-stat-value {
@@ -207,7 +207,7 @@ html, body {
margin-top: 2px;
}
/* ── Deadline banner ─────────────────────────────────────────────── */
/* Deadline banner */
.deadline-banner {
display: flex;
align-items: center;
@@ -245,7 +245,7 @@ html, body {
margin-top: -2px;
}
/* ── Status pill ─────────────────────────────────────────────────── */
/* Status pill */
.status-row {
display: flex;
align-items: center;
@@ -258,15 +258,15 @@ html, body {
gap: 6px;
font-size: 13px;
font-weight: 600;
border-radius: 20px;
border-radius: var(--radius-xl);
padding: 4px 12px;
}
.status-pill.ready { background: rgba(52,199,89,.15); color: #34C759; }
.status-pill.progress { background: rgba(255,149,0,.15); color: #FF9500; }
.status-pill.overdue { background: rgba(255,59,48,.15); color: #FF3B30; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-dot { width: 6px; height: 6px; border-radius: var(--radius-full); background: currentColor; }
/* ── List row ─────────────────────────────────────────────────────── */
/* List row */
.list-row {
display: flex;
align-items: center;
@@ -279,7 +279,7 @@ html, body {
.list-row-icon {
width: 34px; height: 34px;
border-radius: 8px;
border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 17px;
flex-shrink: 0;
@@ -318,14 +318,14 @@ html, body {
margin-top: 1px;
}
/* ── TIN badge ─────────────────────────────────────────────────────── */
/* TIN badge */
.tin-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
border-radius: 6px;
border-radius: var(--radius-sm);
padding: 2px 7px;
margin-top: 4px;
}
@@ -338,7 +338,7 @@ html, body {
color: #FF9500;
}
/* ── Threshold badge ─────────────────────────────────────────────── */
/* Threshold badge */
.threshold-badge {
display: inline-flex;
align-items: center;
@@ -346,7 +346,7 @@ html, body {
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
border-radius: 5px;
border-radius: var(--radius-sm);
padding: 2px 6px;
}
.threshold-badge.over {
@@ -358,7 +358,7 @@ html, body {
color: var(--label-tertiary);
}
/* ── Export buttons ─────────────────────────────────────────────── */
/* Export buttons */
.export-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -392,14 +392,14 @@ html, body {
color: var(--qx-green);
}
/* ── Verify button ─────────────────────────────────────────────── */
/* Verify button */
.verify-btn {
font-size: 13px;
font-weight: 600;
color: var(--qx-blue);
background: rgba(0,122,255,.1);
border: none;
border-radius: 8px;
border-radius: var(--radius-md);
padding: 5px 12px;
cursor: pointer;
transition: opacity .15s;
@@ -413,7 +413,7 @@ html, body {
cursor: default;
}
/* ── Progress bar ─────────────────────────────────────────────── */
/* Progress bar */
.progress-wrap {
padding: 12px 16px;
border-bottom: 0.5px solid var(--separator);
@@ -428,22 +428,22 @@ html, body {
.progress-bar {
height: 6px;
background: var(--fill2);
border-radius: 3px;
border-radius: var(--radius-sm);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, #007AFF, #34C759);
transition: width .6s cubic-bezier(.25,.46,.45,.94);
}
/* ── Loading / error states ──────────────────────────────────── */
/* Loading / error states */
.skeleton {
background: linear-gradient(90deg, var(--fill2) 25%, var(--fill1) 50%, var(--fill2) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 6px;
border-radius: var(--radius-sm);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
@@ -477,7 +477,7 @@ html, body {
}
.error-banner.visible { display: block; }
/* ── Footer note ─────────────────────────────────────────────── */
/* Footer note */
.footer-note {
font-size: 12px;
color: var(--label-tertiary);
@@ -487,7 +487,7 @@ html, body {
line-height: 1.5;
}
/* ── Animations ─────────────────────────────────────────────── */
/* Animations */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
@@ -530,7 +530,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Navbar -->
<nav class="navbar">
<div class="navbar-logo">📍</div>
<div class="navbar-logo"></div>
<div class="navbar-title">DAC7 Rapportering</div>
<div class="navbar-badge">quiXzoom</div>
</nav>
@@ -620,10 +620,10 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="card animate-in">
<div class="export-row">
<button class="export-btn json" onclick="exportReport('json')">
📄 JSON
JSON
</button>
<button class="export-btn xml" onclick="exportReport('xml')">
🗄️ XML (Skatteverket)
XML (Skatteverket)
</button>
</div>
<div style="padding: 0 16px 14px; font-size: 13px; color: var(--label-secondary); line-height: 1.4;">
@@ -655,7 +655,7 @@ const YEAR = new Date().getFullYear();
let allSellers = [];
let statusData = null;
// ── Format helpers ────────────────────────────────────────────────────────
// Format helpers
function formatEur(v) {
return new Intl.NumberFormat('sv-SE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(v);
@@ -692,17 +692,17 @@ function reasonLabel(reason) {
return map[reason] || reason;
}
// ── Render sellers ────────────────────────────────────────────────────────
// Render sellers
function renderSeller(s, showVerify) {
const tin = s.tin_verified
? `<span class="tin-badge verified"> TIN verifierad</span>`
: `<span class="tin-badge unverified"> TIN ej verifierad</span>`;
? `<span class="tin-badge verified"><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> TIN verifierad</span>`
: `<span class="tin-badge unverified"> TIN ej verifierad</span>`;
const verifyBtn = showVerify && !s.tin_verified
? `<button class="verify-btn" onclick="verifySeller('${s.seller_id}')">Verifiera</button>`
: showVerify && s.tin_verified
? `<button class="verify-btn done" disabled> Klar</button>`
? `<button class="verify-btn done" disabled><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> Klar</button>`
: '';
const thresholdBadge = s.reportable
@@ -712,7 +712,7 @@ function renderSeller(s, showVerify) {
return `
<div class="list-row" id="seller-${s.seller_id}">
<div class="list-row-icon" style="background:${s.reportable ? 'rgba(0,122,255,.1)' : 'var(--fill2)'}">
${s.reportable ? '👤' : '👥'}
${s.reportable ? '' : ''}
</div>
<div class="list-row-body">
<div class="list-row-title">${escHtml(s.name)}</div>
@@ -738,7 +738,7 @@ function renderAllSellers(sellers) {
const belowEl = document.getElementById('belowThresholdList');
if (reportable.length === 0) {
sellersEl.innerHTML = `<div class="empty-state"><div class="empty-icon">🎉</div><div class="empty-title">Inga rapportpliktiga Zoomers</div><div class="empty-sub">Alla under tröskelvärdena</div></div>`;
sellersEl.innerHTML = `<div class="empty-state"><div class="empty-icon"></div><div class="empty-title">Inga rapportpliktiga Zoomers</div><div class="empty-sub">Alla under tröskelvärdena</div></div>`;
} else {
sellersEl.innerHTML = reportable.map(s => renderSeller(s, true)).join('');
}
@@ -750,7 +750,7 @@ function renderAllSellers(sellers) {
}
}
// ── Load data ─────────────────────────────────────────────────────────────
// Load data
async function loadData() {
const errorBanner = document.getElementById('errorBanner');
@@ -791,13 +791,13 @@ function renderStatus(data) {
document.getElementById('deadlineDays').innerHTML = `${days}<span>dagar</span>`;
document.getElementById('deadlineDate').textContent = `Deadline: ${data.deadline}`;
if (days < 0) {
document.getElementById('deadlineIcon').textContent = '🚨';
document.getElementById('deadlineIcon').textContent = '';
document.getElementById('deadlineDays').style.color = 'var(--qx-red)';
} else if (days < 30) {
document.getElementById('deadlineIcon').textContent = '⚠️';
document.getElementById('deadlineIcon').textContent = '<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>';
document.getElementById('deadlineDays').style.color = 'var(--qx-orange)';
} else {
document.getElementById('deadlineIcon').textContent = '📅';
document.getElementById('deadlineIcon').textContent = '';
document.getElementById('deadlineDays').style.color = 'var(--qx-blue)';
}
@@ -814,7 +814,7 @@ function renderStatus(data) {
document.getElementById('statusText').textContent = statusLabel(data.status);
}
// ── Verify seller ─────────────────────────────────────────────────────────
// Verify seller
async function verifySeller(sellerId) {
const btn = document.querySelector(`#seller-${sellerId} .verify-btn`);
@@ -854,7 +854,7 @@ async function reloadStatus() {
} catch (e) { /* ignore */ }
}
// ── Export ────────────────────────────────────────────────────────────────
// Export
async function exportReport(format) {
try {
@@ -894,13 +894,13 @@ function downloadBlob(blob, filename, type) {
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
}
// ── Utils ─────────────────────────────────────────────────────────────────
// Utils
function escHtml(str) {
return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Init ──────────────────────────────────────────────────────────────────
// Init
loadData();
</script>