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
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Tillgänglighetsaudit</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Tillgångsspårning</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Broinspektion</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Byggframsteg</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Miljöinventering</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Eventdokumentation</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Fasadinspektion</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
+24 -22
View File
@@ -182,7 +182,7 @@ h1 em{font-style:normal;color:var(--qx-blue)}
.btn-secondary:hover{background:rgba(255,255,255,.15);transform:translateY(-2px)}
.trust-strip{display:flex;align-items:center;justify-content:center;gap:16px;flex-wrap:wrap;margin-top:16px;color:rgba(255,255,255,.5);font-size:.8125rem;font-weight:500}
.trust-strip span{display:flex;align-items:center;gap:6px}
.trust-strip span::before{content:'';color:var(--qx-green);font-weight:700}
.trust-strip span::before{content:'<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>';color:var(--qx-green);font-weight:700}
/* =========================================
STATS BAR
@@ -254,8 +254,8 @@ section{padding:88px 24px}
.payment-details{margin-top:36px}
.payment-details summary{cursor:pointer;font-size:1rem;font-weight:700;color:var(--qx-black);padding:18px 24px;background:var(--qx-gray);border-radius:var(--qx-radius);list-style:none;display:flex;align-items:center;justify-content:space-between;transition:background .15s}
.payment-details summary:hover{background:#eaeaec}
.payment-details summary::after{content:'';font-size:1.25rem;color:var(--qx-blue);transition:transform .25s}
.payment-details[open] summary::after{content:''}
.payment-details summary::after{content:'';font-size:1.25rem;color:var(--qx-blue);transition:transform .25s}
.payment-details[open] summary::after{content:''}
.payment-details-body{padding:24px;background:var(--qx-gray);border-radius:0 0 var(--qx-radius) var(--qx-radius);margin-top:-4px}
.payment-details-body ul{list-style:none;display:flex;flex-direction:column;gap:12px}
.payment-details-body li{display:flex;gap:10px;color:var(--qx-gray-dark);font-size:.9375rem;line-height:1.5}
@@ -292,7 +292,7 @@ section{padding:88px 24px}
.client-card:hover .client-extra,.client-card:focus-within .client-extra{max-height:180px;padding:0 28px 24px}
.client-extra ul{list-style:none;display:flex;flex-direction:column;gap:8px;margin-top:12px;border-top:1px solid var(--qx-border);padding-top:14px}
.client-extra li{display:flex;gap:8px;color:var(--qx-gray-dark);font-size:.875rem;line-height:1.5}
.client-extra li::before{content:'';color:var(--qx-green);font-weight:700;flex-shrink:0}
.client-extra li::before{content:'<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>';color:var(--qx-green);font-weight:700;flex-shrink:0}
.client-extra a{display:inline-flex;align-items:center;gap:4px;color:var(--qx-blue);font-size:.875rem;font-weight:600;margin-top:12px;transition:gap .15s}
.client-extra a:hover{gap:8px}
.client-ctas{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
@@ -553,17 +553,18 @@ footer{background:var(--qx-footer);color:rgba(255,255,255,.6);padding:60px 24px
</ul>
<div class="nav-right">
<div class="lang-dropdown" tabindex="0" role="navigation" aria-label="Välj språk">
<button class="lang-toggle" aria-haspopup="true" aria-label="Välj språk — Svenska aktiv">🇸🇪 SV </button>
<button class="lang-toggle" aria-haspopup="true" aria-label="Välj språk — Svenska aktiv"><span class="flag-se">SE</span> SV </button>
<ul class="lang-menu" role="menu">
<li><a href="https://quixzoom.se/" hreflang="sv" class="active" role="menuitem">🇸🇪 Svenska</a></li>
<li><a href="https://quixzoom.de/" hreflang="de" role="menuitem">🇩🇪 Deutsch</a></li>
<li><a href="https://quixzoom.fr/" hreflang="fr" role="menuitem">🇫🇷 Français</a></li>
<li><a href="https://quixzoom.nl/" hreflang="nl" role="menuitem">🇳🇱 Nederlands</a></li>
<li><a href="https://quixzoom.co.uk/" hreflang="en-gb" role="menuitem">🇬🇧 English</a></li>
<li><a href="https://quixzoom.it/" hreflang="it" role="menuitem">🇮🇹 Italiano</a></li>
<li><a href="https://quixzoom.es/" hreflang="es" role="menuitem">🇪🇸 Español</a></li>
<li><a href="https://quixzoom.se/" hreflang="sv" class="active" role="menuitem"><span class="flag-se">SE</span> Svenska</a></li>
<li><a href="https://quixzoom.de/" hreflang="de" role="menuitem"> Deutsch</a></li>
<li><a href="https://quixzoom.fr/" hreflang="fr" role="menuitem"> Français</a></li>
<li><a href="https://quixzoom.nl/" hreflang="nl" role="menuitem"> Nederlands</a></li>
<li><a href="https://quixzoom.co.uk/" hreflang="en-gb" role="menuitem"> English</a></li>
<li><a href="https://quixzoom.it/" hreflang="it" role="menuitem"> Italiano</a></li>
<li><a href="https://quixzoom.es/" hreflang="es" role="menuitem"> Español</a></li>
</ul>
</div>
<a href="/login/" style="color:rgba(255,255,255,.7);font-size:.875rem;font-weight:600;padding:8px 12px;border-radius:8px;transition:color .15s,background .15s;text-decoration:none;white-space:nowrap">Logga in</a>
<a class="nav-cta" href="https://app.quixzoom.com/join">Bli Zoomer →</a>
<label class="hamburger" for="nav-toggle" aria-label="Öppna meny" role="button">
<span></span><span></span><span></span>
@@ -576,6 +577,7 @@ footer{background:var(--qx-footer);color:rgba(255,255,255,.6);padding:60px 24px
<a href="#tjäna-pengar">Tjäna pengar</a>
<a href="#uppdragsgivare">Uppdragsgivare</a>
<a href="#faq">FAQ</a>
<a href="/login/" style="color:rgba(255,255,255,.85);font-size:1rem;font-weight:500;padding:10px 14px;border-radius:10px;transition:background .15s;text-decoration:none;display:block">Logga in</a>
<a class="m-cta" href="https://app.quixzoom.com/join">Bli Zoomer gratis →</a>
</nav>
</nav>
@@ -587,7 +589,7 @@ footer{background:var(--qx-footer);color:rgba(255,255,255,.6);padding:60px 24px
==================================================== -->
<section id="hero" aria-labelledby="hero-h1">
<div class="container">
<div class="hero-badge">🇸🇪 Sverige · Lanseras 1 augusti 2026 · Skärgårdsregionen</div>
<div class="hero-badge"><span class="flag-se">SE</span> Sverige · Lanseras 1 augusti 2026 · Skärgårdsregionen</div>
<h1 id="hero-h1">Välj ett uppdrag.<br>Gå dit. Fotografera.<br><em>Tjäna.</em></h1>
<p class="hero-sub">quiXzoom betalar dig f
<p class="hero-diff">Nordens enda plattform för on-demand fältinspektion — det drönare och satelliter inte ser.</p>ör att fotografera infrastruktur, natur och stadsmiljöer nära dig. Välj dina egna tider. Betalt varje måndag.</p>
@@ -1003,14 +1005,14 @@ footer{background:var(--qx-footer);color:rgba(255,255,255,.6);padding:60px 24px
<p class="app-sub">Registrera dig gratis. Välj ditt första uppdrag. Tjäna pengar den här veckan.</p>
<div class="app-buttons">
<a class="app-btn" href="https://app.quixzoom.com/join" aria-label="Registrera dig — App Store kommer snart">
<div class="app-btn-icon">🍎</div>
<div class="app-btn-icon"></div>
<div class="app-btn-text">
<small>Ladda ner på</small>
App Store
</div>
</a>
<a class="app-btn" href="https://app.quixzoom.com/join" aria-label="Registrera dig — Google Play kommer snart">
<div class="app-btn-icon"></div>
<div class="app-btn-icon"></div>
<div class="app-btn-text">
<small>Hämta på</small>
Google Play
@@ -1039,13 +1041,13 @@ footer{background:var(--qx-footer);color:rgba(255,255,255,.6);padding:60px 24px
<div class="brand-logo">quiX<span class="x">zoom</span></div>
<div class="tagline">The World's Camera Network</div>
<nav class="footer-lang" aria-label="Välj språk">
<a href="https://quixzoom.se/" hreflang="sv" class="active" aria-label="Svenska">🇸🇪 SV</a>
<a href="https://quixzoom.de/" hreflang="de" aria-label="Deutsch">🇩🇪 DE</a>
<a href="https://quixzoom.fr/" hreflang="fr" aria-label="Français">🇫🇷 FR</a>
<a href="https://quixzoom.nl/" hreflang="nl" aria-label="Nederlands">🇳🇱 NL</a>
<a href="https://quixzoom.co.uk/" hreflang="en-gb" aria-label="English">🇬🇧 EN</a>
<a href="https://quixzoom.it/" hreflang="it" aria-label="Italiano">🇮🇹 IT</a>
<a href="https://quixzoom.es/" hreflang="es" aria-label="Español">🇪🇸 ES</a>
<a href="https://quixzoom.se/" hreflang="sv" class="active" aria-label="Svenska"><span class="flag-se">SE</span> SV</a>
<a href="https://quixzoom.de/" hreflang="de" aria-label="Deutsch"> DE</a>
<a href="https://quixzoom.fr/" hreflang="fr" aria-label="Français"> FR</a>
<a href="https://quixzoom.nl/" hreflang="nl" aria-label="Nederlands"> NL</a>
<a href="https://quixzoom.co.uk/" hreflang="en-gb" aria-label="English"> EN</a>
<a href="https://quixzoom.it/" hreflang="it" aria-label="Italiano"> IT</a>
<a href="https://quixzoom.es/" hreflang="es" aria-label="Español"> ES</a>
</nav>
</div>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Infrastrukturövervakning</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Försäkringsärende</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Hyresaudit</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
+750
View File
@@ -0,0 +1,750 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Logga in — quiXzoom Zoomer</title>
<meta name="description" content="Logga in på ditt Zoomer-konto. Använd appen för snabb inloggning.">
<link rel="canonical" href="https://quixzoom.se/login/">
<meta name="theme-color" content="#0a0a0a">
<style>
:root{
--qx-black:#0a0a0a;
--qx-white:#FFFFFF;
--qx-blue:#0066FF;
--qx-blue-light:#E8F0FF;
--qx-green:#00C853;
--qx-red:#dc2626;
--qx-gray:#F5F5F7;
--qx-gray-dark:#86868b;
--qx-radius:12px;
--font:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Arial,sans-serif;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{-webkit-text-size-adjust:100%}
body{
font-family:var(--font);
background:var(--qx-black);
color:var(--qx-white);
line-height:1.6;
min-height:100vh;
display:flex;
flex-direction:column;
align-items:center;
justify-content:center;
padding:24px;
}
.login-container{
width:100%;
max-width:420px;
}
.logo{
text-align:center;
margin-bottom:40px;
}
.logo h1{
font-size:2rem;
font-weight:800;
letter-spacing:-.03em;
}
.logo h1 .x{color:var(--qx-blue)}
.logo p{
color:rgba(255,255,255,.5);
font-size:.9375rem;
margin-top:8px;
}
.card{
background:rgba(255,255,255,.05);
border:1px solid rgba(255,255,255,.1);
border-radius:var(--qx-radius);
padding:32px;
}
/* Passwordless Section */
.passwordless-section{
text-align:center;
margin-bottom:32px;
}
.passwordless-title{
font-size:1.125rem;
font-weight:700;
margin-bottom:8px;
}
.passwordless-desc{
color:rgba(255,255,255,.5);
font-size:.875rem;
margin-bottom:20px;
}
.qr-container{
background:var(--qx-white);
border-radius:var(--qx-radius);
padding:20px;
margin:0 auto 16px;
width:200px;
height:200px;
display:flex;
align-items:center;
justify-content:center;
}
.qr-container svg,
.qr-container img{
width:160px;
height:160px;
}
.qr-loading{
color:var(--qx-black);
font-size:.875rem;
}
.qr-error{
color:var(--qx-red);
font-size:.875rem;
}
.status-text{
font-size:.875rem;
color:rgba(255,255,255,.6);
margin-top:12px;
}
.status-text.approved{
color:var(--qx-green);
}
.status-text.rejected{
color:var(--qx-red);
}
/* App buttons */
.app-buttons{
display:flex;
gap:12px;
justify-content:center;
margin-top:16px;
}
.app-btn{
display:inline-flex;
align-items:center;
gap:8px;
padding:12px 20px;
background:var(--qx-blue);
color:var(--qx-white);
border-radius:10px;
text-decoration:none;
font-size:.9375rem;
font-weight:600;
transition:opacity .15s;
}
.app-btn:hover{opacity:.9}
.app-btn.secondary{
background:rgba(255,255,255,.1);
}
/* Divider */
.divider{
display:flex;
align-items:center;
gap:16px;
margin:24px 0;
color:rgba(255,255,255,.4);
font-size:.875rem;
}
.divider::before,
.divider::after{
content:'';
flex:1;
height:1px;
background:rgba(255,255,255,.1);
}
/* Traditional login */
.tabs{
display:flex;
gap:8px;
margin-bottom:24px;
}
.tab{
flex:1;
padding:12px;
background:transparent;
border:1px solid rgba(255,255,255,.15);
border-radius:8px;
color:rgba(255,255,255,.6);
font-size:.9375rem;
font-weight:600;
cursor:pointer;
transition:all .15s;
font-family:var(--font);
}
.tab.active{
background:var(--qx-blue);
border-color:var(--qx-blue);
color:var(--qx-white);
}
.form-group{
margin-bottom:16px;
}
label{
display:block;
font-size:.8125rem;
font-weight:600;
color:rgba(255,255,255,.7);
margin-bottom:6px;
}
input{
width:100%;
padding:14px 16px;
background:rgba(255,255,255,.08);
border:1px solid rgba(255,255,255,.15);
border-radius:10px;
color:var(--qx-white);
font-size:1rem;
font-family:var(--font);
transition:border-color .15s,box-shadow .15s;
-webkit-appearance:none;
}
input:focus{
outline:none;
border-color:var(--qx-blue);
box-shadow:0 0 0 3px rgba(0,102,255,.2);
}
input::placeholder{color:rgba(255,255,255,.3)}
.btn{
width:100%;
padding:16px;
background:var(--qx-blue);
border:none;
border-radius:10px;
color:var(--qx-white);
font-size:1rem;
font-weight:700;
cursor:pointer;
transition:opacity .15s,transform .15s;
font-family:var(--font);
margin-top:8px;
}
.btn:hover:not(:disabled){opacity:.9;transform:translateY(-1px)}
.btn:disabled{opacity:.5;cursor:not-allowed}
.error{
background:rgba(220,38,38,.15);
border:1px solid rgba(220,38,38,.3);
color:#ff6b6b;
padding:12px 16px;
border-radius:8px;
font-size:.875rem;
margin-bottom:16px;
display:none;
}
.error.show{display:block}
.success{
background:rgba(0,200,83,.15);
border:1px solid rgba(0,200,83,.3);
color:#4ade80;
padding:12px 16px;
border-radius:8px;
font-size:.875rem;
margin-bottom:16px;
display:none;
}
.success.show{display:block}
.forgot{
text-align:center;
margin-top:16px;
}
.forgot a{
color:rgba(255,255,255,.5);
font-size:.875rem;
text-decoration:none;
transition:color .15s;
}
.forgot a:hover{color:var(--qx-blue)}
.back{
text-align:center;
margin-top:24px;
}
.back a{
color:rgba(255,255,255,.5);
font-size:.875rem;
text-decoration:none;
display:inline-flex;
align-items:center;
gap:6px;
transition:color .15s;
}
.back a:hover{color:var(--qx-white)}
.spinner{
display:inline-block;
width:16px;
height:16px;
border:2px solid rgba(255,255,255,.3);
border-top-color:var(--qx-white);
border-radius:50%;
animation:spin .8s linear infinite;
margin-right:8px;
vertical-align:middle;
}
@keyframes spin{to{transform:rotate(360deg)}}
.hidden{display:none !important}
/* Dashboard */
.dashboard{
display:none;
width:100%;
max-width:420px;
}
.dashboard.show{display:block}
.dashboard-header{
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:24px;
}
.dashboard-header h2{font-size:1.25rem;font-weight:700}
.logout-btn{
background:rgba(255,255,255,.1);
border:none;
color:rgba(255,255,255,.7);
padding:8px 16px;
border-radius:8px;
font-size:.875rem;
cursor:pointer;
font-family:var(--font);
}
.logout-btn:hover{background:rgba(255,255,255,.15);color:var(--qx-white)}
.stats-grid{
display:grid;
grid-template-columns:1fr 1fr;
gap:12px;
margin-bottom:24px;
}
.stat-card{
background:rgba(255,255,255,.05);
border:1px solid rgba(255,255,255,.1);
border-radius:var(--qx-radius);
padding:20px;
text-align:center;
}
.stat-value{
font-size:1.75rem;
font-weight:800;
color:var(--qx-blue);
}
.stat-label{
font-size:.75rem;
color:rgba(255,255,255,.5);
margin-top:4px;
text-transform:uppercase;
letter-spacing:.05em;
}
.nav-links{
display:flex;
flex-direction:column;
gap:8px;
}
.nav-links a{
display:flex;
align-items:center;
gap:12px;
padding:16px;
background:rgba(255,255,255,.05);
border:1px solid rgba(255,255,255,.1);
border-radius:var(--qx-radius);
color:var(--qx-white);
text-decoration:none;
font-weight:600;
transition:background .15s;
}
.nav-links a:hover{background:rgba(255,255,255,.1)}
</style>
</head>
<body>
<!-- Login Form -->
<div class="login-container" id="loginContainer">
<div class="logo">
<h1>qui<span class="x">X</span>zoom</h1>
<p>Zoomer Portal</p>
</div>
<div class="card">
<!-- Passwordless Section -->
<div class="passwordless-section" id="passwordlessSection">
<div class="passwordless-title">🔐 Snabb inloggning</div>
<div class="passwordless-desc">Öppna quiXzoom-appen och skanna QR-koden</div>
<div class="qr-container" id="qrContainer">
<div class="qr-loading">Laddar...</div>
</div>
<div class="status-text" id="statusText">Väntar på godkännande...</div>
<div class="app-buttons" id="appButtons">
<a href="#" class="app-btn" id="openAppBtn">📱 Öppna appen</a>
<a href="https://app.quixzoom.com/join" class="app-btn secondary">⬇️ Ladda ner app</a>
</div>
</div>
<div class="divider">eller</div>
<!-- Traditional Login -->
<div class="tabs">
<button class="tab active" data-mode="login">Logga in</button>
<button class="tab" data-mode="signup">Bli zoomer</button>
</div>
<div class="error" id="errorMsg"></div>
<div class="success" id="successMsg"></div>
<form id="authForm">
<div class="form-group" id="nameGroup" style="display:none">
<label for="displayName">Namn (valfritt)</label>
<input type="text" id="displayName" placeholder="Vad ska folk kalla dig?">
</div>
<div class="form-group">
<label for="email">E-post</label>
<input type="email" id="email" placeholder="din@email.se" required>
</div>
<div class="form-group">
<label for="password">Lösenord</label>
<input type="password" id="password" placeholder="Minst 8 tecken" required minlength="8">
</div>
<button type="submit" class="btn" id="submitBtn">Logga in</button>
</form>
<div class="forgot">
<a href="/help/">Glömt lösenordet?</a>
</div>
</div>
<div class="back">
<a href="/">← Tillbaka till quixzoom.se</a>
</div>
</div>
<!-- Dashboard (shown after login) -->
<div class="dashboard" id="dashboard">
<div class="logo" style="margin-bottom:24px">
<h1>qui<span class="x">X</span>zoom</h1>
</div>
<div class="dashboard-header">
<h2 id="welcomeName">Hej!</h2>
<button class="logout-btn" id="logoutBtn">Logga ut</button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="statEarnings">0 kr</div>
<div class="stat-label">Intjänat</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statMissions">0</div>
<div class="stat-label">Uppdrag</div>
</div>
</div>
<nav class="nav-links">
<a href="/app/">📱 Öppna appen</a>
<a href="/earnings/">💰 Intjäning & utbetalning</a>
<a href="/profile/">👤 Profil & inställningar</a>
<a href="/help/">❓ Hjälp & support</a>
</nav>
<div class="back" style="margin-top:24px">
<a href="/">← Till quixzoom.se</a>
</div>
</div>
<script>
const API_BASE = 'https://api.quixzoom.com/v1';
const TOKEN_KEY = '***';
const REFRESH_KEY = '***';
let currentMode = 'login';
let pollingInterval = null;
let currentSessionId = null;
let currentRequestToken = null;
// DOM elements
const loginContainer = document.getElementById('loginContainer');
const dashboard = document.getElementById('dashboard');
const authForm = document.getElementById('authForm');
const tabs = document.querySelectorAll('.tab');
const nameGroup = document.getElementById('nameGroup');
const submitBtn = document.getElementById('submitBtn');
const errorMsg = document.getElementById('errorMsg');
const successMsg = document.getElementById('successMsg');
const qrContainer = document.getElementById('qrContainer');
const statusText = document.getElementById('statusText');
const openAppBtn = document.getElementById('openAppBtn');
// ─── Passwordless Flow ─────────────────────────────────────────────────────
async function initiatePasswordless() {
try {
const res = await fetch(`${API_BASE}/auth/passwordless/initiate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: 'web-dashboard',
redirect_url: window.location.href,
device_info: {
user_agent: navigator.userAgent,
ip: '' // Backend will detect
}
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunde inte initiera inloggning');
}
const data = await res.json();
currentSessionId = data.session_id;
currentRequestToken = data.request_token;
// Generate QR code
generateQR(data.qr_data);
// Set up deep link
openAppBtn.href = data.qr_data;
// Start polling
startPolling(data.session_id, data.request_token);
} catch (err) {
qrContainer.innerHTML = `<div class="qr-error">${err.message}</div>`;
statusText.textContent = 'Kunde inte ladda QR-kod. Försök med e-post/lösenord istället.';
}
}
function generateQR(data) {
// Simple QR code generation using Google Chart API
// For production, use a proper QR library
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(data)}`;
qrContainer.innerHTML = `<img src="${qrUrl}" alt="QR-kod för inloggning" width="160" height="160">`;
}
function startPolling(sessionId, requestToken) {
if (pollingInterval) clearInterval(pollingInterval);
let attempts = 0;
const maxAttempts = 180; // 6 minutes (2 second intervals)
pollingInterval = setInterval(async () => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(pollingInterval);
statusText.textContent = 'Tiden gick ut. Försök igen.';
statusText.className = 'status-text rejected';
return;
}
try {
const res = await fetch(
`${API_BASE}/auth/passwordless/status?session_id=${sessionId}&request_token=${requestToken}`
);
if (!res.ok) return;
const data = await res.json();
if (data.status === 'approved' || data.status === 'completed') {
clearInterval(pollingInterval);
statusText.textContent = '✅ Inloggning godkänd!';
statusText.className = 'status-text approved';
// Store tokens
if (data.access_token) {
localStorage.setItem(TOKEN_KEY, data.access_token);
if (data.refresh_token) {
localStorage.setItem(REFRESH_KEY, data.refresh_token);
}
}
// Show dashboard
setTimeout(() => {
showDashboard({ display_name: 'Zoomer', total_earned: 0, missions_completed: 0 });
}, 1000);
} else if (data.status === 'rejected') {
clearInterval(pollingInterval);
statusText.textContent = '❌ Inloggning nekad';
statusText.className = 'status-text rejected';
} else if (data.status === 'expired') {
clearInterval(pollingInterval);
statusText.textContent = 'Tiden gick ut. Försök igen.';
statusText.className = 'status-text rejected';
}
} catch (e) {
// Ignore polling errors
}
}, 2000);
}
// ─── Traditional Auth ──────────────────────────────────────────────────────
// Check if already logged in
async function checkAuth() {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) {
// Start passwordless flow
initiatePasswordless();
return;
}
try {
const res = await fetch(`${API_BASE}/auth/me`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const user = await res.json();
showDashboard(user);
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
initiatePasswordless();
}
} catch (e) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
initiatePasswordless();
}
}
// Tab switching
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentMode = tab.dataset.mode;
if (currentMode === 'signup') {
nameGroup.style.display = 'block';
submitBtn.textContent = 'Skapa konto';
} else {
nameGroup.style.display = 'none';
submitBtn.textContent = 'Logga in';
}
hideMessages();
});
});
// Form submit
authForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideMessages();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const displayName = document.getElementById('displayName').value.trim();
if (!email || !password) {
showError('Fyll i e-post och lösenord');
return;
}
if (currentMode === 'signup' && password.length < 8) {
showError('Lösenordet måste vara minst 8 tecken');
return;
}
setLoading(true);
try {
const endpoint = currentMode === 'login' ? '/auth/login' : '/auth/register';
const body = currentMode === 'login'
? { email, password }
: { email, password, display_name: displayName || undefined };
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || data.error || 'Inloggning misslyckades');
}
localStorage.setItem(TOKEN_KEY, data.access_token);
if (data.refresh_token) {
localStorage.setItem(REFRESH_KEY, data.refresh_token);
}
showSuccess(currentMode === 'login' ? 'Välkommen tillbaka!' : 'Konto skapat!');
showDashboard(data.zoomer);
} catch (err) {
showError(err.message || 'Något gick fel. Försök igen.');
} finally {
setLoading(false);
}
});
// Logout
document.getElementById('logoutBtn').addEventListener('click', async () => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
try {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
} catch (e) {
// best-effort
}
}
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
if (pollingInterval) clearInterval(pollingInterval);
location.reload();
});
function showDashboard(user) {
loginContainer.style.display = 'none';
dashboard.classList.add('show');
document.getElementById('welcomeName').textContent = `Hej ${user.display_name || user.email || 'Zoomer'}!`;
const earned = (user.total_earned || 0) / 100;
document.getElementById('statEarnings').textContent = earned > 0
? `${earned.toLocaleString('sv-SE')} kr`
: '0 kr';
document.getElementById('statMissions').textContent = user.missions_completed || 0;
}
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.classList.add('show');
}
function showSuccess(msg) {
successMsg.textContent = msg;
successMsg.classList.add('show');
}
function hideMessages() {
errorMsg.classList.remove('show');
successMsg.classList.remove('show');
}
function setLoading(loading) {
submitBtn.disabled = loading;
if (loading) {
submitBtn.innerHTML = '<span class="spinner"></span>Vänta...';
} else {
submitBtn.textContent = currentMode === 'login' ? 'Logga in' : 'Skapa konto';
}
}
// Init
checkAuth();
</script>
</body>
</html>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>OOH-audit</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Parkeringsaudit</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Köpesbesiktning</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Fastighetsdokumentation</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Hyresinspektion</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Butiksaudit</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Vägcondition</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Takinspektion</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Skyltaudit</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>
@@ -29,7 +29,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Ne
</head>
<body>
<div class="hero">
<div class="badge">🇸🇪 Lanseras i Sverige · 1 augusti 2026</div>
<div class="badge"><span class="flag-se">SE</span> Lanseras i Sverige · 1 augusti 2026</div>
<h1>Stormskadebedömning</h1>
<p>quiXzoom — Crowdsourcad datainsamling i Sverige. Tjäna 150500 kr per uppdrag.</p>
<a href="https://app.quixzoom.com/join" class="cta">Bli Zoomer nu</a>