Auth: Nur noch Magic Link, Code-Verifizierung entfernt
- /api/auth/verify-code Endpoint entfernt - generate_magic_code() entfernt - E-Mail: Nur noch Anmelde-Link, kein 6-stelliger Code - Login-Seite: Zeigt nach E-Mail-Eingabe Hinweis statt Code-Feld - Magic Link Token-Verifikation via URL bleibt bestehen
Dieser Commit ist enthalten in:
110
CLAUDE.md
110
CLAUDE.md
@@ -29,7 +29,7 @@ Inspiriert vom WorldView/God's Eye Konzept, komplett eigenentwickelt.
|
|||||||
| Layer | Quelle | API | Refresh | Menge |
|
| Layer | Quelle | API | Refresh | Menge |
|
||||||
|-------|--------|-----|---------|-------|
|
|-------|--------|-----|---------|-------|
|
||||||
| Flugverkehr | adsb.lol | /v2/point/30/0/10000 | 15s | ~10.800 |
|
| Flugverkehr | adsb.lol | /v2/point/30/0/10000 | 15s | ~10.800 |
|
||||||
| (Fallback) | adsb.one | /v2/point/30/0/10000 | - | ~7.000 |
|
| | adsb.one (Fallback) | /v2/point/30/0/10000 | - | ~7.000 |
|
||||||
| Schiffsverkehr | AISStream.io | WebSocket wss://stream.aisstream.io | Echtzeit | ~15.000+ |
|
| Schiffsverkehr | AISStream.io | WebSocket wss://stream.aisstream.io | Echtzeit | ~15.000+ |
|
||||||
| Erdbeben | USGS | earthquake.usgs.gov GeoJSON | 5min | ~50/Tag |
|
| Erdbeben | USGS | earthquake.usgs.gov GeoJSON | 5min | ~50/Tag |
|
||||||
| Katastrophen | NASA EONET | eonet.gsfc.nasa.gov/api/v3 | 10min | ~30-80 aktiv |
|
| Katastrophen | NASA EONET | eonet.gsfc.nasa.gov/api/v3 | 10min | ~30-80 aktiv |
|
||||||
@@ -51,67 +51,26 @@ Inspiriert vom WorldView/God's Eye Konzept, komplett eigenentwickelt.
|
|||||||
## API-Keys & Credentials
|
## API-Keys & Credentials
|
||||||
|
|
||||||
Alle in ~/AegisSight-Globe/.env (nicht in Git):
|
Alle in ~/AegisSight-Globe/.env (nicht in Git):
|
||||||
- AISSTREAM_KEY: AISStream.io WebSocket API
|
- AISSTREAM_KEY — AISStream.io WebSocket API
|
||||||
- CESIUM_ION_TOKEN: Cesium Ion (Free-Tier, Commercial noetig fuer Vertrieb)
|
- CESIUM_ION_TOKEN — Cesium Ion (Free-Tier, Commercial noetig fuer Vertrieb)
|
||||||
- JWT_SECRET: Gleicher wie Monitor
|
- JWT_SECRET — Gleicher wie Monitor
|
||||||
- SMTP_*: E-Mail-Versand fuer Magic Link
|
- SMTP_* — E-Mail-Versand fuer Magic Link
|
||||||
- MONITOR_API_KEY: Public API Key des Monitors
|
- MONITOR_API_KEY — Public API Key des Monitors
|
||||||
- DISASTER_INCIDENT_ID=45: Naturkatastrophen-Lage fuer Auto-Push
|
- DISASTER_INCIDENT_ID=45 — Naturkatastrophen-Lage fuer Auto-Push
|
||||||
|
|
||||||
## Verzeichnisstruktur
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
```
|
|
||||||
~/AegisSight-Globe/
|
|
||||||
├── .env
|
|
||||||
├── .gitignore
|
|
||||||
├── CLAUDE.md
|
|
||||||
├── requirements.txt
|
|
||||||
├── src/
|
|
||||||
│ ├── main.py # FastAPI App, Auth-Middleware, Static Mount
|
|
||||||
│ ├── config.py # DB, JWT, SMTP Konfiguration
|
|
||||||
│ ├── database.py # aiosqlite Verbindung zur geteilten DB
|
|
||||||
│ ├── auth.py # JWT + Magic Link Token/Code
|
|
||||||
│ ├── auth_router.py # /api/auth/* Endpoints
|
|
||||||
│ ├── email_utils.py # Magic Link E-Mail-Versand
|
|
||||||
│ ├── data_flights.py # adsb.lol + adsb.one Fallback
|
|
||||||
│ ├── data_ships.py # AISStream.io WebSocket Collector
|
|
||||||
│ ├── data_quakes.py # USGS Earthquake API
|
|
||||||
│ ├── data_gdelt.py # GDELT GEO 2.0 API
|
|
||||||
│ ├── data_satellites.py # CelesTrak TLE Orbital Elements
|
|
||||||
│ ├── data_disasters.py # NASA EONET Events
|
|
||||||
│ ├── data_monitor.py # Proxy zum Monitor Public API
|
|
||||||
│ └── data_push.py # Auto-Push: Globe-Events -> Monitor
|
|
||||||
├── static/
|
|
||||||
│ ├── index.html # CesiumJS Globe
|
|
||||||
│ ├── login.html # Magic Link Login
|
|
||||||
│ ├── css/globe.css # Dark-Theme
|
|
||||||
│ └── js/
|
|
||||||
│ ├── app.js # Viewer, Layer-Management
|
|
||||||
│ ├── layers/
|
|
||||||
│ │ ├── flights.js # Flugverkehr (zoom-adaptiv)
|
|
||||||
│ │ ├── ships.js # Schiffsverkehr (zoom-adaptiv)
|
|
||||||
│ │ ├── disasters.js # EONET + USGS kombiniert
|
|
||||||
│ │ ├── gdelt.js # GDELT Nachrichten
|
|
||||||
│ │ ├── satellites.js # Satelliten-Orbits
|
|
||||||
│ │ ├── weather.js # Regenradar
|
|
||||||
│ │ ├── monitor.js # Monitor OSINT-Daten
|
|
||||||
│ │ └── visualmodes.js # NVG, FLIR, CRT
|
|
||||||
│ └── ui/
|
|
||||||
│ ├── sidebar.js # Rechte Datenpunkt-Sidebar
|
|
||||||
│ ├── search.js # Ortssuche + City Quick-Links
|
|
||||||
│ ├── imagery.js # Satellitenbilder-Switcher
|
|
||||||
│ └── crosshairs.js # Fadenkreuz + Range Rings
|
|
||||||
└── logs/globe.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bidirektionale Monitor-Verbindung
|
## Bidirektionale Monitor-Verbindung
|
||||||
|
|
||||||
### Globe -> Monitor (Auto-Push, data_push.py)
|
### Globe -> Monitor (Auto-Push)
|
||||||
- Alle 10min: NASA EONET + USGS M4.5+ als Artikel an Monitor-Lage 45
|
- data_push.py sendet alle 10min NASA EONET + USGS M4.5+ an Monitor
|
||||||
- Duplikat-Check per Headline, Locations mit Koordinaten
|
- Ziel-Lage: ID 45 (Naturkatastrophen international)
|
||||||
|
- Duplikat-Check per Headline im Monitor
|
||||||
- Monitor verifiziert und erstellt Zusammenfassung
|
- Monitor verifiziert und erstellt Zusammenfassung
|
||||||
|
|
||||||
### Monitor -> Globe (Feed, data_monitor.py)
|
### Monitor -> Globe (Feed)
|
||||||
- /api/public/globe-feed?incident_id=X liefert GeoJSON
|
- /api/public/globe-feed?incident_id=X liefert GeoJSON
|
||||||
- Pro Standort: Ortsspezifische Artikel (Headline, Quelle, Auszug, Datum)
|
- Pro Standort: Ortsspezifische Artikel (Headline, Quelle, Auszug, Datum)
|
||||||
- Lage-Auswahl im Globe-Header bestimmt welche Daten angezeigt werden
|
- Lage-Auswahl im Globe-Header bestimmt welche Daten angezeigt werden
|
||||||
@@ -119,24 +78,27 @@ Alle in ~/AegisSight-Globe/.env (nicht in Git):
|
|||||||
|
|
||||||
### Klick-Flow
|
### Klick-Flow
|
||||||
1. User waehlt Lage im Header (z.B. Naturkatastrophen)
|
1. User waehlt Lage im Header (z.B. Naturkatastrophen)
|
||||||
2. Monitor-Standorte erscheinen als rote Punkte
|
2. Monitor-Standorte erscheinen als rote Punkte auf dem Globus
|
||||||
3. Klick auf Katastrophe zeigt NASA-Daten + passende Monitor-Artikel
|
3. Klick auf Katastrophe zeigt: NASA-Daten + passende Monitor-Artikel
|
||||||
4. Zuordnung ist ortsspezifisch (Kenia-Klick = Kenia-Artikel)
|
4. Artikel sind ortsspezifisch (Kenia-Klick = Kenia-Artikel, nicht global)
|
||||||
|
|
||||||
## Auth-System
|
## Auth-System
|
||||||
|
|
||||||
- Magic Link: E-Mail -> 6-stelliger Code -> JWT (24h)
|
- Magic Link Login: E-Mail -> 6-stelliger Code per Mail -> JWT
|
||||||
- Prueft: users.is_active=1 UND users.globe_access=1
|
- Prueft: users.is_active=1 UND users.globe_access=1
|
||||||
- Globe-Zugang: An/Aus-Button im Verwaltungsportal pro User
|
- Globe-Zugang wird im Verwaltungsportal per An/Aus-Button gesteuert
|
||||||
|
- JWT-Tokens: 24h Gueltigkeit, akzeptiert auch Monitor-Tokens
|
||||||
- Alle Daten-APIs hinter Auth-Middleware
|
- Alle Daten-APIs hinter Auth-Middleware
|
||||||
- Akzeptiert auch Monitor-JWT-Tokens (Kompatibilitaet)
|
|
||||||
|
|
||||||
## Rendering-Architektur
|
## Rendering-Architektur
|
||||||
|
|
||||||
### Performance (PointPrimitiveCollection, GPU-beschleunigt)
|
### Performance
|
||||||
- Weit (>5.000km): 5-Grad-Raster-Cluster mit Count-Labels
|
- Flugzeuge/Schiffe: PointPrimitiveCollection (GPU-beschleunigt)
|
||||||
|
- Zoom-adaptiv: Cluster bei Uebersicht, Einzelpunkte bei Detail
|
||||||
|
- Weit (>5.000km): 5-Grad-Raster mit Count-Labels
|
||||||
- Mittel (1.000-5.000km): 2-Grad-Raster
|
- Mittel (1.000-5.000km): 2-Grad-Raster
|
||||||
- Nah (<1.000km): Einzelne Punkte, Details bei Klick
|
- Nah (<1.000km): Einzelne Punkte
|
||||||
|
- Katastrophen/Satelliten: Entity API (kleinere Mengen)
|
||||||
|
|
||||||
### Visual Modes
|
### Visual Modes
|
||||||
- STD: Photorealistisch
|
- STD: Photorealistisch
|
||||||
@@ -146,23 +108,19 @@ Alle in ~/AegisSight-Globe/.env (nicht in Git):
|
|||||||
|
|
||||||
## Bekannte Limitierungen
|
## Bekannte Limitierungen
|
||||||
|
|
||||||
- Cesium Ion Free-Tier: Nicht fuer kommerziellen Vertrieb
|
- Cesium Ion Free-Tier: Nicht fuer kommerziellen Vertrieb (Commercial License noetig)
|
||||||
- Satelliten-Positionen: Vereinfachte Kepler-Berechnung (nicht SGP4-exakt)
|
- Satelliten-Positionen: Vereinfachte Kepler-Berechnung (nicht SGP4-exakt)
|
||||||
- AISStream: ~1-2min bis globale Abdeckung nach Restart
|
- AISStream: Schiffsdaten brauchen ~1-2min bis globale Abdeckung nach Restart
|
||||||
- Sentinel-2: Wolkenfreie Komposite, nicht tagesaktuell
|
- OpenSky als Flug-Fallback: Aggressive Rate-Limits (429), daher adsb.lol als Primary
|
||||||
|
|
||||||
|
## Services neustarten
|
||||||
|
|
||||||
## Services
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart globe # Globe-App
|
|
||||||
sudo systemctl restart osint-monitor # Monitor (API-Aenderungen)
|
|
||||||
sudo systemctl restart verwaltungsportal # Verwaltung
|
|
||||||
```
|
|
||||||
|
|
||||||
## Entwicklungshinweise
|
## Entwicklungshinweise
|
||||||
|
|
||||||
- Alle JS als statische Dateien (kein Build-Step)
|
- Alle JS-Dateien werden als statische Dateien ausgeliefert (kein Build-Step)
|
||||||
- Cache-Busting: Ctrl+Shift+R im Browser
|
- Cache-Busting: Browser-Cache manuell mit Ctrl+Shift+R leeren
|
||||||
- JS-Syntax pruefen: node --check static/js/layers/DATEI.js
|
- JS-Syntax pruefen: node --check ~/AegisSight-Globe/static/js/layers/DATEI.js
|
||||||
- Inkrementelle Patches vermeiden — Dateien komplett neu schreiben
|
- Inkrementelle Patches vermeiden — bei groesseren Aenderungen Datei komplett neu schreiben
|
||||||
- .env Aenderungen erfordern Service-Restart
|
- .env Aenderungen erfordern Service-Restart
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Auth: JWT + Magic Link fuer Globe."""
|
"""Auth: JWT + Magic Link fuer Globe."""
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
@@ -72,6 +71,3 @@ async def get_current_user(
|
|||||||
def generate_magic_token() -> str:
|
def generate_magic_token() -> str:
|
||||||
return secrets.token_urlsafe(48)
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
def generate_magic_code() -> str:
|
|
||||||
return "".join(secrets.choice(string.digits) for _ in range(6))
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
from auth import create_token, generate_magic_token, generate_magic_code, get_current_user
|
from auth import create_token, generate_magic_token, get_current_user
|
||||||
from config import GLOBE_BASE_URL, MAGIC_LINK_EXPIRE_MINUTES
|
from config import GLOBE_BASE_URL, MAGIC_LINK_EXPIRE_MINUTES
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from email_utils import send_magic_link_email
|
from email_utils import send_magic_link_email
|
||||||
@@ -18,14 +18,9 @@ class LoginRequest(BaseModel):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
class CodeVerifyRequest(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
code: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/request-link")
|
@router.post("/request-link")
|
||||||
async def request_magic_link(req: LoginRequest, db=Depends(get_db)):
|
async def request_magic_link(req: LoginRequest, db=Depends(get_db)):
|
||||||
"""Sendet Magic Link + Code per E-Mail."""
|
"""Sendet Magic Link per E-Mail."""
|
||||||
email = req.email.lower().strip()
|
email = req.email.lower().strip()
|
||||||
|
|
||||||
# User pruefen
|
# User pruefen
|
||||||
@@ -41,27 +36,26 @@ async def request_magic_link(req: LoginRequest, db=Depends(get_db)):
|
|||||||
if not user["globe_access"]:
|
if not user["globe_access"]:
|
||||||
raise HTTPException(status_code=403, detail="Kein Globe-Zugang. Bitte wenden Sie sich an Ihren Administrator.")
|
raise HTTPException(status_code=403, detail="Kein Globe-Zugang. Bitte wenden Sie sich an Ihren Administrator.")
|
||||||
|
|
||||||
# Magic Token + Code erzeugen
|
# Magic Token erzeugen
|
||||||
token = generate_magic_token()
|
token = generate_magic_token()
|
||||||
code = generate_magic_code()
|
|
||||||
expires = datetime.now(timezone.utc) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)
|
expires = datetime.now(timezone.utc) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO magic_links (user_id, email, token, code, expires_at, purpose)
|
"""INSERT INTO magic_links (user_id, email, token, code, expires_at, purpose)
|
||||||
VALUES (?, ?, ?, ?, ?, 'globe_login')""",
|
VALUES (?, ?, ?, '', ?, 'globe_login')""",
|
||||||
(user["id"], email, token, code, expires.isoformat()),
|
(user["id"], email, token, expires.isoformat()),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
link = f"{GLOBE_BASE_URL}/api/auth/verify?token={token}"
|
link = f"{GLOBE_BASE_URL}/api/auth/verify?token={token}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await send_magic_link_email(email, code, link)
|
await send_magic_link_email(email, link)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=502, detail="E-Mail konnte nicht gesendet werden.")
|
raise HTTPException(status_code=502, detail="E-Mail konnte nicht gesendet werden.")
|
||||||
|
|
||||||
logger.info(f"Magic Link gesendet an {email}")
|
logger.info(f"Magic Link gesendet an {email}")
|
||||||
return {"ok": True, "message": "Zugangscode wurde per E-Mail gesendet."}
|
return {"ok": True, "message": "Anmelde-Link wurde per E-Mail gesendet."}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/verify")
|
@router.get("/verify")
|
||||||
@@ -101,37 +95,6 @@ async def verify_token(token: str, db=Depends(get_db)):
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-code")
|
|
||||||
async def verify_code(req: CodeVerifyRequest, db=Depends(get_db)):
|
|
||||||
"""Verifiziert 6-stelligen Code, gibt JWT zurueck."""
|
|
||||||
email = req.email.lower().strip()
|
|
||||||
cursor = await db.execute(
|
|
||||||
"""SELECT ml.id, ml.user_id, ml.expires_at, ml.is_used,
|
|
||||||
u.username, u.email, u.is_active, u.globe_access, u.role
|
|
||||||
FROM magic_links ml JOIN users u ON ml.user_id = u.id
|
|
||||||
WHERE ml.code = ? AND LOWER(u.email) = ? AND ml.purpose = 'globe_login'
|
|
||||||
ORDER BY ml.created_at DESC LIMIT 1""",
|
|
||||||
(req.code, email),
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=400, detail="Ungueltiger Code.")
|
|
||||||
if row["is_used"]:
|
|
||||||
raise HTTPException(status_code=400, detail="Code wurde bereits verwendet.")
|
|
||||||
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
|
|
||||||
raise HTTPException(status_code=400, detail="Code ist abgelaufen.")
|
|
||||||
if not row["is_active"] or not row["globe_access"]:
|
|
||||||
raise HTTPException(status_code=403, detail="Kein Zugang.")
|
|
||||||
|
|
||||||
await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (row["id"],))
|
|
||||||
await db.execute("UPDATE users SET last_login_at = ? WHERE id = ?",
|
|
||||||
(datetime.now(timezone.utc).isoformat(), row["user_id"]))
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
jwt_token = create_token(row["user_id"], row["username"], row["email"], row["role"])
|
|
||||||
return {"ok": True, "token": jwt_token}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def get_me(user: dict = Depends(get_current_user)):
|
async def get_me(user: dict = Depends(get_current_user)):
|
||||||
return {"id": user["id"], "email": user["email"], "username": user["username"]}
|
return {"id": user["id"], "email": user["email"], "username": user["username"]}
|
||||||
|
|||||||
@@ -9,21 +9,21 @@ from config import SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_EMA
|
|||||||
logger = logging.getLogger("globe.email")
|
logger = logging.getLogger("globe.email")
|
||||||
|
|
||||||
|
|
||||||
async def send_magic_link_email(to_email: str, code: str, link: str):
|
async def send_magic_link_email(to_email: str, link: str):
|
||||||
"""Sendet Magic Link + Code per E-Mail."""
|
"""Sendet Magic Link per E-Mail."""
|
||||||
html = f"""
|
html = f"""
|
||||||
<div style="font-family: 'Courier New', monospace; max-width: 500px; margin: 0 auto; padding: 30px; background: #0b1121; color: #e8eaf0; border-radius: 12px;">
|
<div style="font-family: 'Courier New', monospace; max-width: 500px; margin: 0 auto; padding: 30px; background: #0b1121; color: #e8eaf0; border-radius: 12px;">
|
||||||
<h2 style="color: #00ff88; font-size: 16px; letter-spacing: 2px; margin-bottom: 24px;">AEGISSIGHT GLOBE</h2>
|
<h2 style="color: #00ff88; font-size: 16px; letter-spacing: 2px; margin-bottom: 24px;">AEGISSIGHT GLOBE</h2>
|
||||||
<p style="font-size: 14px; line-height: 1.6;">Dein Zugangscode:</p>
|
<p style="font-size: 14px; line-height: 1.6;">Klicke auf den Button, um dich anzumelden:</p>
|
||||||
<div style="background: rgba(0,255,136,0.08); border: 1px solid rgba(0,255,136,0.2); border-radius: 8px; padding: 20px; text-align: center; margin: 20px 0;">
|
<div style="text-align: center; margin: 24px 0;">
|
||||||
<span style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #00ff88;">{code}</span>
|
<a href="{link}" style="display: inline-block; background: #00ff88; color: #0b1121; padding: 14px 40px; border-radius: 6px; text-decoration: none; font-weight: 700; font-size: 16px; letter-spacing: 1px;">Jetzt anmelden</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size: 13px; color: rgba(255,255,255,0.6); line-height: 1.6;">
|
<p style="font-size: 12px; color: rgba(255,255,255,0.4); line-height: 1.6;">
|
||||||
Oder klicke auf diesen Link:<br>
|
Oder kopiere diesen Link in deinen Browser:<br>
|
||||||
<a href="{link}" style="color: #00ff88; word-break: break-all;">{link}</a>
|
<a href="{link}" style="color: #00ff88; word-break: break-all; font-size: 11px;">{link}</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size: 11px; color: rgba(255,255,255,0.3); margin-top: 24px;">
|
<p style="font-size: 11px; color: rgba(255,255,255,0.3); margin-top: 24px;">
|
||||||
Dieser Code ist 10 Minuten gueltig. Falls du diese Anfrage nicht gesendet hast, ignoriere diese E-Mail.
|
Dieser Link ist 10 Minuten gueltig. Falls du diese Anfrage nicht gesendet hast, ignoriere diese E-Mail.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -31,8 +31,8 @@ async def send_magic_link_email(to_email: str, code: str, link: str):
|
|||||||
msg = MIMEMultipart("alternative")
|
msg = MIMEMultipart("alternative")
|
||||||
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>"
|
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>"
|
||||||
msg["To"] = to_email
|
msg["To"] = to_email
|
||||||
msg["Subject"] = "AegisSight Globe — Zugangscode"
|
msg["Subject"] = "AegisSight Globe — Anmelde-Link"
|
||||||
msg.attach(MIMEText(f"Dein Globe-Zugangscode: {code}\n\nLink: {link}\n\nGueltig fuer 10 Minuten.", "plain"))
|
msg.attach(MIMEText(f"Dein Globe-Anmeldelink:\n\n{link}\n\nGueltig fuer 10 Minuten.", "plain"))
|
||||||
msg.attach(MIMEText(html, "html"))
|
msg.attach(MIMEText(html, "html"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -15,12 +15,11 @@
|
|||||||
.logo p { font-size:11px; color:var(--text-dim); margin-top:4px; }
|
.logo p { font-size:11px; color:var(--text-dim); margin-top:4px; }
|
||||||
.form-group { margin-bottom:20px; }
|
.form-group { margin-bottom:20px; }
|
||||||
label { display:block; font-size:10px; letter-spacing:1.5px; color:var(--text-dim); margin-bottom:6px; text-transform:uppercase; }
|
label { display:block; font-size:10px; letter-spacing:1.5px; color:var(--text-dim); margin-bottom:6px; text-transform:uppercase; }
|
||||||
input[type="email"], input[type="text"] {
|
input[type="email"] {
|
||||||
width:100%; padding:12px 14px; background:rgba(255,255,255,0.05); border:1px solid var(--border);
|
width:100%; padding:12px 14px; background:rgba(255,255,255,0.05); border:1px solid var(--border);
|
||||||
border-radius:6px; color:var(--text); font-family:var(--mono); font-size:14px; outline:none; transition:border-color 0.2s;
|
border-radius:6px; color:var(--text); font-family:var(--mono); font-size:14px; outline:none; transition:border-color 0.2s;
|
||||||
}
|
}
|
||||||
input:focus { border-color:var(--accent); }
|
input:focus { border-color:var(--accent); }
|
||||||
.code-input { text-align:center; letter-spacing:8px; font-size:24px; font-weight:700; }
|
|
||||||
.btn {
|
.btn {
|
||||||
width:100%; padding:12px; background:rgba(0,255,136,0.1); border:1px solid var(--accent);
|
width:100%; padding:12px; background:rgba(0,255,136,0.1); border:1px solid var(--accent);
|
||||||
border-radius:6px; color:var(--accent); font-family:var(--mono); font-size:13px; font-weight:700;
|
border-radius:6px; color:var(--accent); font-family:var(--mono); font-size:13px; font-weight:700;
|
||||||
@@ -28,12 +27,11 @@
|
|||||||
}
|
}
|
||||||
.btn:hover { background:rgba(0,255,136,0.2); }
|
.btn:hover { background:rgba(0,255,136,0.2); }
|
||||||
.btn:disabled { opacity:0.4; cursor:not-allowed; }
|
.btn:disabled { opacity:0.4; cursor:not-allowed; }
|
||||||
|
.btn-secondary { background:none; border-color:var(--border); color:var(--text-dim); }
|
||||||
|
.btn-secondary:hover { border-color:var(--accent); color:var(--accent); }
|
||||||
.error { color:#ff4444; font-size:12px; margin-top:8px; display:none; }
|
.error { color:#ff4444; font-size:12px; margin-top:8px; display:none; }
|
||||||
.success { color:var(--accent); font-size:12px; margin-top:8px; display:none; }
|
|
||||||
.step { display:none; }
|
.step { display:none; }
|
||||||
.step.active { display:block; }
|
.step.active { display:block; }
|
||||||
.back-link { display:block; text-align:center; margin-top:16px; font-size:11px; color:var(--text-dim); cursor:pointer; }
|
|
||||||
.back-link:hover { color:var(--accent); }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -52,20 +50,21 @@
|
|||||||
<label>E-Mail-Adresse</label>
|
<label>E-Mail-Adresse</label>
|
||||||
<input type="email" id="input-email" placeholder="name@beispiel.de" autofocus>
|
<input type="email" id="input-email" placeholder="name@beispiel.de" autofocus>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" id="btn-send" onclick="requestLink()">Zugangscode anfordern</button>
|
<button class="btn" id="btn-send" onclick="requestLink()">Anmelde-Link anfordern</button>
|
||||||
<div class="error" id="error-email"></div>
|
<div class="error" id="error-email"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Code -->
|
<!-- Step 2: Link gesendet -->
|
||||||
<div id="step-code" class="step">
|
<div id="step-sent" class="step">
|
||||||
<div class="form-group">
|
<div style="text-align:center; padding: 16px 0;">
|
||||||
<label>6-stelliger Zugangscode</label>
|
<div style="font-size: 36px; margin-bottom: 16px;">✉</div>
|
||||||
<input type="text" id="input-code" class="code-input" maxlength="6" placeholder="------" inputmode="numeric" pattern="[0-9]*">
|
<p style="color:var(--text-dim); font-size:13px; margin-bottom:8px;">Anmelde-Link wurde gesendet an</p>
|
||||||
|
<p style="color:var(--accent); font-size:14px; font-weight:700; margin-bottom:16px;" id="sent-email"></p>
|
||||||
|
<p style="color:var(--text-dim); font-size:12px; line-height:1.6;">
|
||||||
|
Bitte prüfe dein Postfach und klicke auf den Link in der E-Mail.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="success" id="success-code" style="display:block;margin-bottom:16px;">Zugangscode wurde per E-Mail gesendet.</div>
|
<button class="btn btn-secondary" style="margin-top:20px;" onclick="showStep('email')">Andere E-Mail verwenden</button>
|
||||||
<button class="btn" id="btn-verify" onclick="verifyCode()">Verifizieren</button>
|
|
||||||
<div class="error" id="error-code"></div>
|
|
||||||
<span class="back-link" onclick="showStep('email')">Andere E-Mail verwenden</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,48 +104,18 @@
|
|||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
showError('error-email', data.detail || 'Fehler');
|
showError('error-email', data.detail || 'Fehler');
|
||||||
} else {
|
} else {
|
||||||
showStep('code');
|
document.getElementById('sent-email').textContent = email;
|
||||||
document.getElementById('input-code').focus();
|
showStep('sent');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError('error-email', 'Verbindungsfehler');
|
showError('error-email', 'Verbindungsfehler');
|
||||||
}
|
}
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Zugangscode anfordern';
|
btn.textContent = 'Anmelde-Link anfordern';
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyCode() {
|
|
||||||
var code = document.getElementById('input-code').value.trim();
|
|
||||||
var email = document.getElementById('input-email').value.trim();
|
|
||||||
if (!code || code.length !== 6) return;
|
|
||||||
var btn = document.getElementById('btn-verify');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Pruefe...';
|
|
||||||
document.getElementById('error-code').style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/api/auth/verify-code', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: email, code: code }),
|
|
||||||
});
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
showError('error-code', data.detail || 'Fehler');
|
|
||||||
} else {
|
|
||||||
localStorage.setItem('globe_token', data.token);
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showError('error-code', 'Verbindungsfehler');
|
|
||||||
}
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Verifizieren';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter-Taste
|
// Enter-Taste
|
||||||
document.getElementById('input-email').addEventListener('keydown', function(e) { if (e.key === 'Enter') requestLink(); });
|
document.getElementById('input-email').addEventListener('keydown', function(e) { if (e.key === 'Enter') requestLink(); });
|
||||||
document.getElementById('input-code').addEventListener('keydown', function(e) { if (e.key === 'Enter') verifyCode(); });
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren