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
+25 -25
View File
@@ -47,7 +47,7 @@
.navbar-inner { display: flex; align-items: center; justify-content: space-between; height: 44px; padding: 0 16px; }
.navbar-logo { display: flex; align-items: center; gap: 8px; }
.navbar-logo span { font-size: 17px; font-weight: 700; color: var(--blue); letter-spacing: -0.5px; }
.navbar-badge { background: var(--blue); color: #1d1d1f; font-size: 11px; font-weight: 600; padding: 2px 7px; border-radius: 10px; }
.navbar-badge { background: var(--blue); color: #1d1d1f; font-size: 11px; font-weight: 600; padding: 2px 7px; border-radius: var(--radius-md); }
.scroll-area {
position: fixed; top: 0; bottom: 0; left: 0; right: 0;
@@ -89,7 +89,7 @@
text-transform: uppercase; letter-spacing: 0.3px;
}
.inset-group { margin: 0 16px 4px; border-radius: 12px; overflow: hidden; background: var(--card-bg); }
.inset-group { margin: 0 16px 4px; border-radius: var(--radius-md); overflow: hidden; background: var(--card-bg); }
.inset-group .cell { display: flex; align-items: center; padding: 12px 16px; min-height: 44px; border-bottom: 0.5px solid var(--sep); }
.inset-group .cell:last-child { border-bottom: none; }
.cell-label { flex: 1; font-size: 17px; }
@@ -98,7 +98,7 @@
.searchbar-wrap { padding: 8px 16px; }
.searchbar {
display: flex; align-items: center; gap: 8px;
background: var(--card-bg); border-radius: 10px;
background: var(--card-bg); border-radius: var(--radius-md);
padding: 8px 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.searchbar input { flex: 1; border: none; outline: none; font-size: 17px; background: transparent; color: var(--label); font-family: inherit; }
@@ -106,9 +106,9 @@
.searchbar .icon { color: var(--gray3); font-size: 18px; }
.seg-wrap { padding: 8px 16px; }
.seg-control { display: flex; background: var(--gray5); border-radius: 9px; padding: 2px; gap: 2px; }
.seg-control { display: flex; background: var(--gray5); border-radius: var(--radius-md); padding: 2px; gap: 2px; }
.seg-btn {
flex: 1; padding: 6px 4px; border: none; background: none; border-radius: 7px;
flex: 1; padding: 6px 4px; border: none; background: none; border-radius: var(--radius-sm);
font-size: 13px; font-weight: 500; color: var(--gray); cursor: pointer;
transition: all 0.2s; font-family: inherit;
}
@@ -116,13 +116,13 @@
.prop-list { padding: 0 16px; display: flex; flex-direction: column; gap: 10px; }
.prop-card {
background: var(--card-bg); border-radius: 12px;
background: var(--card-bg); border-radius: var(--radius-md);
padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
cursor: pointer; transition: transform 0.15s;
}
.prop-card:active { transform: scale(0.98); }
.prop-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.prop-type-badge { font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 6px; white-space: nowrap; }
.prop-type-badge { font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: var(--radius-sm); white-space: nowrap; }
.prop-address { font-size: 17px; font-weight: 600; letter-spacing: -0.3px; }
.prop-owner { font-size: 14px; color: var(--gray); margin-top: 2px; }
.prop-meta { display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
@@ -130,13 +130,13 @@
.prop-meta-label { font-size: 11px; color: var(--gray); font-weight: 500; text-transform: uppercase; letter-spacing: 0.3px; }
.prop-meta-value { font-size: 15px; font-weight: 600; }
.prop-condition { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; margin-top: 10px; }
.condition-dot { width: 8px; height: 8px; border-radius: 50%; }
.condition-dot { width: 8px; height: 8px; border-radius: var(--radius-full); }
.cond-good { background: var(--green); }
.cond-fair { background: var(--orange); }
.cond-poor, .cond-critical { background: var(--red); }
.cond-unknown { background: var(--gray3); }
.form-group { margin: 0 16px 4px; border-radius: 12px; overflow: hidden; background: var(--card-bg); }
.form-group { margin: 0 16px 4px; border-radius: var(--radius-md); overflow: hidden; background: var(--card-bg); }
.form-field { display: flex; flex-direction: column; padding: 10px 16px; border-bottom: 0.5px solid var(--sep); }
.form-field:last-child { border-bottom: none; }
.form-label { font-size: 12px; font-weight: 500; color: var(--gray); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.3px; }
@@ -147,7 +147,7 @@
.btn-primary {
display: block; width: calc(100% - 32px); margin: 16px 16px 8px;
background: var(--blue); color: #1d1d1f; font-size: 17px; font-weight: 600;
border: none; border-radius: 12px; padding: 14px; cursor: pointer;
border: none; border-radius: var(--radius-md); padding: 14px; cursor: pointer;
font-family: inherit; transition: opacity 0.15s; letter-spacing: -0.3px;
}
.btn-primary:active { opacity: 0.75; }
@@ -155,13 +155,13 @@
.btn-secondary {
display: block; width: calc(100% - 32px); margin: 4px 16px;
background: var(--gray6); color: var(--blue); font-size: 17px; font-weight: 500;
border: none; border-radius: 12px; padding: 14px; cursor: pointer;
border: none; border-radius: var(--radius-md); padding: 14px; cursor: pointer;
font-family: inherit; transition: opacity 0.15s;
}
.btn-secondary:active { opacity: 0.7; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px; }
.stat-card { background: var(--card-bg); border-radius: 12px; padding: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.stat-card { background: var(--card-bg); border-radius: var(--radius-md); padding: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.stat-icon { font-size: 24px; margin-bottom: 8px; }
.stat-value { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
.stat-label { font-size: 13px; color: var(--gray); margin-top: 2px; }
@@ -171,8 +171,8 @@
.bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.bar-name { font-size: 13px; font-weight: 500; flex: 0 0 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-count { font-size: 13px; color: var(--gray); margin-left: auto; }
.bar-track { height: 8px; background: var(--gray5); border-radius: 4px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 4px; background: var(--blue); transition: width 0.6s cubic-bezier(0.34,1.56,0.64,1); }
.bar-track { height: 8px; background: var(--gray5); border-radius: var(--radius-sm); overflow: hidden; }
.bar-fill { height: 100%; border-radius: var(--radius-sm); background: var(--blue); transition: width 0.6s cubic-bezier(0.34,1.56,0.64,1); }
.trend-row { display: flex; align-items: center; padding: 12px 16px; border-bottom: 0.5px solid var(--sep); }
.trend-row:last-child { border-bottom: none; }
@@ -184,13 +184,13 @@
.toast {
position: fixed; bottom: calc(83px + var(--safe-bottom) + 12px); left: 50%; transform: translateX(-50%) translateY(20px);
background: rgba(50,50,50,0.92); color: #1d1d1f; padding: 10px 18px; border-radius: 20px;
background: rgba(50,50,50,0.92); color: #1d1d1f; padding: 10px 18px; border-radius: var(--radius-xl);
font-size: 15px; font-weight: 500; opacity: 0; transition: all 0.3s; pointer-events: none;
white-space: nowrap; backdrop-filter: blur(10px); z-index: 200;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--gray4); border-top-color: var(--blue); border-radius: 50%; animation: spin 0.7s linear infinite; }
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--gray4); border-top-color: var(--blue); border-radius: var(--radius-full); animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-center { display: flex; justify-content: center; align-items: center; padding: 40px; gap: 10px; color: var(--gray); font-size: 15px; }
@@ -199,7 +199,7 @@
.empty-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
.empty-body { font-size: 15px; color: var(--gray); line-height: 1.5; }
.new-badge { background: rgba(52,199,89,0.15); color: var(--green); font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 6px; }
.new-badge { background: rgba(52,199,89,0.15); color: var(--green); font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: var(--radius-sm); }
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
@@ -349,7 +349,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="searchbar-wrap">
<div class="searchbar">
<span class="icon">🔍</span>
<span class="icon"><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></span>
<input type="text" id="cityInput" placeholder="Stad (t.ex. Stockholm)" autocomplete="off" autocorrect="off">
</div>
</div>
@@ -478,11 +478,11 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<nav class="tabbar">
<button class="tab-item active" onclick="switchTab('search',this)">
<span class="tab-icon">🔍</span>
<span class="tab-icon"><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></span>
<span class="tab-label">Sök</span>
</button>
<button class="tab-item" onclick="switchTab('register',this)">
<span class="tab-icon"></span>
<span class="tab-icon"></span>
<span class="tab-label">Registrera</span>
</button>
<button class="tab-item" onclick="switchTab('market',this)">
@@ -572,7 +572,7 @@ async function loadProperties(city, type) {
if (type) parts.push({commercial:'Kommersiella',residential:'Bostäder',industrial:'Industrier'}[type]||type);
header.textContent = (parts.join(' · ') || 'Alla fastigheter') + ` (${d.total})`;
if (!d.properties?.length) {
list.innerHTML = '<div class="empty-state"><div class="empty-icon">🏢</div><div class="empty-title">Inga fastigheter</div><div class="empty-body">Prova en annan stad eller kategori.</div></div>';
list.innerHTML = '<div class="empty-state"><div class="empty-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 5H7M6 8H7M6 11H7M9 5H10M9 8H10M9 11H10" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></div><div class="empty-title">Inga fastigheter</div><div class="empty-body">Prova en annan stad eller kategori.</div></div>';
return;
}
list.innerHTML = d.properties.map(renderPropCard).join('');
@@ -614,10 +614,10 @@ async function loadMarket(city) {
const d = await r.json();
const m = d.market_overview;
grid.innerHTML = `
<div class="stat-card"><div class="stat-icon">🏢</div><div class="stat-value">${m.total_properties}</div><div class="stat-label">Fastigheter</div></div>
<div class="stat-card"><div class="stat-icon">💰</div><div class="stat-value">${fmtSEK(m.total_assessed_value_sek)}</div><div class="stat-label">Totalt taxvärde</div></div>
<div class="stat-card"><div class="stat-icon">📐</div><div class="stat-value">${fmtSqm(m.total_area_sqm)}</div><div class="stat-label">Total yta</div></div>
<div class="stat-card"><div class="stat-icon">📈</div><div class="stat-value">${m.avg_value_per_sqm?.toLocaleString('sv-SE')||'—'}</div><div class="stat-label">kr/kvm snitt</div></div>`;
<div class="stat-card"><div class="stat-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 5H7M6 8H7M6 11H7M9 5H10M9 8H10M9 11H10" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></div><div class="stat-value">${m.total_properties}</div><div class="stat-label">Fastigheter</div></div>
<div class="stat-card"><div class="stat-icon"></div><div class="stat-value">${fmtSEK(m.total_assessed_value_sek)}</div><div class="stat-label">Totalt taxvärde</div></div>
<div class="stat-card"><div class="stat-icon"></div><div class="stat-value">${fmtSqm(m.total_area_sqm)}</div><div class="stat-label">Total yta</div></div>
<div class="stat-card"><div class="stat-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 12L6 8L9 11L14 5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 5H14V9" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div class="stat-value">${m.avg_value_per_sqm?.toLocaleString('sv-SE')||'—'}</div><div class="stat-label">kr/kvm snitt</div></div>`;
const totalCnt = Object.values(m.by_type).reduce((s,v)=>s+v.count,0)||1;
document.getElementById('typeDistribution').innerHTML = Object.entries(m.by_type).map(([t,v]) => {