Files
boc/quixzoom-market-pages/se/login/index.html
T
Bernt 623561d30a feat: Passwordless auth — backend deployad, frontend uppdaterad
- Backend: Docker-container på port 8082
- Redis: Intern container (ingen exposed port)
- Nginx-config: nginx-passwordless.conf (väntar på deploy)
- Webb: API_BASE uppdaterad till api.quixzoom.com
- Fix: datetime timezone-aware i approve-endpoint
- Test: End-to-end flöde verifierat
2026-07-07 09:05:21 +00:00

751 lines
18 KiB
HTML

<!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';
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>