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:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Executable
+33
@@ -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!"
|
||||
Executable
+42
@@ -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"
|
||||
@@ -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-----
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+36
@@ -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"
|
||||
Reference in New Issue
Block a user