Auth: Magic Link Login + Globe-Zugangssteuerung
- Magic Link Login (E-Mail + 6-stelliger Code) - JWT-basierte Session (24h) - Prueft: is_active=1 UND globe_access=1 - Akzeptiert auch Monitor-JWT-Tokens (Kompatibilitaet) - Globe-spezifisches E-Mail-Template (Dark Theme) - Alle Daten-APIs hinter Auth-Middleware - Login-Seite mit taktischem Design - Auto-Redirect bei fehlendem/abgelaufenem Token - Fetch-Wrapper injiziert Authorization Header automatisch
Dieser Commit ist enthalten in:
@@ -2,3 +2,7 @@ fastapi==0.115.6
|
|||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
websockets>=13.0
|
websockets>=13.0
|
||||||
|
aiosqlite>=0.20
|
||||||
|
python-jose[cryptography]>=3.3
|
||||||
|
aiosmtplib>=3.0
|
||||||
|
pydantic[email]>=2.0
|
||||||
|
|||||||
77
src/auth.py
Normale Datei
77
src/auth.py
Normale Datei
@@ -0,0 +1,77 @@
|
|||||||
|
"""Auth: JWT + Magic Link fuer Globe."""
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
|
||||||
|
from database import get_db
|
||||||
|
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(user_id: int, username: str, email: str, role: str = "member") -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"role": role,
|
||||||
|
"iss": "aegissight-globe",
|
||||||
|
"aud": "aegissight-globe",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(hours=JWT_EXPIRE_HOURS),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM], audience="aegissight-globe")
|
||||||
|
|
||||||
|
|
||||||
|
# Also accept Monitor tokens (audience: intelsight-osint)
|
||||||
|
def decode_any_token(token: str) -> dict:
|
||||||
|
for aud in ["aegissight-globe", "intelsight-osint"]:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM], audience=aud)
|
||||||
|
except JWTError:
|
||||||
|
continue
|
||||||
|
raise JWTError("Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db=Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="Nicht authentifiziert")
|
||||||
|
try:
|
||||||
|
payload = decode_any_token(credentials.credentials)
|
||||||
|
user_id = int(payload["sub"])
|
||||||
|
except (JWTError, KeyError, ValueError):
|
||||||
|
raise HTTPException(status_code=401, detail="Token ungueltig")
|
||||||
|
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id, email, username, is_active, globe_access FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
user = await cursor.fetchone()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Nutzer nicht gefunden")
|
||||||
|
if not user["is_active"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Account deaktiviert")
|
||||||
|
if not user["globe_access"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Kein Globe-Zugang")
|
||||||
|
|
||||||
|
return dict(user)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_magic_token() -> str:
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_magic_code() -> str:
|
||||||
|
return "".join(secrets.choice(string.digits) for _ in range(6))
|
||||||
137
src/auth_router.py
Normale Datei
137
src/auth_router.py
Normale Datei
@@ -0,0 +1,137 @@
|
|||||||
|
"""Auth-Router: Magic Link Login fuer Globe."""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
from auth import create_token, generate_magic_token, generate_magic_code, get_current_user
|
||||||
|
from config import GLOBE_BASE_URL, MAGIC_LINK_EXPIRE_MINUTES
|
||||||
|
from database import get_db
|
||||||
|
from email_utils import send_magic_link_email
|
||||||
|
|
||||||
|
logger = logging.getLogger("globe.auth")
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class CodeVerifyRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/request-link")
|
||||||
|
async def request_magic_link(req: LoginRequest, db=Depends(get_db)):
|
||||||
|
"""Sendet Magic Link + Code per E-Mail."""
|
||||||
|
email = req.email.lower().strip()
|
||||||
|
|
||||||
|
# User pruefen
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT id, username, email, is_active, globe_access FROM users WHERE LOWER(email) = ?",
|
||||||
|
(email,),
|
||||||
|
)
|
||||||
|
user = await cursor.fetchone()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Kein Account mit dieser E-Mail-Adresse gefunden.")
|
||||||
|
if not user["is_active"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Account ist deaktiviert.")
|
||||||
|
if not user["globe_access"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Kein Globe-Zugang. Bitte wenden Sie sich an Ihren Administrator.")
|
||||||
|
|
||||||
|
# Magic Token + Code erzeugen
|
||||||
|
token = generate_magic_token()
|
||||||
|
code = generate_magic_code()
|
||||||
|
expires = datetime.now(timezone.utc) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO magic_links (user_id, token, code, expires_at, purpose)
|
||||||
|
VALUES (?, ?, ?, ?, 'globe_login')""",
|
||||||
|
(user["id"], token, code, expires.isoformat()),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
link = f"{GLOBE_BASE_URL}/api/auth/verify?token={token}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_magic_link_email(email, code, link)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=502, detail="E-Mail konnte nicht gesendet werden.")
|
||||||
|
|
||||||
|
logger.info(f"Magic Link gesendet an {email}")
|
||||||
|
return {"ok": True, "message": "Zugangscode wurde per E-Mail gesendet."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify")
|
||||||
|
async def verify_token(token: str, db=Depends(get_db)):
|
||||||
|
"""Verifiziert Magic Link Token, gibt JWT zurueck."""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""SELECT 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.token = ? AND ml.purpose = 'globe_login'""",
|
||||||
|
(token,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungueltiger Link.")
|
||||||
|
if row["is_used"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Link wurde bereits verwendet.")
|
||||||
|
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
|
||||||
|
raise HTTPException(status_code=400, detail="Link 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 token = ?", (token,))
|
||||||
|
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"])
|
||||||
|
|
||||||
|
# Redirect zum Frontend mit Token als Query-Parameter
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
return HTMLResponse(f"""
|
||||||
|
<html><body><script>
|
||||||
|
localStorage.setItem('globe_token', '{jwt_token}');
|
||||||
|
window.location.href = '/';
|
||||||
|
</script></body></html>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
async def get_me(user: dict = Depends(get_current_user)):
|
||||||
|
return {"id": user["id"], "email": user["email"], "username": user["username"]}
|
||||||
17
src/config.py
Normale Datei
17
src/config.py
Normale Datei
@@ -0,0 +1,17 @@
|
|||||||
|
"""Globe Konfiguration."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/mnt/gitea/osint-data/osint.db")
|
||||||
|
JWT_SECRET = os.environ.get("JWT_SECRET", "")
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
JWT_EXPIRE_HOURS = 24
|
||||||
|
|
||||||
|
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.ionos.de")
|
||||||
|
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
||||||
|
SMTP_USER = os.environ.get("SMTP_USER", "info@aegis-sight.de")
|
||||||
|
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
||||||
|
SMTP_FROM_EMAIL = os.environ.get("SMTP_FROM_EMAIL", "noreply@aegis-sight.de")
|
||||||
|
SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "AegisSight Globe")
|
||||||
|
|
||||||
|
GLOBE_BASE_URL = os.environ.get("GLOBE_BASE_URL", "https://globe.aegis-sight.de")
|
||||||
|
MAGIC_LINK_EXPIRE_MINUTES = 10
|
||||||
12
src/database.py
Normale Datei
12
src/database.py
Normale Datei
@@ -0,0 +1,12 @@
|
|||||||
|
"""Datenbank-Zugriff auf geteilte osint.db."""
|
||||||
|
import aiosqlite
|
||||||
|
from config import DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
db = await aiosqlite.connect(DB_PATH)
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
50
src/email_utils.py
Normale Datei
50
src/email_utils.py
Normale Datei
@@ -0,0 +1,50 @@
|
|||||||
|
"""E-Mail-Versand fuer Globe Magic Links."""
|
||||||
|
import logging
|
||||||
|
import aiosmtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
from config import SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_EMAIL, SMTP_FROM_NAME
|
||||||
|
|
||||||
|
logger = logging.getLogger("globe.email")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_magic_link_email(to_email: str, code: str, link: str):
|
||||||
|
"""Sendet Magic Link + Code per E-Mail."""
|
||||||
|
html = f"""
|
||||||
|
<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>
|
||||||
|
<p style="font-size: 14px; line-height: 1.6;">Dein Zugangscode:</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;">
|
||||||
|
<span style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #00ff88;">{code}</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 13px; color: rgba(255,255,255,0.6); line-height: 1.6;">
|
||||||
|
Oder klicke auf diesen Link:<br>
|
||||||
|
<a href="{link}" style="color: #00ff88; word-break: break-all;">{link}</a>
|
||||||
|
</p>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>"
|
||||||
|
msg["To"] = to_email
|
||||||
|
msg["Subject"] = "AegisSight Globe — Zugangscode"
|
||||||
|
msg.attach(MIMEText(f"Dein Globe-Zugangscode: {code}\n\nLink: {link}\n\nGueltig fuer 10 Minuten.", "plain"))
|
||||||
|
msg.attach(MIMEText(html, "html"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await aiosmtplib.send(
|
||||||
|
msg,
|
||||||
|
hostname=SMTP_HOST,
|
||||||
|
port=SMTP_PORT,
|
||||||
|
username=SMTP_USER,
|
||||||
|
password=SMTP_PASSWORD,
|
||||||
|
start_tls=True,
|
||||||
|
)
|
||||||
|
logger.info(f"Magic Link E-Mail gesendet an {to_email}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"E-Mail-Versand fehlgeschlagen: {e}")
|
||||||
|
raise
|
||||||
24
src/main.py
24
src/main.py
@@ -3,7 +3,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Depends
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
@@ -20,22 +20,34 @@ logger = logging.getLogger("globe")
|
|||||||
|
|
||||||
app = FastAPI(title="AegisSight Globe", docs_url=None, redoc_url=None)
|
app = FastAPI(title="AegisSight Globe", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
# --- Data modules ---
|
# --- Auth ---
|
||||||
|
from auth import get_current_user
|
||||||
|
from auth_router import router as auth_router
|
||||||
|
|
||||||
|
app.include_router(auth_router, prefix="/api")
|
||||||
|
|
||||||
|
# --- Data modules (geschuetzt) ---
|
||||||
from data_flights import router as flights_router, start_flight_collector
|
from data_flights import router as flights_router, start_flight_collector
|
||||||
from data_ships import router as ships_router, start_ais_collector
|
from data_ships import router as ships_router, start_ais_collector
|
||||||
from data_quakes import router as quakes_router
|
from data_quakes import router as quakes_router
|
||||||
from data_gdelt import router as gdelt_router
|
from data_gdelt import router as gdelt_router
|
||||||
|
|
||||||
app.include_router(flights_router, prefix="/api")
|
# Alle Daten-APIs hinter Auth
|
||||||
app.include_router(ships_router, prefix="/api")
|
app.include_router(flights_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
||||||
app.include_router(quakes_router, prefix="/api")
|
app.include_router(ships_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
||||||
app.include_router(gdelt_router, prefix="/api")
|
app.include_router(quakes_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
||||||
|
app.include_router(gdelt_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
# --- Static files ---
|
# --- Static files ---
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
static_dir = Path(__file__).parent.parent / "static"
|
||||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def login_page():
|
||||||
|
return FileResponse(str(static_dir / "login.html"))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index():
|
async def index():
|
||||||
return FileResponse(str(static_dir / "index.html"))
|
return FileResponse(str(static_dir / "index.html"))
|
||||||
|
|||||||
@@ -9,6 +9,23 @@
|
|||||||
<link rel="stylesheet" href="/static/css/globe.css">
|
<link rel="stylesheet" href="/static/css/globe.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script>
|
||||||
|
// Auth-Check: Ohne Token zum Login
|
||||||
|
if (!localStorage.getItem('globe_token')) { window.location.href = '/login'; }
|
||||||
|
// Auth-Header fuer alle Fetch-Calls
|
||||||
|
var _origFetch = window.fetch;
|
||||||
|
window.fetch = function(url, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
if (typeof url === 'string' && url.startsWith('/api/') && !url.includes('/auth/')) {
|
||||||
|
opts.headers = opts.headers || {};
|
||||||
|
opts.headers['Authorization'] = 'Bearer ' + localStorage.getItem('globe_token');
|
||||||
|
}
|
||||||
|
return _origFetch(url, opts).then(function(r) {
|
||||||
|
if (r.status === 401 || r.status === 403) { localStorage.removeItem('globe_token'); window.location.href = '/login'; }
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header id="header">
|
<header id="header">
|
||||||
<div class="header-brand">
|
<div class="header-brand">
|
||||||
@@ -30,13 +47,13 @@
|
|||||||
<h3 class="panel-title">LAYER</h3>
|
<h3 class="panel-title">LAYER</h3>
|
||||||
<div class="panel-section">
|
<div class="panel-section">
|
||||||
<label class="layer-toggle">
|
<label class="layer-toggle">
|
||||||
<input type="checkbox" id="layer-flights">
|
<input type="checkbox" id="layer-flights" checked>
|
||||||
<span class="layer-dot dot-flights"></span>
|
<span class="layer-dot dot-flights"></span>
|
||||||
<span class="layer-name">Flugverkehr</span>
|
<span class="layer-name">Flugverkehr</span>
|
||||||
<span class="layer-count" id="count-flights">-</span>
|
<span class="layer-count" id="count-flights">-</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="layer-toggle">
|
<label class="layer-toggle">
|
||||||
<input type="checkbox" id="layer-ships">
|
<input type="checkbox" id="layer-ships" checked>
|
||||||
<span class="layer-dot dot-ships"></span>
|
<span class="layer-dot dot-ships"></span>
|
||||||
<span class="layer-name">Schiffsverkehr</span>
|
<span class="layer-name">Schiffsverkehr</span>
|
||||||
<span class="layer-count" id="count-ships">-</span>
|
<span class="layer-count" id="count-ships">-</span>
|
||||||
@@ -57,7 +74,7 @@
|
|||||||
<div class="panel-divider"></div>
|
<div class="panel-divider"></div>
|
||||||
<div class="panel-section">
|
<div class="panel-section">
|
||||||
<label class="layer-toggle">
|
<label class="layer-toggle">
|
||||||
<input type="checkbox" id="layer-daynight">
|
<input type="checkbox" id="layer-daynight" checked>
|
||||||
<span class="layer-dot dot-daynight"></span>
|
<span class="layer-dot dot-daynight"></span>
|
||||||
<span class="layer-name">Tag/Nacht</span>
|
<span class="layer-name">Tag/Nacht</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
152
static/login.html
Normale Datei
152
static/login.html
Normale Datei
@@ -0,0 +1,152 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AegisSight Globe — Login</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #0b1121; --accent: #00ff88; --border: rgba(0,255,136,0.15); --text: #e8eaf0; --text-dim: rgba(255,255,255,0.5); --mono: 'JetBrains Mono','Courier New',monospace; }
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body { min-height:100vh; background:var(--bg); color:var(--text); font-family:var(--mono); display:flex; align-items:center; justify-content:center; }
|
||||||
|
.login-box { width:380px; background:rgba(11,17,33,0.95); border:1px solid var(--border); border-radius:12px; padding:40px 32px; box-shadow:0 16px 64px rgba(0,0,0,0.5); }
|
||||||
|
.logo { text-align:center; margin-bottom:32px; }
|
||||||
|
.logo svg { width:40px; height:40px; }
|
||||||
|
.logo h1 { font-size:14px; letter-spacing:3px; color:var(--accent); margin-top:12px; }
|
||||||
|
.logo p { font-size:11px; color:var(--text-dim); margin-top:4px; }
|
||||||
|
.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; }
|
||||||
|
input[type="email"], input[type="text"] {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
input:focus { border-color:var(--accent); }
|
||||||
|
.code-input { text-align:center; letter-spacing:8px; font-size:24px; font-weight:700; }
|
||||||
|
.btn {
|
||||||
|
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;
|
||||||
|
letter-spacing:1px; cursor:pointer; transition:all 0.2s;
|
||||||
|
}
|
||||||
|
.btn:hover { background:rgba(0,255,136,0.2); }
|
||||||
|
.btn:disabled { opacity:0.4; cursor:not-allowed; }
|
||||||
|
.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.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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="logo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="#00ff88" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/>
|
||||||
|
</svg>
|
||||||
|
<h1>AEGISSIGHT GLOBE</h1>
|
||||||
|
<p>Geospatial Intelligence Dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: E-Mail -->
|
||||||
|
<div id="step-email" class="step active">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>E-Mail-Adresse</label>
|
||||||
|
<input type="email" id="input-email" placeholder="name@beispiel.de" autofocus>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="btn-send" onclick="requestLink()">Zugangscode anfordern</button>
|
||||||
|
<div class="error" id="error-email"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Code -->
|
||||||
|
<div id="step-code" class="step">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>6-stelliger Zugangscode</label>
|
||||||
|
<input type="text" id="input-code" class="code-input" maxlength="6" placeholder="------" inputmode="numeric" pattern="[0-9]*">
|
||||||
|
</div>
|
||||||
|
<div class="success" id="success-code" style="display:block;margin-bottom:16px;">Zugangscode wurde per E-Mail gesendet.</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Check if already logged in
|
||||||
|
if (localStorage.getItem('globe_token')) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStep(step) {
|
||||||
|
document.querySelectorAll('.step').forEach(function(el) { el.classList.remove('active'); });
|
||||||
|
document.getElementById('step-' + step).classList.add('active');
|
||||||
|
document.querySelectorAll('.error').forEach(function(el) { el.style.display = 'none'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(id, msg) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestLink() {
|
||||||
|
var email = document.getElementById('input-email').value.trim();
|
||||||
|
if (!email) return;
|
||||||
|
var btn = document.getElementById('btn-send');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Sende...';
|
||||||
|
document.getElementById('error-email').style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/auth/request-link', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email }),
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
showError('error-email', data.detail || 'Fehler');
|
||||||
|
} else {
|
||||||
|
showStep('code');
|
||||||
|
document.getElementById('input-code').focus();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError('error-email', 'Verbindungsfehler');
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Zugangscode 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
|
||||||
|
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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren