docs: add quixzoom-auth-core product to AAMOS

- Product documentation in docs/products/
- Updated MEMORY.md with product info
- quiXzoom Auth Core as AAMOS Identity product
This commit is contained in:
Bernt
2026-07-14 09:58:53 +00:00
parent 58ca4e68db
commit 48ea61cdcc
2730 changed files with 293827 additions and 7213 deletions
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Generate JWT keys if not present
RUN python -c "from cryptography.hazmat.primitives import serialization; from cryptography.hazmat.primitives.asymmetric import rsa; from cryptography.hazmat.backends import default_backend; import os; \
os.makedirs('/app/keys', exist_ok=True); \
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()); \
open('/app/keys/private.pem', 'wb').write(private_key.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption())); \
open('/app/keys/public.pem', 'wb').write(private_key.public_key().public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo)); \
print('JWT keys generated')"
ENV PORT=8080
ENV HOST=0.0.0.0
ENV JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
ENV JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
ENV REDIS_HOST=localhost
ENV REDIS_PORT=6379
EXPOSE 8080
CMD ["python", "main.py"]
+118
View File
@@ -0,0 +1,118 @@
# quiXzoom SSO Authentication Service
Microsoft-like single sign-on för alla quixzoom-egendomar.
## Arkitektur
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ quixzoom.com │ │ app.quixzoom │ │ quixzoom.se │
│ (landing) │ │ (app/web) │ │ (marknad) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌─────────────▼─────────────┐
│ auth.quixzoom.com │
│ (SSO / JWT / Cookies) │
└─────────────┬─────────────┘
┌─────────────▼─────────────┐
│ Redis (sessions) │
└───────────────────────────┘
```
## Flöde
### Inloggad i appen → Automatiskt inloggad på webb
1. Användare loggar in i quiXzoom-appen
2. Appen får JWT access token + refresh token
3. Cookies sätts på `.quixzoom.com` (shared across subdomains)
4. Användare besöker `www.quixzoom.com/mina-sidor`
5. Webbsidan kollar `qz_access_token` cookie
6. Token är giltig → användare är inloggad utan att göra något
### Cross-domain cookie-sharing
```
Cookie: qz_access_token=xxx
Domain: .quixzoom.com
Path: /
Secure: true
SameSite: lax
HttpOnly: true
```
Detta gör att cookien skickas med till:
- `www.quixzoom.com`
- `app.quixzoom.com`
- `quixzoom.se`
- `quixzoom.de`
- etc.
## Endpoints
| Endpoint | Beskrivning |
|----------|-------------|
| `POST /auth/login` | E-post + lösenord, sätter cookies |
| `POST /auth/refresh` | Förnya access token |
| `POST /auth/logout` | Logga ut, rensa cookies |
| `GET /auth/me` | Hämta inloggad användare |
| `GET /auth/check` | Snabb auth-check (200/401) |
| `GET /auth/.well-known/jwks.json` | Publik nyckel för verifiering |
## Användning
### Webb (JavaScript)
```javascript
import { quixzoomAuth } from './sso-client.js';
// Kolla om inloggad
const user = await quixzoomAuth.getUser();
if (user) {
console.log('Inloggad som:', user.email);
}
// Logga in
await quixzoomAuth.login('user@example.com', 'password');
// Logga ut
await quixzoomAuth.logout();
```
### React Native / App
```javascript
// Samma client, men skickar tokens i Authorization-header
const response = await fetch('https://api.quixzoom.com/missions', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
```
## Driftsättning
```bash
docker-compose up -d
```
## Miljövariabler
| Variabel | Default | Beskrivning |
|----------|---------|-------------|
| `PORT` | 8080 | Server port |
| `REDIS_HOST` | localhost | Redis server |
| `JWT_PRIVATE_KEY_PATH` | - | Sökväg till privat nyckel |
| `JWT_PUBLIC_KEY_PATH` | - | Sökväg till publik nyckel |
## Säkerhet
- RS256-signerade JWT-tokens
- HttpOnly cookies (skyddade mot XSS)
- Secure flag (endast HTTPS)
- SameSite=lax (CSRF-skydd)
- Refresh token-rotation
- Token-blacklisting vid utloggning
+142
View File
@@ -0,0 +1,142 @@
<!--
quiXzoom Auth Header Snippet
Add this to the <head> of every quixzoom page
Features:
- Auto-detects if user is logged in
- Shows login button or user menu
- Works across all quixzoom domains
-->
<!-- Auth check script -->
<script type="module">
import { quixzoomAuth } from 'https://auth.quixzoom.com/sso-client.js';
// Initialize auth on page load
const user = await quixzoomAuth.init();
// Update all auth-aware elements
document.querySelectorAll('[data-auth-state]').forEach(el => {
const state = el.dataset.authState;
const isLoggedIn = user !== null;
if (state === 'authenticated') {
el.style.display = isLoggedIn ? '' : 'none';
} else if (state === 'anonymous') {
el.style.display = isLoggedIn ? 'none' : '';
}
});
// Update user info
if (user) {
document.querySelectorAll('[data-auth-user-name]').forEach(el => {
el.textContent = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email;
});
document.querySelectorAll('[data-auth-user-avatar]').forEach(el => {
const initial = (user.first_name?.[0] || user.email?.[0] || 'Z').toUpperCase();
el.textContent = initial;
});
}
// Update login links with redirect
document.querySelectorAll('a[href*="auth.quixzoom.com/login"]').forEach(link => {
const url = new URL(link.href);
if (!url.searchParams.has('redirect')) {
url.searchParams.set('redirect', window.location.href);
link.href = url.toString();
}
});
</script>
<!-- Auth UI Components -->
<!-- Anonymous state (not logged in) -->
<div data-auth-state="anonymous" style="display:none">
<a href="https://auth.quixzoom.com/login" class="auth-btn login-btn">
Logga in
</a>
<a href="https://auth.quixzoom.com/register" class="auth-btn register-btn">
Registrera dig
</a>
</div>
<!-- Authenticated state (logged in) -->
<div data-auth-state="authenticated" style="display:none">
<div class="user-menu">
<a href="/mina-sidor" class="user-link">
<span class="user-avatar" data-auth-user-avatar>Z</span>
<span class="user-name" data-auth-user-name>Zoomer</span>
</a>
<button onclick="quixzoomAuth.logout().then(() => window.location.reload())" class="logout-btn">
Logga ut
</button>
</div>
</div>
<style>
.auth-btn {
padding: 8px 16px;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: .2s;
}
.login-btn {
color: #0066FF;
border: 1px solid rgba(0,102,255,.2);
}
.login-btn:hover {
background: rgba(0,102,255,.06);
}
.register-btn {
background: #0066FF;
color: #fff;
margin-left: 8px;
}
.register-btn:hover {
background: #0052CC;
}
.user-menu {
display: flex;
align-items: center;
gap: 12px;
}
.user-link {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #0066FF;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 13px;
}
.user-name {
font-size: 14px;
font-weight: 500;
color: #1a1a1a;
}
.logout-btn {
padding: 6px 12px;
border: 1px solid rgba(0,0,0,.1);
border-radius: 6px;
background: transparent;
color: #6B6B6B;
font-size: 13px;
cursor: pointer;
}
.logout-btn:hover {
border-color: #ff4444;
color: #ff4444;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Deploy Mina Sidor auth bar to all quiXzoom sites
SITES=(
"/home/bernt/.openclaw/workspace/quixzoom-landing-fixed"
"/home/bernt/.openclaw/workspace/quixzoom-asia-pages"
"/home/bernt/.openclaw/workspace/quixzoom-market-pages"
"/home/bernt/.openclaw/workspace/landvex-site/quixzoom"
)
COMPONENT_FILE="/home/bernt/.openclaw/workspace/quixzoom-sso-auth/mina-sidor-component.html"
echo "🚀 Deploying Mina Sidor to all quiXzoom sites..."
for site in "${SITES[@]}"; do
if [ -d "$site" ]; then
echo " 📁 $site"
# Find all HTML files
find "$site" -name "*.html" -type f | while read -r htmlfile; do
# Check if already has qz-auth-bar
if ! grep -q "qz-auth-bar" "$htmlfile" 2>/dev/null; then
# Inject before </body> or </head>
if grep -q "</body>" "$htmlfile"; then
sed -i '/<\/body>/e cat '"$COMPONENT_FILE"'' "$htmlfile"
echo " ✓ Injected: $(basename "$htmlfile")"
fi
fi
done
fi
done
echo "✅ Mina Sidor deployed to all sites!"
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Deploy quiXzoom SSO Auth Service
set -e
echo "🚀 Deploying quiXzoom SSO Auth Service..."
# Build and start services
docker-compose down
docker-compose build
docker-compose up -d
# Wait for health check
echo "⏳ Waiting for service to start..."
sleep 5
# Check health
if curl -sf http://localhost:8080/health > /dev/null; then
echo "✅ SSO Auth Service is running"
else
echo "❌ Health check failed"
exit 1
fi
# Generate keys if needed
if [ ! -f "keys/private.pem" ]; then
echo "🔑 Generating JWT keys..."
mkdir -p keys
openssl genrsa -out keys/private.pem 2048
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
echo "✅ Keys generated"
fi
echo ""
echo "📋 SSO Service deployed!"
echo " URL: https://auth.quixzoom.com"
echo " Health: http://localhost:8080/health"
echo ""
echo "Next steps:"
echo "1. Configure DNS: auth.quixzoom.com → this server"
echo "2. Set up SSL certificates"
echo "3. Update all quixzoom sites to include auth-check.js"
+42
View File
@@ -0,0 +1,42 @@
version: '3.8'
services:
quixzoom-sso-auth:
build: .
container_name: quixzoom-sso-auth
ports:
- "8088:8080"
environment:
- PORT=8080
- HOST=0.0.0.0
- REDIS_HOST=quixzoom-sso-redis
- REDIS_PORT=6379
- REDIS_DB=0
- JWT_PRIVATE_KEY_PATH=/app/keys/quixzoom-private.pem
- JWT_PUBLIC_KEY_PATH=/app/keys/quixzoom-public.pem
volumes:
- ./keys:/app/keys
- ./login.html:/app/static/login.html:ro
- ./mina-sidor.html:/app/static/mina-sidor.html:ro
depends_on:
- quixzoom-sso-redis
networks:
- quixzoom-sso
restart: unless-stopped
quixzoom-sso-redis:
image: redis:7-alpine
container_name: quixzoom-sso-redis
# Internal network only - no port exposed to host
volumes:
- redis-data:/data
networks:
- quixzoom-sso
restart: unless-stopped
networks:
quixzoom-sso:
driver: bridge
volumes:
redis-data:
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6UWlv4PBbM8RX
yBQCrVRasfxkLzb7I15udRXFOkJzeRXihLpN2e/xQn7CzokTchpSrPr5J9DY8TGj
BJp0sSwrK6fXk8URn0jyJKIDZG8c1lShWVdqinUqZNqjpj49izt0uGMl5urSyy0x
aj2z/rdryz13GsGXfLZ07WFv/3i8zVdxXTaz8CBhsXIQS8girOErw/rEiqa0mwj5
ELOf/w+/syQ/uit9wqABSJVJiMiOzvEbVe/MV/IRteaOiAvNleNeQm/4eczvQ6+/
wfNkejG3PHpaRZBvd9eu1JyIgO0beaSCa/ImeFWagAby1cBr7NLxEFGbj1oaMztU
Riu2x8FvAgMBAAECggEABpEU/sD2UmM+MdEsR2CK3xFz3FIVBX/xyuyB18GIaF/Q
UiYBwk8Cl8W+CFQAMnkXOBUUWWYPryhnv/T1vql57YxB2PrCe4RaLVKg39lmgyUu
DBZv2szwzo2Jw90kR3CvUptRv0W8FAbeMRDWBjsy2sU/MCyiExqYUaxbfqI0mVmH
UAoqNFHtLMw9pXn41Id6iiJXdH/wyLemFsFipw9jGiKSfYnYiBVnEvW3hrlF/Gqe
FqO0zDbJht6l8b08CJssj3q8MPWKGCqVK1l+gfOph6l3WHX2RG65HfIgBVyTUCL8
DKIPokoMyTgaZzU8620EAWx9Rg0uL4k3Jr0ktBhMiQKBgQDoQ9VXO1f/kTmY9xal
khDt4lmCz4rXN2/VqP0yJm8ztUY4lSKdXWnPznytJNI3CAu1XPYgHLvRwilTVjOk
dU8p47NHsyfL8t3fhM7lr4HWHRA8igWb4lI2Fe/5hAVgNWKZTMVGUeVYp+al0AlZ
DtqAGqWlMl2RYwX/WSdRkaMChwKBgQDNW5VUi/Tk1IXWW+oWViFdOm4UQ5uuPaIk
nrHf0O3OruWoEONkdj9z2ttxbpqY8xHLDlMEboGJgikpiYL/A/ww5muBolGJP2l9
MFQZ+me1IsWX1ZwHt9+LPwUKOH0G+TsPwlHWetFqDIb8io6evgAB2HxwnQNIZ72b
crFD/6672QKBgQDM8pY86/OBYr8lN9q27MWdcx6y67nCoHtBWGVbLEjxoqI86XPq
0fO9V6HyEkygHKxgM5BG07PzqlVW4PiexJi/CNo4iWCzeTHIuuLqD80Mhwa9tLiw
TatnaEIhtRodQ94mEXT90OQEL9u5MnIdMJsjcN/7fg7MbEltgVjNhCoH3wKBgH2P
nLuoI2FyzC5n3rYvjZDaNAox2FNuHeC0I2AM+Apih8r+IHsBjgSBcaFmliIkpOF5
7aqNAqkYK6DZEn5oal9f06XcoGwBmLdRwGpt39Ex5IzUr+VMOOOD8cnxBgkohEM7
dhxk1fw4kqSA93j6BTkbq+KTLjmsLJOKSfags67pAoGBAN+46k7EiK095unPsLp2
lpykroMRgzIB//Xtv5JMMObfF15ELdySqlma2yo4vLDze+rbxdqlQjFhi+xnA1IX
mrZiZL90Kj2xMvHh8qxfAryQjMNEIMNYnSPPQnms9NFyFweP1MdSMm+n9sOn/fQ+
4pY5qGLNcY9P7SJgu4ivHCd2
-----END PRIVATE KEY-----
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAulFpb+DwWzPEV8gUAq1U
WrH8ZC82+yNebnUVxTpCc3kV4oS6Tdnv8UJ+ws6JE3IaUqz6+SfQ2PExowSadLEs
Kyun15PFEZ9I8iSiA2RvHNZUoVlXaop1KmTao6Y+PYs7dLhjJebq0sstMWo9s/63
a8s9dxrBl3y2dO1hb/94vM1XcV02s/AgYbFyEEvIIqzhK8P6xIqmtJsI+RCzn/8P
v7MkP7orfcKgAUiVSYjIjs7xG1XvzFfyEbXmjogLzZXjXkJv+HnM70Ovv8HzZHox
tzx6WkWQb3fXrtSciIDtG3mkgmvyJnhVmoAG8tXAa+zS8RBRm49aGjM7VEYrtsfB
bwIDAQAB
-----END PUBLIC KEY-----
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logga in — quiXzoom</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root{
--bg:#F7F7F5;
--white:#FFFFFF;
--blue:#0066FF;
--blue-dark:#0052CC;
--green:#00C853;
--text:#1a1a1a;
--text-body:#3a3a3a;
--text-muted:#6B6B6B;
--error:#ff4444;
--radius:16px;
--shadow:0 4px 24px rgba(0,0,0,.08);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Inter','Helvetica Neue',sans-serif;background:var(--bg);color:var(--text-body);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.login-container{width:100%;max-width:420px}
.login-card{background:var(--white);border-radius:var(--radius);padding:40px;box-shadow:var(--shadow)}
.logo{display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:32px;text-decoration:none}
.logo-icon{width:44px;height:44px;background:var(--blue);border-radius:12px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:22px}
.logo-text{font-size:24px;font-weight:700;color:var(--text);letter-spacing:-.5px}
.logo-text span{color:var(--blue)}
h1{font-size:24px;font-weight:700;color:var(--text);text-align:center;margin-bottom:8px}
.subtitle{text-align:center;color:var(--text-muted);font-size:15px;margin-bottom:32px}
.form-group{margin-bottom:20px}
label{display:block;font-size:14px;font-weight:500;color:var(--text);margin-bottom:6px}
input{width:100%;padding:12px 16px;border:1.5px solid rgba(0,0,0,.1);border-radius:10px;font-size:15px;transition:.2s;background:var(--bg)}
input:focus{outline:none;border-color:var(--blue);background:var(--white)}
input::placeholder{color:var(--text-muted)}
.error{color:var(--error);font-size:13px;margin-top:6px;display:none}
.error.visible{display:block}
.login-btn{width:100%;padding:14px;border-radius:10px;background:var(--blue);color:#fff;border:none;font-size:16px;font-weight:600;cursor:pointer;transition:.2s;margin-top:8px}
.login-btn:hover{background:var(--blue-dark)}
.login-btn:disabled{opacity:.6;cursor:not-allowed}
.divider{display:flex;align-items:center;gap:16px;margin:24px 0;color:var(--text-muted);font-size:14px}
.divider::before,.divider::after{content:'';flex:1;height:1px;background:rgba(0,0,0,.1)}
.social-btn{width:100%;padding:12px;border-radius:10px;border:1.5px solid rgba(0,0,0,.1);background:var(--white);font-size:15px;font-weight:500;cursor:pointer;transition:.2s;display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:12px}
.social-btn:hover{background:var(--bg)}
.footer{text-align:center;margin-top:24px;font-size:14px;color:var(--text-muted)}
.footer a{color:var(--blue);text-decoration:none;font-weight:500}
.footer a:hover{text-decoration:underline}
.remember{display:flex;align-items:center;gap:8px;margin-bottom:20px}
.remember input{width:auto}
.remember label{margin:0;font-size:14px;color:var(--text-body);font-weight:400}
.forgot{text-align:right;margin-bottom:20px}
.forgot a{color:var(--blue);text-decoration:none;font-size:14px}
@media(max-width:480px){
.login-card{padding:28px 20px}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<a href="/" class="logo">
<div class="logo-icon">Q</div>
<div class="logo-text">qui<span>X</span>zoom</div>
</a>
<h1>Välkommen tillbaka</h1>
<p class="subtitle">Logga in för att fortsätta samla verkligheten</p>
<form id="loginForm">
<div class="form-group">
<label for="email">E-post</label>
<input type="email" id="email" name="email" placeholder="din@email.com" required autofocus>
<div class="error" id="emailError">Ogiltig e-postadress</div>
</div>
<div class="form-group">
<label for="password">Lösenord</label>
<input type="password" id="password" name="password" placeholder="Ditt lösenord" required>
<div class="error" id="passwordError">Felaktigt lösenord</div>
</div>
<div class="forgot">
<a href="/forgot-password">Glömt lösenord?</a>
</div>
<button type="submit" class="login-btn" id="loginBtn">Logga in</button>
</form>
<div class="divider">eller</div>
<button class="social-btn" onclick="loginWithApple()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>
Fortsätt med Apple
</button>
<button class="social-btn" onclick="loginWithGoogle()">
<svg width="20" height="20" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Fortsätt med Google
</button>
<div class="footer">
Har du inget konto? <a href="/register">Registrera dig</a>
</div>
</div>
</div>
<script type="module">
import { quixzoomAuth } from './sso-client.js';
const form = document.getElementById('loginForm');
const loginBtn = document.getElementById('loginBtn');
const emailError = document.getElementById('emailError');
const passwordError = document.getElementById('passwordError');
// Get redirect URL from query params
const urlParams = new URLSearchParams(window.location.search);
const redirectUrl = urlParams.get('redirect') || 'https://www.quixzoom.com/mina-sidor';
form.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
// Reset errors
emailError.classList.remove('visible');
passwordError.classList.remove('visible');
loginBtn.disabled = true;
loginBtn.textContent = 'Loggar in...';
try {
await quixzoomAuth.login(email, password, {
userAgent: navigator.userAgent,
platform: navigator.platform,
});
// Redirect to intended page
window.location.href = redirectUrl;
} catch (error) {
console.error('Login failed:', error);
passwordError.textContent = error.message || 'Inloggning misslyckades';
passwordError.classList.add('visible');
loginBtn.disabled = false;
loginBtn.textContent = 'Logga in';
}
});
// Social login stubs
window.loginWithApple = () => {
alert('Apple Login - kommer snart');
};
window.loginWithGoogle = () => {
alert('Google Login - kommer snart');
};
</script>
</body>
</html>
+531
View File
@@ -0,0 +1,531 @@
"""
quiXzoom SSO Authentication Service
Microsoft-like seamless auth across all quixzoom properties
Architecture:
- Shared JWT tokens signed with RS256
- Cookie domain: .quixzoom.com (shared across all subdomains)
- Access token: short-lived (15 min)
- Refresh token: long-lived (30 days), rotated on each use
- Cross-domain: all quixzoom.* domains trust the same auth service
"""
import os
import time
import uuid
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from fastapi import FastAPI, Request, Response, HTTPException, Cookie, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
import jwt
import redis
import uvicorn
app = FastAPI(
title="quiXzoom SSO Auth",
description="Single Sign-On for all quixzoom properties",
version="2.0.0"
)
# Redis for sessions and refresh tokens
redis_client = redis.Redis(
host=os.getenv('REDIS_HOST', 'localhost'),
port=int(os.getenv('REDIS_PORT', 6379)),
db=int(os.getenv('REDIS_DB', 0)),
decode_responses=True
)
# JWT Configuration - load from files
# Use quixzoom- prefixed keys (actual filenames)
JWT_PRIVATE_KEY_PATH = os.getenv('JWT_PRIVATE_KEY_PATH', '/app/keys/quixzoom-private.pem')
JWT_PUBLIC_KEY_PATH = os.getenv('JWT_PUBLIC_KEY_PATH', '/app/keys/quixzoom-public.pem')
def load_key(path: str, key_type: str = 'private') -> str:
"""Load key from file, trying variations of the filename"""
# Try the exact path first
paths_to_try = [path]
# If path ends with private.pem (without quixzoom- prefix), try with prefix
if '/private.pem' in path and 'quixzoom' not in path:
paths_to_try.append(path.replace('/private.pem', '/quixzoom-private.pem'))
if '/public.pem' in path and 'quixzoom' not in path:
paths_to_try.append(path.replace('/public.pem', '/quixzoom-public.pem'))
# Also try the default names in /app/keys
paths_to_try.append('/app/keys/quixzoom-private.pem')
paths_to_try.append('/app/keys/quixzoom-public.pem')
for p in paths_to_try:
try:
with open(p, 'r') as f:
content = f.read()
print(f"Loaded key from: {p} ({len(content)} chars)", flush=True)
return content
except FileNotFoundError:
continue
# Fallback to env var
fallback = os.getenv('JWT_PRIVATE_KEY' if key_type == 'private' else 'JWT_PUBLIC_KEY', '')
if fallback:
print(f"Using fallback env var for {key_type} key", flush=True)
return fallback
JWT_PRIVATE_KEY = load_key(JWT_PRIVATE_KEY_PATH, 'private')
JWT_PUBLIC_KEY = load_key(JWT_PUBLIC_KEY_PATH, 'public')
JWT_ALGORITHM = 'RS256'
print(f"Final - Private key: {len(JWT_PRIVATE_KEY)} chars, Public key: {len(JWT_PUBLIC_KEY)} chars", flush=True)
ACCESS_TOKEN_TTL = 900 # 15 minutes
REFRESH_TOKEN_TTL = 2592000 # 30 days
# Cookie settings
COOKIE_DOMAIN = '.quixzoom.com'
COOKIE_SECURE = True
COOKIE_SAMESITE = 'lax'
# Allowed origins - all quixzoom properties
ALLOWED_ORIGINS = [
'https://quixzoom.com',
'https://www.quixzoom.com',
'https://app.quixzoom.com',
'https://quixzoom.se',
'https://www.quixzoom.se',
'https://quixzoom.de',
'https://www.quixzoom.de',
'https://quixzoom.fr',
'https://www.quixzoom.fr',
'https://quixzoom.nl',
'https://www.quixzoom.nl',
'https://quixzoom.co.uk',
'https://www.quixzoom.co.uk',
'https://quixzoom.asia',
'https://www.quixzoom.asia',
'https://quixzoom.es',
'https://www.quixzoom.es',
'https://quixzoom.it',
'https://www.quixzoom.it',
'https://quixzoom.pl',
'https://www.quixzoom.pl',
'http://localhost:3000',
'http://localhost:8080',
]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
# ─── Models ──────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
email: str
password: str
device_id: Optional[str] = None
device_info: Optional[Dict[str, Any]] = None
redirect_url: Optional[str] = 'https://www.quixzoom.com/mina-sidor'
class RegisterRequest(BaseModel):
email: str
password: str
first_name: str
last_name: str
phone: Optional[str] = None
country: Optional[str] = None
device_id: Optional[str] = None
device_info: Optional[Dict[str, Any]] = None
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = 'Bearer'
expires_in: int = ACCESS_TOKEN_TTL
user: Dict[str, Any]
class UserProfile(BaseModel):
id: str
email: str
first_name: str
last_name: str
avatar_url: Optional[str] = None
role: str = 'zoomer'
kyc_status: str = 'pending'
wallet_balance: float = 0.0
qz_tokens: float = 0.0
# ─── Helper Functions ────────────────────────────────────────────────────
def generate_token_id() -> str:
return secrets.token_urlsafe(32)
def create_access_token(user_id: str, email: str, role: str, token_id: str) -> str:
now = datetime.now(timezone.utc)
payload = {
'sub': user_id,
'email': email,
'role': role,
'jti': token_id,
'iat': now,
'exp': now + timedelta(seconds=ACCESS_TOKEN_TTL),
'type': 'access',
'iss': 'https://auth.quixzoom.com',
'aud': 'quixzoom-platform',
}
return jwt.encode(payload, JWT_PRIVATE_KEY, algorithm=JWT_ALGORITHM)
def create_refresh_token(user_id: str, token_id: str, device_id: Optional[str] = None) -> str:
now = datetime.now(timezone.utc)
payload = {
'sub': user_id,
'jti': token_id,
'iat': now,
'exp': now + timedelta(seconds=REFRESH_TOKEN_TTL),
'type': 'refresh',
'device_id': device_id,
'iss': 'https://auth.quixzoom.com',
'aud': 'quixzoom-platform',
}
return jwt.encode(payload, JWT_PRIVATE_KEY, algorithm=JWT_ALGORITHM)
def verify_token(token: str, token_type: str = 'access') -> Optional[Dict]:
try:
payload = jwt.decode(
token,
JWT_PUBLIC_KEY,
algorithms=[JWT_ALGORITHM],
audience='quixzoom-platform',
issuer='https://auth.quixzoom.com'
)
if payload.get('type') != token_type:
return None
# Check if token is blacklisted
jti = payload.get('jti')
if jti and redis_client.get(f'blacklist:{jti}'):
return None
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
"""Set cookies that work across all quixzoom subdomains"""
response.set_cookie(
key='qz_access_token',
value=access_token,
max_age=ACCESS_TOKEN_TTL,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
domain=COOKIE_DOMAIN,
path='/'
)
response.set_cookie(
key='qz_refresh_token',
value=refresh_token,
max_age=REFRESH_TOKEN_TTL,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
domain=COOKIE_DOMAIN,
path='/'
)
def clear_auth_cookies(response: Response):
"""Clear auth cookies from all quixzoom domains"""
for cookie_name in ['qz_access_token', 'qz_refresh_token']:
response.delete_cookie(
key=cookie_name,
domain=COOKIE_DOMAIN,
path='/'
)
# ─── Authentication Dependency ───────────────────────────────────────────
async def get_current_user(request: Request) -> Optional[Dict]:
"""Extract and verify user from cookie or Authorization header"""
token = None
# Try cookie first (for web)
token = request.cookies.get('qz_access_token')
# Try Authorization header (for API/app)
if not token:
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header[7:]
if not token:
return None
payload = verify_token(token, 'access')
if not payload:
return None
# TODO: Fetch full user from database
return {
'id': payload['sub'],
'email': payload['email'],
'role': payload['role'],
}
# ─── Routes ──────────────────────────────────────────────────────────────
@app.get('/health')
async def health():
return {'status': 'ok', 'service': 'quixzoom-sso-auth', 'version': '2.0.0'}
@app.post('/auth/login')
async def login(request: LoginRequest, response: Response):
"""Login and set cross-domain cookies"""
# TODO: Verify credentials against database
# For now, mock implementation
user_id = str(uuid.uuid4())
token_id = generate_token_id()
refresh_id = generate_token_id()
access_token = create_access_token(user_id, request.email, 'zoomer', token_id)
refresh_token = create_refresh_token(user_id, refresh_id, request.device_id)
# Store refresh token in Redis
redis_client.setex(
f'refresh:{refresh_id}',
REFRESH_TOKEN_TTL,
user_id
)
# Set cookies for web clients
set_auth_cookies(response, access_token, refresh_token)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user={
'id': user_id,
'email': request.email,
'first_name': 'Test',
'last_name': 'User',
}
)
@app.post('/auth/refresh')
async def refresh_token(request: Request, response: Response):
"""Refresh access token using refresh token"""
refresh_token = request.cookies.get('qz_refresh_token')
if not refresh_token:
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
refresh_token = auth_header[7:]
if not refresh_token:
raise HTTPException(status_code=401, detail='No refresh token provided')
payload = verify_token(refresh_token, 'refresh')
if not payload:
raise HTTPException(status_code=401, detail='Invalid refresh token')
# Check if refresh token is in Redis
jti = payload.get('jti')
user_id = redis_client.get(f'refresh:{jti}')
if not user_id:
raise HTTPException(status_code=401, detail='Refresh token revoked')
# Rotate refresh token (security best practice)
new_token_id = generate_token_id()
new_refresh_id = generate_token_id()
# Delete old refresh token
redis_client.delete(f'refresh:{jti}')
# Create new tokens
access_token = create_access_token(user_id, payload.get('email', ''), 'zoomer', new_token_id)
new_refresh_token = create_refresh_token(user_id, new_refresh_id, payload.get('device_id'))
# Store new refresh token
redis_client.setex(f'refresh:{new_refresh_id}', REFRESH_TOKEN_TTL, user_id)
# Update cookies
set_auth_cookies(response, access_token, new_refresh_token)
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
user={'id': user_id, 'email': payload.get('email', '')}
)
@app.post('/auth/logout')
async def logout(request: Request, response: Response):
"""Logout and clear all cookies"""
# Blacklist the access token
access_token = request.cookies.get('qz_access_token')
if access_token:
payload = verify_token(access_token)
if payload and payload.get('jti'):
redis_client.setex(f'blacklist:{payload["jti"]}', ACCESS_TOKEN_TTL, '1')
# Delete refresh token
refresh_token = request.cookies.get('qz_refresh_token')
if refresh_token:
payload = verify_token(refresh_token, 'refresh')
if payload and payload.get('jti'):
redis_client.delete(f'refresh:{payload["jti"]}')
clear_auth_cookies(response)
return {'status': 'logged_out'}
@app.get('/auth/me')
async def get_me(current_user: Optional[Dict] = Depends(get_current_user)):
"""Get current user info"""
if not current_user:
raise HTTPException(status_code=401, detail='Not authenticated')
return {
'authenticated': True,
'user': current_user
}
@app.get('/auth/check')
async def auth_check(current_user: Optional[Dict] = Depends(get_current_user)):
"""Quick auth check - returns 200 if authenticated, 401 if not"""
if not current_user:
raise HTTPException(status_code=401, detail='Not authenticated')
return {
'authenticated': True,
'user': current_user
}
@app.get('/auth/sso/initiate')
async def sso_initiate(
redirect_url: str = 'https://www.quixzoom.com/mina-sidor',
client_id: str = 'quixzoom-web'
):
"""Initiate SSO login flow - redirect to login page"""
# Store the redirect URL in Redis for after login
session_id = secrets.token_urlsafe(32)
redis_client.setex(f'sso:{session_id}', 300, redirect_url)
login_url = f'https://auth.quixzoom.com/login?session={session_id}&redirect={redirect_url}'
return RedirectResponse(url=login_url)
@app.get('/auth/sso/callback')
async def sso_callback(
code: str,
state: str,
response: Response
):
"""SSO callback - exchange code for tokens and redirect"""
# Verify the code and get redirect URL
redirect_url = redis_client.get(f'sso:{state}')
if not redirect_url:
raise HTTPException(status_code=400, detail='Invalid or expired session')
# TODO: Verify code with identity provider
# Set cookies and redirect
token_id = generate_token_id()
refresh_id = generate_token_id()
# Mock user - replace with actual lookup
user_id = str(uuid.uuid4())
access_token = create_access_token(user_id, 'user@quixzoom.com', 'zoomer', token_id)
refresh_token = create_refresh_token(user_id, refresh_id)
set_auth_cookies(response, access_token, refresh_token)
return RedirectResponse(url=redirect_url)
# ─── Public Key Endpoint ─────────────────────────────────────────────────
@app.post('/auth/sync')
async def sync_tokens(
request: Request,
response: Response,
current_user: Optional[Dict] = Depends(get_current_user)
):
"""Sync mobile app tokens with web cookies
Called by mobile app after login to enable seamless web auth.
Sets cookies on .quixzoom.com domain so user is auto-logged in on web.
"""
if not current_user:
raise HTTPException(status_code=401, detail='Not authenticated')
body = await request.json()
refresh_token = body.get('refresh_token')
# Create new tokens for web session
token_id = generate_token_id()
refresh_id = generate_token_id()
access_token = create_access_token(
current_user['id'],
current_user['email'],
current_user.get('role', 'zoomer'),
token_id
)
refresh_token_web = create_refresh_token(
current_user['id'],
refresh_id,
device_id='web_sync'
)
# Store refresh token
redis_client.setex(f'refresh:{refresh_id}', REFRESH_TOKEN_TTL, current_user['id'])
# Set cookies for web
set_auth_cookies(response, access_token, refresh_token_web)
return {
'status': 'synced',
'message': 'Web cookies set - user is now logged in on all quixzoom sites'
}
@app.get('/auth/.well-known/jwks.json')
async def jwks():
"""JWKS endpoint for token verification by other services"""
# TODO: Return proper JWKS format
return {
'keys': [
{
'kty': 'RSA',
'use': 'sig',
'kid': 'quixzoom-2026',
'alg': 'RS256',
# TODO: Add actual public key components
}
]
}
# ─── Error Handlers ──────────────────────────────────────────────────────
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={'error': exc.detail}
)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={'error': 'Internal server error'}
)
if __name__ == '__main__':
port = int(os.getenv('PORT', 8080))
host = os.getenv('HOST', '0.0.0.0')
uvicorn.run(app, host=host, port=port)
+229
View File
@@ -0,0 +1,229 @@
<!-- quiXzoom Mina Sidor - Universal Header Component -->
<!-- Injicera detta i <head> på alla quiXzoom-sajter -->
<style>
.qz-auth-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: #0A0A0A;
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
}
.qz-auth-bar .qz-logo {
font-size: 18px;
font-weight: 700;
color: #fff;
text-decoration: none;
letter-spacing: -0.5px;
}
.qz-auth-bar .qz-logo span {
color: #007AFF;
}
.qz-auth-bar .qz-nav {
display: flex;
gap: 8px;
align-items: center;
}
.qz-auth-bar .qz-btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
text-decoration: none;
}
.qz-auth-bar .qz-btn-primary {
background: #007AFF;
color: white;
}
.qz-auth-bar .qz-btn-primary:hover {
background: #0056CC;
}
.qz-auth-bar .qz-btn-ghost {
background: transparent;
color: rgba(255,255,255,0.8);
}
.qz-auth-bar .qz-btn-ghost:hover {
background: rgba(255,255,255,0.1);
color: white;
}
.qz-auth-bar .qz-user {
display: flex;
align-items: center;
gap: 8px;
color: white;
font-size: 14px;
}
.qz-auth-bar .qz-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #007AFF;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.qz-auth-bar .qz-dropdown {
position: relative;
}
.qz-auth-bar .qz-dropdown-menu {
position: absolute;
top: 40px;
right: 0;
background: #1C1C1E;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
padding: 8px;
min-width: 200px;
display: none;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.qz-auth-bar .qz-dropdown-menu.active {
display: block;
}
.qz-auth-bar .qz-dropdown-item {
padding: 10px 12px;
border-radius: 8px;
color: white;
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
transition: background 0.2s;
}
.qz-auth-bar .qz-dropdown-item:hover {
background: rgba(255,255,255,0.1);
}
.qz-auth-bar .qz-dropdown-divider {
height: 1px;
background: rgba(255,255,255,0.1);
margin: 8px 0;
}
.qz-auth-bar .qz-balance {
background: rgba(0,122,255,0.15);
color: #007AFF;
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
@media (max-width: 640px) {
.qz-auth-bar .qz-nav .qz-btn-ghost:not(.qz-user) {
display: none;
}
}
</style>
<div id="qz-auth-bar" class="qz-auth-bar">
<a href="https://quixzoom.com" class="qz-logo">qui<span>X</span>zoom</a>
<div class="qz-nav">
<div id="qz-auth-guest">
<a href="https://app.quixzoom.com/login" class="qz-btn qz-btn-ghost">Logga in</a>
<a href="https://app.quixzoom.com/register" class="qz-btn qz-btn-primary">Bli Zoomer</a>
</div>
<div id="qz-auth-user" style="display:none;">
<span class="qz-balance" id="qz-balance">$0.00</span>
<div class="qz-dropdown">
<button class="qz-btn qz-btn-ghost qz-user" onclick="qzToggleDropdown()">
<div class="qz-avatar" id="qz-avatar">E</div>
<span id="qz-user-name">Erik</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="qz-dropdown-menu" id="qz-dropdown-menu">
<a href="https://app.quixzoom.com/dashboard" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
Dashboard
</a>
<a href="https://app.quixzoom.com/wallet" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/></svg>
Plånbok
</a>
<a href="https://app.quixzoom.com/missions" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Mina uppdrag
</a>
<div class="qz-dropdown-divider"></div>
<a href="https://app.quixzoom.com/settings" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.67 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.67 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.67a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Inställningar
</a>
<a href="#" onclick="qzLogout(); return false;" class="qz-dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Logga ut
</a>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
const SSO_BASE = 'https://auth.quixzoom.com';
const APP_BASE = 'https://app.quixzoom.com';
// Check auth status
async function qzCheckAuth() {
try {
const res = await fetch(SSO_BASE + '/auth/check', {
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
document.getElementById('qz-auth-guest').style.display = 'none';
document.getElementById('qz-auth-user').style.display = 'flex';
document.getElementById('qz-user-name').textContent = data.user.name || data.user.email;
document.getElementById('qz-avatar').textContent = (data.user.name || data.user.email)[0].toUpperCase();
if (data.user.balance !== undefined) {
document.getElementById('qz-balance').textContent = '$' + data.user.balance.toFixed(2);
}
}
}
} catch (e) {
// Silent fail - show guest state
}
}
// Toggle dropdown
window.qzToggleDropdown = function() {
document.getElementById('qz-dropdown-menu').classList.toggle('active');
};
// Close dropdown on outside click
document.addEventListener('click', function(e) {
if (!e.target.closest('.qz-dropdown')) {
document.getElementById('qz-dropdown-menu').classList.remove('active');
}
});
// Logout
window.qzLogout = async function() {
try {
await fetch(SSO_BASE + '/auth/logout', {
method: 'POST',
credentials: 'include',
});
} catch (e) {}
document.cookie = 'qz_access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=.quixzoom.com; path=/;';
document.cookie = 'qz_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=.quixzoom.com; path=/;';
window.location.reload();
};
// Init
qzCheckAuth();
})();
</script>
+328
View File
@@ -0,0 +1,328 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mina Sidor — quiXzoom</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root{
--bg:#F7F7F5;
--white:#FFFFFF;
--blue:#0066FF;
--blue-dark:#0052CC;
--green:#00C853;
--text:#1a1a1a;
--text-body:#3a3a3a;
--text-muted:#6B6B6B;
--radius:16px;
--shadow:0 4px 24px rgba(0,0,0,.08);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Inter','Helvetica Neue',sans-serif;background:var(--bg);color:var(--text-body);min-height:100vh}
/* Header */
.header{background:var(--white);border-bottom:1px solid rgba(0,0,0,.06);padding:16px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
.logo{display:flex;align-items:center;gap:12px;text-decoration:none}
.logo-icon{width:36px;height:36px;background:var(--blue);border-radius:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px}
.logo-text{font-size:20px;font-weight:700;color:var(--text);letter-spacing:-.5px}
.logo-text span{color:var(--blue)}
.nav{display:flex;gap:8px;align-items:center}
.nav-link{padding:8px 16px;border-radius:8px;color:var(--text-body);text-decoration:none;font-size:14px;font-weight:500;transition:.2s}
.nav-link:hover{background:rgba(0,102,255,.06);color:var(--blue)}
.nav-link.active{background:var(--blue);color:#fff}
.user-menu{display:flex;align-items:center;gap:12px}
.user-avatar{width:36px;height:36px;border-radius:50%;background:var(--blue);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:14px}
.user-name{font-size:14px;font-weight:600;color:var(--text)}
.logout-btn{padding:8px 16px;border:1px solid rgba(0,0,0,.1);border-radius:8px;background:transparent;color:var(--text-muted);font-size:13px;cursor:pointer;transition:.2s}
.logout-btn:hover{border-color:#ff4444;color:#ff4444}
/* Main */
.main{max-width:1200px;margin:0 auto;padding:32px 24px}
/* Dashboard Grid */
.dashboard{display:grid;grid-template-columns:280px 1fr;gap:24px}
/* Sidebar */
.sidebar{background:var(--white);border-radius:var(--radius);padding:20px;box-shadow:var(--shadow)}
.sidebar-menu{display:flex;flex-direction:column;gap:4px}
.sidebar-item{padding:12px 16px;border-radius:10px;color:var(--text-body);text-decoration:none;font-size:14px;font-weight:500;display:flex;align-items:center;gap:12px;transition:.2s;cursor:pointer;border:none;background:transparent;width:100%;text-align:left}
.sidebar-item:hover{background:rgba(0,102,255,.06);color:var(--blue)}
.sidebar-item.active{background:var(--blue);color:#fff}
.sidebar-icon{width:20px;height:20px;display:flex;align-items:center;justify-content:center}
.sidebar-divider{height:1px;background:rgba(0,0,0,.06);margin:12px 0}
/* Content */
.content{display:flex;flex-direction:column;gap:24px}
/* Stats Cards */
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.stat-card{background:var(--white);border-radius:var(--radius);padding:24px;box-shadow:var(--shadow)}
.stat-label{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-bottom:8px}
.stat-value{font-size:32px;font-weight:700;color:var(--text);letter-spacing:-1px}
.stat-change{font-size:13px;color:var(--green);margin-top:4px;font-weight:500}
.stat-change.negative{color:#ff4444}
/* Wallet Card */
.wallet-card{background:linear-gradient(135deg,var(--blue),var(--blue-dark));border-radius:var(--radius);padding:28px;color:#fff;position:relative;overflow:hidden}
.wallet-card::before{content:'';position:absolute;top:-50%;right:-20%;width:300px;height:300px;background:rgba(255,255,255,.05);border-radius:50%}
.wallet-label{font-size:12px;text-transform:uppercase;letter-spacing:1px;opacity:.8;margin-bottom:8px}
.wallet-balance{font-size:42px;font-weight:700;letter-spacing:-1px}
.wallet-currency{font-size:18px;opacity:.8;margin-left:4px}
.wallet-actions{display:flex;gap:12px;margin-top:20px}
.wallet-btn{padding:10px 20px;border-radius:8px;background:rgba(255,255,255,.15);color:#fff;border:none;font-size:14px;font-weight:500;cursor:pointer;transition:.2s}
.wallet-btn:hover{background:rgba(255,255,255,.25)}
.wallet-btn.primary{background:#fff;color:var(--blue)}
/* Missions */
.section{background:var(--white);border-radius:var(--radius);padding:24px;box-shadow:var(--shadow)}
.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
.section-title{font-size:18px;font-weight:700;color:var(--text)}
.section-action{color:var(--blue);text-decoration:none;font-size:14px;font-weight:500}
.mission-list{display:flex;flex-direction:column;gap:12px}
.mission-item{display:flex;align-items:center;gap:16px;padding:16px;border-radius:12px;background:var(--bg);transition:.2s}
.mission-item:hover{box-shadow:0 2px 8px rgba(0,0,0,.06)}
.mission-icon{width:48px;height:48px;border-radius:12px;background:rgba(0,102,255,.1);display:flex;align-items:center;justify-content:center;color:var(--blue);font-size:20px}
.mission-info{flex:1}
.mission-title{font-size:15px;font-weight:600;color:var(--text);margin-bottom:4px}
.mission-meta{font-size:13px;color:var(--text-muted)}
.mission-reward{font-size:16px;font-weight:700;color:var(--green)}
.mission-status{padding:6px 12px;border-radius:6px;font-size:12px;font-weight:600}
.mission-status.pending{background:rgba(255,193,7,.15);color:#f9a825}
.mission-status.approved{background:rgba(0,200,83,.15);color:var(--green)}
.mission-status.rejected{background:rgba(255,68,68,.15);color:#ff4444}
/* Login State */
.login-prompt{text-align:center;padding:80px 24px}
.login-prompt h1{font-size:32px;font-weight:700;color:var(--text);margin-bottom:16px}
.login-prompt p{font-size:16px;color:var(--text-muted);margin-bottom:32px;max-width:400px;margin-left:auto;margin-right:auto}
.login-btn{padding:14px 32px;border-radius:12px;background:var(--blue);color:#fff;border:none;font-size:16px;font-weight:600;cursor:pointer;transition:.2s;text-decoration:none;display:inline-block}
.login-btn:hover{background:var(--blue-dark);transform:translateY(-1px)}
/* Loading */
.loading{display:flex;align-items:center;justify-content:center;min-height:60vh}
.loading-spinner{width:40px;height:40px;border:3px solid rgba(0,102,255,.1);border-top-color:var(--blue);border-radius:50%;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Mobile */
@media(max-width:768px){
.dashboard{grid-template-columns:1fr}
.stats{grid-template-columns:1fr}
.header{padding:12px 16px}
.nav{display:none}
.main{padding:16px}
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<a href="/" class="logo">
<div class="logo-icon">Q</div>
<div class="logo-text">qui<span>X</span>zoom</div>
</a>
<nav class="nav">
<a href="/" class="nav-link">Hem</a>
<a href="/missions" class="nav-link">Uppdrag</a>
<a href="/mina-sidor" class="nav-link active">Mina Sidor</a>
<a href="/help" class="nav-link">Hjälp</a>
</nav>
<div class="user-menu" id="userMenu" style="display:none">
<div class="user-avatar" id="userAvatar">Z</div>
<span class="user-name" id="userName">Zoomer</span>
<button class="logout-btn" onclick="logout()">Logga ut</button>
</div>
</header>
<!-- Main Content -->
<main class="main">
<!-- Loading State -->
<div class="loading" id="loadingState">
<div class="loading-spinner"></div>
</div>
<!-- Login Prompt (shown if not authenticated) -->
<div class="login-prompt" id="loginPrompt" style="display:none">
<h1>Välkommen till quiXzoom</h1>
<p>Logga in för att se dina uppdrag, saldo och inställningar. Samma konto fungerar i appen och på webben.</p>
<a href="https://auth.quixzoom.com/login?redirect=https://www.quixzoom.com/mina-sidor" class="login-btn">Logga in</a>
<p style="margin-top:24px;font-size:14px">
Har du inget konto? <a href="https://auth.quixzoom.com/register" style="color:var(--blue);text-decoration:none;font-weight:600">Registrera dig</a>
</p>
</div>
<!-- Dashboard (shown if authenticated) -->
<div class="dashboard" id="dashboard" style="display:none">
<!-- Sidebar -->
<aside class="sidebar">
<nav class="sidebar-menu">
<button class="sidebar-item active" onclick="showSection('overview')">
<span class="sidebar-icon">📊</span>
Översikt
</button>
<button class="sidebar-item" onclick="showSection('missions')">
<span class="sidebar-icon">📍</span>
Mina Uppdrag
</button>
<button class="sidebar-item" onclick="showSection('wallet')">
<span class="sidebar-icon">💰</span>
Plånbok
</button>
<button class="sidebar-item" onclick="showSection('profile')">
<span class="sidebar-icon">👤</span>
Profil
</button>
<div class="sidebar-divider"></div>
<button class="sidebar-item" onclick="showSection('settings')">
<span class="sidebar-icon">⚙️</span>
Inställningar
</button>
</nav>
</aside>
<!-- Content -->
<div class="content">
<!-- Wallet Card -->
<div class="wallet-card">
<div class="wallet-label">Tillgängligt Saldo</div>
<div class="wallet-balance">
<span id="walletBalance">0.00</span>
<span class="wallet-currency">USD</span>
</div>
<div style="margin-top:4px;opacity:.7;font-size:14px">
<span id="tokenBalance">0</span> QZ Tokens
</div>
<div class="wallet-actions">
<button class="wallet-btn primary">Begär utbetalning</button>
<button class="wallet-btn">Transaktionshistorik</button>
</div>
</div>
<!-- Stats -->
<div class="stats">
<div class="stat-card">
<div class="stat-label">Genomförda Uppdrag</div>
<div class="stat-value" id="completedMissions">0</div>
<div class="stat-change">+12 denna vecka</div>
</div>
<div class="stat-card">
<div class="stat-label">Godkännandegrad</div>
<div class="stat-value" id="approvalRate">0%</div>
<div class="stat-change">+2.3% denna månad</div>
</div>
<div class="stat-card">
<div class="stat-label">Veckans Intjäning</div>
<div class="stat-value" id="weeklyEarnings">$0</div>
<div class="stat-change">+$45 vs förra veckan</div>
</div>
</div>
<!-- Recent Missions -->
<div class="section">
<div class="section-header">
<h2 class="section-title">Senaste Uppdrag</h2>
<a href="/missions" class="section-action">Se alla →</a>
</div>
<div class="mission-list" id="missionList">
<!-- Populated by JS -->
</div>
</div>
</div>
</div>
</main>
<script type="module">
import { quixzoomAuth } from './sso-client.js';
// DOM elements
const loadingState = document.getElementById('loadingState');
const loginPrompt = document.getElementById('loginPrompt');
const dashboard = document.getElementById('dashboard');
const userMenu = document.getElementById('userMenu');
const userAvatar = document.getElementById('userAvatar');
const userName = document.getElementById('userName');
// Initialize
async function init() {
const user = await quixzoomAuth.init();
loadingState.style.display = 'none';
if (user) {
showDashboard(user);
} else {
showLoginPrompt();
}
}
function showDashboard(user) {
dashboard.style.display = 'grid';
userMenu.style.display = 'flex';
// Update user info
userAvatar.textContent = (user.first_name?.[0] || 'Z').toUpperCase();
userName.textContent = `${user.first_name || ''} ${user.last_name || ''}`.trim() || 'Zoomer';
// Load dashboard data
loadDashboardData();
}
function showLoginPrompt() {
loginPrompt.style.display = 'block';
}
async function loadDashboardData() {
// Mock data - replace with API calls
document.getElementById('walletBalance').textContent = '1,247.50';
document.getElementById('tokenBalance').textContent = '1,247.5';
document.getElementById('completedMissions').textContent = '47';
document.getElementById('approvalRate').textContent = '94%';
document.getElementById('weeklyEarnings').textContent = '$312';
// Mock missions
const missions = [
{title: 'Fasadinspektion — Stureplan 4', location: 'Stockholm', reward: 85, status: 'approved', date: 'Idag'},
{title: 'Vägskade dokumentation', location: 'Södermalm', reward: 45, status: 'pending', date: 'Igår'},
{title: 'Butiksfasad — Kungsgatan', location: 'Stockholm', reward: 60, status: 'approved', date: '2 dagar sedan'},
];
const missionList = document.getElementById('missionList');
missionList.innerHTML = missions.map(m => `
<div class="mission-item">
<div class="mission-icon">📸</div>
<div class="mission-info">
<div class="mission-title">${m.title}</div>
<div class="mission-meta">${m.location}${m.date}</div>
</div>
<div class="mission-reward">$${m.reward}</div>
<div class="mission-status ${m.status}">${m.status === 'approved' ? 'Godkänd' : m.status === 'pending' ? 'Granskas' : 'Avvisad'}</div>
</div>
`).join('');
}
// Global functions for onclick handlers
window.logout = async () => {
await quixzoomAuth.logout();
window.location.reload();
};
window.showSection = (section) => {
// Update active sidebar item
document.querySelectorAll('.sidebar-item').forEach(item => {
item.classList.remove('active');
});
event.target.closest('.sidebar-item').classList.add('active');
// TODO: Show different content sections
console.log('Show section:', section);
};
// Start
init();
</script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
# quiXzoom SSO - Nginx Configuration
# Place this in /etc/nginx/conf.d/quixzoom-sso.conf
upstream quixzoom_sso {
server localhost:8080;
keepalive 32;
}
# SSO Auth Service
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name auth.quixzoom.com;
# SSL certificates (Let's Encrypt or similar)
ssl_certificate /etc/letsencrypt/live/quixzoom.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/quixzoom.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# CORS for all quixzoom domains
location / {
# Preflight
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $http_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 1728000 always;
add_header 'Content-Type' 'text/plain; charset=utf-8' always;
add_header 'Content-Length' 0 always;
return 204;
}
# Actual requests
add_header 'Access-Control-Allow-Origin' $http_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Expose-Headers' 'Authorization' always;
proxy_pass http://quixzoom_sso;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# Cookie handling
proxy_cookie_domain localhost .quixzoom.com;
proxy_cookie_path / /;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name auth.quixzoom.com;
return 301 https://$server_name$request_uri;
}
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
pyjwt>=2.8.0
cryptography>=41.0.0
redis>=5.0.0
pydantic>=2.5.0
python-multipart>=0.0.6
+59
View File
@@ -0,0 +1,59 @@
/**
* quiXzoom Site Auth Snippet
* Include this on every quixzoom page for automatic auth
*
* Usage: Add to <head> of each HTML page:
* <script type="module" src="/auth-check.js"></script>
*/
import { quixzoomAuth } from 'https://auth.quixzoom.com/sso-client.js';
// Auto-check auth on page load
(async function() {
const user = await quixzoomAuth.init();
// Update UI based on auth state
updateAuthUI(user);
// Listen for auth state changes
document.addEventListener('quixzoom:auth', (e) => {
updateAuthUI(e.detail.user);
});
})();
function updateAuthUI(user) {
// Find auth elements
const authElements = document.querySelectorAll('[data-auth]');
authElements.forEach(el => {
const showWhen = el.dataset.auth; // 'logged-in' or 'logged-out'
const isLoggedIn = user !== null;
if (showWhen === 'logged-in') {
el.style.display = isLoggedIn ? '' : 'none';
// Update user info
if (isLoggedIn && el.dataset.authName) {
const nameEl = el.querySelector('[data-auth-user-name]');
if (nameEl) {
nameEl.textContent = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email;
}
}
} else if (showWhen === 'logged-out') {
el.style.display = isLoggedIn ? 'none' : '';
}
});
// Update login links to include redirect
document.querySelectorAll('a[href*="auth.quixzoom.com/login"]').forEach(link => {
const url = new URL(link.href);
url.searchParams.set('redirect', window.location.href);
link.href = url.toString();
});
}
// Global logout function
window.quixzoomLogout = async () => {
await quixzoomAuth.logout();
window.location.reload();
};
+223
View File
@@ -0,0 +1,223 @@
/**
* quiXzoom SSO Client
* Universal auth client for all quixzoom properties
*
* Usage:
* import { quixzoomAuth } from './sso-client.js';
*
* // Check if logged in
* const user = await quixzoomAuth.getUser();
*
* // Login
* await quixzoomAuth.login(email, password);
*
* // Logout
* await quixzoomAuth.logout();
*/
const AUTH_BASE_URL = 'https://auth.quixzoom.com';
const COOKIE_NAMES = ['qz_access_token', 'qz_refresh_token'];
class QuixzoomAuth {
constructor() {
this.user = null;
this.tokenRefreshTimer = null;
}
/**
* Initialize auth - call on page load
*/
async init() {
// Check if we have a valid session
const user = await this.getUser();
if (user) {
this.startTokenRefresh();
}
return user;
}
/**
* Get current user (checks cookie automatically)
*/
async getUser() {
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/me`, {
method: 'GET',
credentials: 'include', // Important: sends cookies
headers: {
'Accept': 'application/json',
}
});
if (response.ok) {
const data = await response.json();
this.user = data.user;
return data.user;
}
// Try to refresh if access token expired
if (response.status === 401) {
const refreshed = await this.refreshToken();
if (refreshed) {
return this.getUser();
}
}
return null;
} catch (error) {
console.error('Auth check failed:', error);
return null;
}
}
/**
* Login with email/password
*/
async login(email, password, deviceInfo = {}) {
const response = await fetch(`${AUTH_BASE_URL}/auth/login`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
email,
password,
device_info: deviceInfo,
redirect_url: window.location.href,
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Login failed');
}
const data = await response.json();
this.user = data.user;
this.startTokenRefresh();
return data;
}
/**
* Refresh access token
*/
async refreshToken() {
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
headers: {
'Accept': 'application/json',
}
});
if (response.ok) {
const data = await response.json();
this.user = data.user;
return true;
}
return false;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}
/**
* Logout
*/
async logout() {
try {
await fetch(`${AUTH_BASE_URL}/auth/logout`, {
method: 'POST',
credentials: 'include',
});
} catch (error) {
console.error('Logout error:', error);
}
this.user = null;
this.stopTokenRefresh();
// Clear any local storage
localStorage.removeItem('quixzoom_user');
return true;
}
/**
* Check if user is authenticated
*/
isAuthenticated() {
return this.user !== null;
}
/**
* Redirect to login if not authenticated
*/
async requireAuth(redirectUrl = window.location.href) {
const user = await this.getUser();
if (!user) {
// Store intended URL
sessionStorage.setItem('auth_redirect', redirectUrl);
// Redirect to login page
const loginUrl = `https://auth.quixzoom.com/login?redirect=${encodeURIComponent(redirectUrl)}`;
window.location.href = loginUrl;
return null;
}
return user;
}
/**
* Start automatic token refresh
*/
startTokenRefresh() {
// Refresh 2 minutes before expiry
const refreshInterval = (15 - 2) * 60 * 1000; // 13 minutes
this.stopTokenRefresh();
this.tokenRefreshTimer = setInterval(() => {
this.refreshToken();
}, refreshInterval);
}
/**
* Stop automatic token refresh
*/
stopTokenRefresh() {
if (this.tokenRefreshTimer) {
clearInterval(this.tokenRefreshTimer);
this.tokenRefreshTimer = null;
}
}
/**
* Get auth headers for API requests
*/
getAuthHeaders() {
// Cookies are sent automatically, but we can add Bearer if needed
return {
'Accept': 'application/json',
};
}
}
// Singleton instance
export const quixzoomAuth = new QuixzoomAuth();
// Auto-init on import
quixzoomAuth.init();
// Also expose for non-module usage
if (typeof window !== 'undefined') {
window.quixzoomAuth = quixzoomAuth;
}
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Update all quixzoom sites with SSO auth integration
echo "🔄 Updating quixzoom sites with SSO auth..."
# List of sites to update
SITES=(
"/home/bernt/.openclaw/workspace/quixzoom-landing-fixed"
"/home/bernt/.openclaw/workspace/quixzoom-asia-pages"
"/home/bernt/.openclaw/workspace/quixzoom-market-pages"
)
AUTH_SNIPPET='<script type="module" src="https://auth.quixzoom.com/site-auth-snippet.js"></script>'
for site in "${SITES[@]}"; do
if [ -d "$site" ]; then
echo "📁 Processing: $site"
# Find all HTML files
find "$site" -name "*.html" -type f | while read -r file; do
# Check if already has auth snippet
if ! grep -q "site-auth-snippet.js" "$file"; then
# Add auth snippet before </head>
sed -i "s|</head>| $AUTH_SNIPPET\n</head>|" "$file"
echo " ✅ Updated: $(basename "$file")"
else
echo " ⏭️ Already has auth: $(basename "$file")"
fi
done
fi
done
echo ""
echo "✅ All sites updated!"
echo ""
echo "Next: Deploy updated sites to S3/CloudFront"