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
+35 -35
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>quiXzoom API Status</title>
<style>
/* ── iOS 26 / Apple Native Design System ─────────────────────────────── */
/* iOS 26 / Apple Native Design System */
:root {
--bg: #000000;
--bg-secondary: #1c1c1e;
@@ -44,7 +44,7 @@
overscroll-behavior-y: contain;
}
/* ── Navigation Bar ───────────────────────────────────────────────────── */
/* Navigation Bar */
.nav-bar {
position: sticky;
top: 0;
@@ -70,7 +70,7 @@
color: var(--label-secondary);
}
/* ── Large Title ──────────────────────────────────────────────────────── */
/* Large Title */
.page-header {
padding: 24px 20px 8px;
}
@@ -87,7 +87,7 @@
margin-top: 4px;
}
/* ── Summary Cards ────────────────────────────────────────────────────── */
/* Summary Cards */
.summary-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -107,7 +107,7 @@
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
border-radius: 2px 2px 0 0;
border-radius: var(--radius-sm) 2px 0 0;
}
.stat-card.green::before { background: var(--green); }
.stat-card.red::before { background: var(--red); }
@@ -131,7 +131,7 @@
letter-spacing: 0.3px;
}
/* ── Section ──────────────────────────────────────────────────────────── */
/* Section */
.section {
margin-top: 28px;
}
@@ -159,7 +159,7 @@
}
.section-action:active { opacity: 0.6; }
/* ── Endpoint List ────────────────────────────────────────────────────── */
/* Endpoint List */
.endpoint-list {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
@@ -186,7 +186,7 @@
font-weight: 700;
letter-spacing: 0.4px;
padding: 3px 7px;
border-radius: 5px;
border-radius: var(--radius-sm);
font-family: "SF Mono", "Menlo", monospace;
}
.method-GET { background: rgba(48,209,88,0.2); color: var(--green); }
@@ -226,7 +226,7 @@
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.dot-green { background: var(--green); box-shadow: 0 0 6px rgba(48,209,88,0.5); }
@@ -243,7 +243,7 @@
.resp-time.fast { color: var(--green); }
.resp-time.slow { color: var(--orange); }
/* ── Test Button ──────────────────────────────────────────────────────── */
/* Test Button */
.test-btn {
display: inline-flex;
align-items: center;
@@ -272,7 +272,7 @@
pointer-events: none;
}
/* ── Detail Row (expandable) ─────────────────────────────────────────── */
/* Detail Row (expandable) */
.endpoint-detail {
display: none;
background: var(--bg-tertiary);
@@ -301,7 +301,7 @@
word-break: break-all;
}
/* ── Run All Button ───────────────────────────────────────────────────── */
/* Run All Button */
.run-all-container {
padding: 20px 16px 8px;
}
@@ -333,7 +333,7 @@
pointer-events: none;
}
/* ── Last Run ─────────────────────────────────────────────────────────── */
/* Last Run */
.last-run {
text-align: center;
font-size: 12px;
@@ -341,7 +341,7 @@
padding: 8px 20px 0;
}
/* ── Toast ────────────────────────────────────────────────────────────── */
/* Toast */
.toast {
position: fixed;
bottom: calc(var(--safe-bottom) + 20px);
@@ -354,7 +354,7 @@
font-size: 14px;
font-weight: 500;
padding: 12px 20px;
border-radius: 999px;
border-radius: var(--radius-full);
z-index: 999;
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.3s;
opacity: 0;
@@ -366,34 +366,34 @@
opacity: 1;
}
/* ── Progress bar ─────────────────────────────────────────────────────── */
/* Progress bar */
.progress-bar {
height: 3px;
background: var(--bg-tertiary);
border-radius: 2px;
border-radius: var(--radius-sm);
margin: 0 16px 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--tint);
border-radius: 2px;
border-radius: var(--radius-sm);
width: 0%;
transition: width 0.3s ease;
}
/* ── Spinner ──────────────────────────────────────────────────────────── */
/* Spinner */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
border-radius: var(--radius-full);
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
/* ── Footer ───────────────────────────────────────────────────────────── */
/* Footer */
.footer {
text-align: center;
padding: 32px 20px 8px;
@@ -468,12 +468,12 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- Run All -->
<div class="run-all-container">
<button class="run-all-btn" id="run-all-btn" onclick="runAll()">
<span> Testa alla endpoints</span>
<span> Testa alla endpoints</span>
</button>
</div>
<div class="last-run" id="last-run">Inte testad ännu</div>
<!-- ── Endpoint Sections ───────────────────────────────────────────────── -->
<!-- Endpoint Sections -->
<!-- Zoomers / Profiles -->
<div class="section">
@@ -877,7 +877,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="toast" id="toast"></div>
<script>
// ── Endpoint definitions ───────────────────────────────────────────────
// Endpoint definitions
const BASE = '';
const ENDPOINTS = {
@@ -910,7 +910,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
const results = {};
let testingAll = false;
// ── Test single endpoint ───────────────────────────────────────────────
// Test single endpoint
async function testEndpoint(id) {
const ep = ENDPOINTS[id];
if (!ep) return;
@@ -944,7 +944,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
if (resp) resp.textContent = JSON.stringify(data).slice(0, 120) + (JSON.stringify(data).length > 120 ? '…' : '');
btn.classList.remove('testing');
btn.textContent = ok ? '✓' : '✗';
btn.textContent = ok ? '<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>' : '<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>';
btn.style.background = ok ? 'var(--green)' : 'var(--red)';
setTimeout(() => {
btn.textContent = 'Test';
@@ -959,14 +959,14 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
ms.textContent = `${elapsed}ms`;
if (resp) resp.textContent = String(e).slice(0, 120);
btn.classList.remove('testing');
btn.textContent = '';
btn.textContent = '<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>';
btn.style.background = 'var(--red)';
setTimeout(() => { btn.textContent = 'Test'; btn.style.background = ''; }, 2000);
updateSummary();
}
}
// ── Test a section ─────────────────────────────────────────────────────
// Test a section
async function testSection(section) {
const ids = Object.keys(ENDPOINTS).filter(id => ENDPOINTS[id].section === section);
for (const id of ids) {
@@ -975,7 +975,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
}
}
// ── Run All ────────────────────────────────────────────────────────────
// Run All
async function runAll() {
if (testingAll) return;
testingAll = true;
@@ -994,15 +994,15 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
prog.style.width = '100%';
testingAll = false;
btn.disabled = false;
btn.innerHTML = '<span> Kör igen</span>';
btn.innerHTML = '<span> Kör igen</span>';
const now = new Date().toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
document.getElementById('last-run').textContent = `Senast testad: ${now}`;
const okCount = Object.values(results).filter(r => r.ok).length;
showToast(`${okCount}/${ids.length} endpoints `);
showToast(`${okCount}/${ids.length} endpoints <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>`);
}
// ── Update summary bar ─────────────────────────────────────────────────
// Update summary bar
function updateSummary() {
const vals = Object.values(results);
const ok = vals.filter(r => r.ok).length;
@@ -1019,14 +1019,14 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
document.getElementById('nav-status').textContent = `${tested}/${total} testad`;
}
// ── Toggle detail row ──────────────────────────────────────────────────
// Toggle detail row
function toggleDetail(id) {
const el = document.getElementById(`detail-${id}`);
if (!el) return;
el.classList.toggle('open');
}
// ── Toast ──────────────────────────────────────────────────────────────
// Toast
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
@@ -1036,7 +1036,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
// ── Auto-run on load ───────────────────────────────────────────────────
// Auto-run on load
window.addEventListener('load', () => {
setTimeout(runAll, 600);
});