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
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>quiXzoom Academy — Certifiering</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root{--ink:#0A0A0A;--text-secondary:#6E6E73;--surface:#F5F5F7;--card:#FFFFFF;--blue:#0066FF;--green:#00C853}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--surface);color:var(--ink);line-height:1.6}
.container{max-width:800px;margin:0 auto;padding:24px}
.nav{display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background:rgba(255,255,255,0.9);backdrop-filter:blur(20px);position:sticky;top:0;z-index:100;border-bottom:1px solid rgba(0,0,0,0.06)}
.nav-logo{font-size:18px;font-weight:800;color:var(--ink);text-decoration:none;letter-spacing:-0.3px}
.nav-logo span{color:var(--blue)}
.hero{text-align:center;padding:60px 24px 40px}
.hero h1{font-size:42px;font-weight:800;margin-bottom:16px;letter-spacing:-0.5px}
.hero p{font-size:18px;color:var(--text-secondary);max-width:500px;margin:0 auto}
.cert-card{background:var(--card);border-radius:20px;padding:40px;margin:24px 0;box-shadow:0 4px 24px rgba(0,0,0,0.08);text-align:center}
.cert-icon{font-size:80px;margin-bottom:24px}
.cert-title{font-size:28px;font-weight:800;margin-bottom:12px}
.cert-status{font-size:18px;color:var(--text-secondary);margin-bottom:32px}
.progress-ring{position:relative;width:200px;height:200px;margin:0 auto 32px}
.progress-ring svg{transform:rotate(-90deg)}
.progress-ring-circle{fill:none;stroke:#E5E5EA;stroke-width:12}
.progress-ring-progress{fill:none;stroke:var(--blue);stroke-width:12;stroke-linecap:round;transition:stroke-dashoffset 1s ease}
.progress-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:48px;font-weight:800;color:var(--ink)}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:16px;margin:32px 0}
.stat-card{background:var(--surface);border-radius:16px;padding:24px;text-align:center}
.stat-value{font-size:36px;font-weight:800;color:var(--blue)}
.stat-label{font-size:14px;color:var(--text-secondary);margin-top:8px}
.modules-list{display:grid;gap:16px;margin:32px 0}
.module-item{background:var(--card);border-radius:16px;padding:24px;display:flex;align-items:center;gap:16px;box-shadow:0 2px 12px rgba(0,0,0,0.06)}
.module-status{width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:24px;flex-shrink:0}
.module-status.complete{background:var(--green);color:white}
.module-status.incomplete{background:#E5E5EA;color:var(--text-secondary)}
.module-info{flex:1}
.module-name{font-size:18px;font-weight:700}
.module-progress{font-size:14px;color:var(--text-secondary);margin-top:4px}
.btn{display:inline-flex;align-items:center;gap:8px;background:var(--blue);color:white;padding:16px 32px;border-radius:12px;text-decoration:none;font-weight:700;font-size:16px;border:none;cursor:pointer;transition:all 0.2s}
.btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,102,255,0.3)}
.btn:disabled{background:#E5E5EA;color:var(--text-secondary);cursor:not-allowed;transform:none;box-shadow:none}
.certificate{background:linear-gradient(135deg,#1a1a2e 0%,#16213e 50%,#0f3460 100%);color:white;border-radius:20px;padding:48px;margin:32px 0;text-align:center;position:relative;overflow:hidden}
.certificate::before{content:'';position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(circle,rgba(255,255,255,0.1) 1px,transparent 1px);background-size:20px 20px;opacity:0.3}
.cert-badge{font-size:64px;margin-bottom:16px}
.cert-name{font-size:32px;font-weight:800;margin-bottom:8px}
.cert-date{font-size:16px;opacity:0.8}
.cert-id{font-size:14px;opacity:0.6;margin-top:16px;font-family:monospace}
.footer{text-align:center;padding:40px 24px;color:var(--text-secondary);font-size:14px;border-top:1px solid rgba(0,0,0,0.06)}
@media(max-width:640px){.hero h1{font-size:32px}.hero{padding:40px 24px 30px}.cert-card{padding:24px}}
</style>
</head>
<body>
<nav class="nav">
<a href="/" class="nav-logo">qui<span style="color:var(--blue);font-weight:900">X</span>zoom</a>
<a href="/academy/" style="color:var(--blue);text-decoration:none;font-weight:600;font-size:14px">← Academy</a>
</nav>
<div class="hero">
<h1>Din Certifiering</h1>
<p>Tracka din framsteg och bli en certifierad Zoomer</p>
</div>
<div class="container">
<div class="cert-card">
<div id="certContent">
<!-- Fylls i av JavaScript -->
</div>
</div>
<div class="modules-list" id="modulesList">
<!-- Fylls i av JavaScript -->
</div>
</div>
<div class="footer">
<p>quiXzoom Academy — Learn. Earn. Grow.</p>
</div>
<script src="/academy/academy-auth-integration.js"></script>
<script>
// Initiera auth
const auth = new AcademyAuth();
// Hämta progress från backend om inloggad
auth.fetchProgress().then(() => {
renderPage();
});
const modules = [
{ id: 'm1', name: 'Komma Igång', lessons: ['m1l1','m1l2','m1l3','m1l4'] },
{ id: 'm2', name: 'Fotokvalitet', lessons: ['m2l1','m2l2','m2l3','m2l4'] },
{ id: 'm3', name: 'Uppdragstyper', lessons: ['m3l1','m3l2','m3l3'] },
{ id: 'm4', name: 'Avancerade Tekniker', lessons: ['m4l1','m4l2','m4l3'] },
{ id: 'm5', name: 'Tjäna Mer', lessons: ['m5l1','m5l2','m5l3'] },
{ id: 'm6', name: 'Pro Zoomer', lessons: ['m6l1','m6l2','m6l3','m6l4'] }
];
const completedLessons = JSON.parse(localStorage.getItem('academyProgress') || '[]');
const totalLessons = modules.reduce((sum,m) => sum + m.lessons.length, 0);
const completedCount = completedLessons.length;
const progressPercent = Math.round((completedCount / totalLessons) * 100);
const isCertified = progressPercent >= 100;
// Rendera certifierings-status
const certContent = document.getElementById('certContent');
if (isCertified) {
const certId = 'QZ-' + Date.now().toString(36).toUpperCase();
certContent.innerHTML = `
<div class="certificate">
<div class="cert-badge">🎓</div>
<div class="cert-name">Certifierad Zoomer</div>
<div class="cert-date">Utfärdad ${new Date().toLocaleDateString('sv-SE')}</div>
<div class="cert-id">ID: ${certId}</div>
</div>
<p style="margin-top:24px;color:var(--text-secondary)">Grattis! Du har slutfört alla lektioner och är nu en certifierad Zoomer.</p>
<button class="btn" onclick="shareCertificate()">📤 Dela certifikat</button>
`;
} else {
const circumference = 2 * Math.PI * 90;
const offset = circumference - (progressPercent / 100) * circumference;
certContent.innerHTML = `
<div class="progress-ring">
<svg width="200" height="200">
<circle class="progress-ring-circle" cx="100" cy="100" r="90"/>
<circle class="progress-ring-progress" cx="100" cy="100" r="90"
stroke-dasharray="${circumference}"
stroke-dashoffset="${offset}"/>
</svg>
<div class="progress-text">${progressPercent}%</div>
</div>
<div class="cert-title">På väg mot certifiering</div>
<div class="cert-status">${completedCount} av ${totalLessons} lektioner slutförda</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${completedCount}</div>
<div class="stat-label">Lektioner klara</div>
</div>
<div class="stat-card">
<div class="stat-value">${totalLessons - completedCount}</div>
<div class="stat-label">Kvar att göra</div>
</div>
<div class="stat-card">
<div class="stat-value">${modules.filter(m => m.lessons.every(l => completedLessons.includes(l))).length}</div>
<div class="stat-label">Moduler klara</div>
</div>
</div>
<a href="/academy/" class="btn">Fortsätt lära dig →</a>
`;
}
// Rendera modul-lista
const modulesList = document.getElementById('modulesList');
modules.forEach(module => {
const moduleCompleted = module.lessons.filter(l => completedLessons.includes(l)).length;
const isComplete = moduleCompleted === module.lessons.length;
const moduleEl = document.createElement('div');
moduleEl.className = 'module-item';
moduleEl.innerHTML = `
<div class="module-status ${isComplete ? 'complete' : 'incomplete'}">
${isComplete ? '✅' : '📚'}
</div>
<div class="module-info">
<div class="module-name">${module.name}</div>
<div class="module-progress">${moduleCompleted} av ${module.lessons.length} lektioner</div>
</div>
${isComplete ? '<span style="color:var(--green);font-weight:700">Klar!</span>' : `<a href="/academy/${module.id}/${module.lessons[moduleCompleted]}/" class="btn" style="padding:8px 16px;font-size:14px">Fortsätt</a>`}
`;
modulesList.appendChild(moduleEl);
});
function shareCertificate() {
if (navigator.share) {
navigator.share({
title: 'Jag är certifierad Zoomer!',
text: 'Jag har slutfört quiXzoom Academy och är nu en certifierad Zoomer.',
url: window.location.href
});
} else {
alert('Certifikat kopierat till urklipp!');
}
}
</script>
</body>
</html>