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
+580
View File
@@ -0,0 +1,580 @@
<!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>{{LESSON_TITLE}} — quiXzoom Academy</title>
<meta name="description" content="{{LESSON_DESCRIPTION}}">
<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;
--red: #FF3B30;
--orange: #FF9500;
--landvex-dark: #0D1F3C;
--landvex-accent: #00D4AA;
--landvex-border: #1A3A5C;
}
* { 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;
-webkit-font-smoothing: antialiased;
}
/* Navigation */
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(255,255,255,0.95);
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);
font-weight: 900;
}
.nav-progress {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.progress-bar {
width: 100px;
height: 4px;
background: rgba(0,0,0,0.08);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--blue);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Hero */
.hero {
background: var(--landvex-dark);
color: white;
padding: 60px 24px 40px;
text-align: center;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(0,212,170,0.15);
color: var(--landvex-accent);
padding: 8px 16px;
border-radius: 100px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 20px;
border: 1px solid rgba(0,212,170,0.3);
}
.hero h1 {
font-size: 36px;
font-weight: 800;
margin-bottom: 16px;
letter-spacing: -0.5px;
line-height: 1.2;
}
.hero p {
font-size: 18px;
color: rgba(255,255,255,0.7);
max-width: 500px;
margin: 0 auto;
}
/* Content */
.container {
max-width: 720px;
margin: 0 auto;
padding: 40px 24px;
}
.lesson-content {
background: var(--card);
border-radius: 20px;
padding: 40px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.lesson-content h2 {
font-size: 24px;
font-weight: 700;
margin: 32px 0 16px;
letter-spacing: -0.3px;
}
.lesson-content h2:first-child {
margin-top: 0;
}
.lesson-content p {
font-size: 16px;
color: var(--text-secondary);
margin-bottom: 16px;
line-height: 1.7;
}
.lesson-content ul {
margin: 16px 0;
padding-left: 24px;
}
.lesson-content li {
margin-bottom: 12px;
color: var(--text-secondary);
}
.lesson-content strong {
color: var(--ink);
font-weight: 600;
}
/* Tip Box */
.tip-box {
background: rgba(0,102,255,0.06);
border-left: 4px solid var(--blue);
border-radius: 0 12px 12px 0;
padding: 20px 24px;
margin: 24px 0;
}
.tip-box-label {
font-size: 12px;
font-weight: 700;
color: var(--blue);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.tip-box p {
margin: 0;
color: var(--ink);
}
/* Warning Box */
.warning-box {
background: rgba(255,149,0,0.06);
border-left: 4px solid var(--orange);
border-radius: 0 12px 12px 0;
padding: 20px 24px;
margin: 24px 0;
}
.warning-box-label {
font-size: 12px;
font-weight: 700;
color: var(--orange);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
/* Process Flow Image */
.process-image {
width: 100%;
border-radius: 16px;
margin: 24px 0;
}
/* Quiz */
.quiz-container {
background: var(--surface);
border-radius: 20px;
padding: 32px;
margin: 32px 0;
}
.quiz-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.quiz-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--landvex-dark);
display: flex;
align-items: center;
justify-content: center;
color: var(--landvex-accent);
font-size: 20px;
}
.quiz-title {
font-size: 18px;
font-weight: 700;
}
.quiz-question {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
line-height: 1.5;
}
.quiz-options {
list-style: none;
}
.quiz-option {
background: white;
padding: 16px 20px;
margin-bottom: 12px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
}
.quiz-option:hover {
border-color: var(--blue);
transform: translateX(4px);
}
.quiz-option.correct {
background: rgba(0,200,83,0.1);
border-color: var(--green);
color: var(--green);
}
.quiz-option.wrong {
background: rgba(255,59,48,0.1);
border-color: var(--red);
color: var(--red);
}
.quiz-option.disabled {
pointer-events: none;
opacity: 0.6;
}
.quiz-feedback {
display: none;
padding: 16px 20px;
border-radius: 12px;
margin-top: 16px;
font-size: 15px;
font-weight: 500;
}
.quiz-feedback.show {
display: block;
}
.quiz-feedback.correct {
background: rgba(0,200,83,0.1);
color: var(--green);
border: 1px solid rgba(0,200,83,0.2);
}
.quiz-feedback.wrong {
background: rgba(255,59,48,0.1);
color: var(--red);
border: 1px solid rgba(255,59,48,0.2);
}
/* Complete Button */
.complete-section {
text-align: center;
margin-top: 40px;
padding-top: 40px;
border-top: 1px solid rgba(0,0,0,0.08);
}
.complete-btn {
display: inline-flex;
align-items: center;
gap: 12px;
background: var(--blue);
color: white;
padding: 18px 40px;
border-radius: 16px;
font-size: 18px;
font-weight: 700;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.complete-btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(0,102,255,0.3);
}
.complete-btn:disabled {
background: var(--green);
cursor: default;
transform: none;
box-shadow: none;
}
.complete-btn.completed {
background: var(--green);
}
/* Navigation Footer */
.lesson-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
padding-top: 32px;
border-top: 1px solid rgba(0,0,0,0.08);
}
.nav-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
text-decoration: none;
font-weight: 600;
font-size: 15px;
transition: all 0.2s;
}
.nav-btn-prev {
color: var(--text-secondary);
background: var(--surface);
}
.nav-btn-prev:hover {
background: rgba(0,0,0,0.08);
}
.nav-btn-next {
color: white;
background: var(--blue);
}
.nav-btn-next:hover {
transform: translateX(4px);
box-shadow: 0 8px 24px rgba(0,102,255,0.25);
}
/* Footer */
.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: 28px; }
.hero { padding: 40px 24px 30px; }
.lesson-content { padding: 24px; }
.quiz-container { padding: 24px; }
.nav-progress { display: none; }
}
</style>
</head>
<body data-lesson-id="{{LESSON_ID}}">
<nav class="nav">
<a href="/" class="nav-logo">qui<span>X</span>zoom</a>
<div class="nav-progress">
<span>Modul {{MODULE_NUM}}</span>
<div class="progress-bar">
<div class="progress-fill" style="width: {{PROGRESS_PERCENT}}%"></div>
</div>
<span>{{PROGRESS_PERCENT}}%</span>
</div>
<a href="/academy/" style="color: var(--text-secondary); text-decoration: none; font-weight: 600; font-size: 14px;">Academy</a>
</nav>
<div class="hero">
<div class="hero-badge">
<span>Modul {{MODULE_NUM}}</span>
<span>·</span>
<span>Lektion {{LESSON_NUM}}</span>
</div>
<h1>{{LESSON_TITLE}}</h1>
<p>{{LESSON_DESCRIPTION}}</p>
</div>
<div class="container">
<div class="lesson-content">
{{LESSON_CONTENT}}
<!-- Quiz -->
<div class="quiz-container">
<div class="quiz-header">
<div class="quiz-icon">?</div>
<div class="quiz-title">Kunskapskontroll</div>
</div>
<div class="quiz-question">{{QUIZ_QUESTION}}</div>
<ul class="quiz-options">
<li class="quiz-option" onclick="checkAnswer(this, false)">
<span>A.</span> {{OPTION_A}}
</li>
<li class="quiz-option" onclick="checkAnswer(this, true)">
<span>B.</span> {{OPTION_B}}
</li>
<li class="quiz-option" onclick="checkAnswer(this, false)">
<span>C.</span> {{OPTION_C}}
</li>
</ul>
<div id="quizFeedback" class="quiz-feedback"></div>
</div>
<!-- Complete Section -->
<div class="complete-section">
<button class="complete-btn" id="completeBtn" onclick="completeLesson()">
<span></span>
<span>Markera som slutförd</span>
</button>
</div>
<!-- Lesson Navigation -->
<div class="lesson-nav">
<a href="{{PREV_LESSON_URL}}" class="nav-btn nav-btn-prev">← Föregående</a>
<a href="{{NEXT_LESSON_URL}}" class="nav-btn nav-btn-next">Nästa →</a>
</div>
</div>
</div>
<div class="footer">
<p>quiXzoom Academy — Learn. Earn. Grow.</p>
</div>
<script src="/academy/academy-auth-integration.js"></script>
<script>
const lessonId = document.body.dataset.lessonId;
let completedLessons = JSON.parse(localStorage.getItem('academyProgress') || '[]');
function checkAnswer(element, isCorrect) {
const options = document.querySelectorAll('.quiz-option');
const feedback = document.getElementById('quizFeedback');
options.forEach(opt => {
opt.classList.add('disabled');
opt.onclick = null;
});
if (isCorrect) {
element.classList.add('correct');
feedback.className = 'quiz-feedback show correct';
feedback.innerHTML = '<strong>✓ Rätt!</strong> Bra jobbat. Du kan nu markera lektionen som slutförd.';
} else {
element.classList.add('wrong');
feedback.className = 'quiz-feedback show wrong';
feedback.innerHTML = '<strong>✗ Inte riktigt.</strong> Läs igenom materialet och försök igen.';
// Add retry button
const retryBtn = document.createElement('button');
retryBtn.className = 'nav-btn';
retryBtn.style.marginTop = '12px';
retryBtn.innerHTML = '↻ Försök igen';
retryBtn.onclick = () => location.reload();
feedback.appendChild(document.createElement('br'));
feedback.appendChild(retryBtn);
}
feedback.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function completeLesson() {
if (!completedLessons.includes(lessonId)) {
completedLessons.push(lessonId);
localStorage.setItem('academyProgress', JSON.stringify(completedLessons));
// Sync to backend if logged in
if (window.academyAuth) {
academyAuth.saveProgress(lessonId);
}
const btn = document.getElementById('completeBtn');
btn.innerHTML = '<span>✓</span><span>Lektion slutförd!</span>';
btn.classList.add('completed');
btn.disabled = true;
// Confetti
showConfetti();
}
}
function showConfetti() {
const colors = ['#0066FF', '#00D4AA', '#00C853'];
for (let i = 0; i < 50; i++) {
const confetti = document.createElement('div');
confetti.style.cssText = `
position: fixed;
left: ${Math.random() * 100}vw;
top: -10px;
width: 8px;
height: 8px;
background: ${colors[Math.floor(Math.random() * colors.length)]};
border-radius: 50%;
z-index: 9999;
pointer-events: none;
`;
document.body.appendChild(confetti);
confetti.animate([
{ transform: 'translateY(0) rotate(0deg)', opacity: 1 },
{ transform: `translateY(${window.innerHeight}px) rotate(${Math.random() * 360}deg)`, opacity: 0 }
], {
duration: 2000 + Math.random() * 2000,
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}).onfinish = () => confetti.remove();
}
}
// Check if already completed
if (completedLessons.includes(lessonId)) {
const btn = document.getElementById('completeBtn');
btn.innerHTML = '<span>✓</span><span>Lektion slutförd!</span>';
btn.classList.add('completed');
btn.disabled = true;
}
</script>
</body>
</html>