diff --git a/app/i18n.py b/app/i18n.py new file mode 100644 index 0000000..0e7ea71 --- /dev/null +++ b/app/i18n.py @@ -0,0 +1,350 @@ +from typing import Dict + +AVAILABLE_LANGUAGES: Dict[str, str] = { + "de": "Deutsch", + "en": "English", +} + +translations: Dict[str, Dict[str, str]] = { + "de": { + "brand.tagline": "Deine gemieteten Server im Blick", + "nav.admin": "Admin-Dashboard", + "nav.users": "User", + "nav.overview": "Übersicht", + "nav.map": "Karte", + "nav.new_server": "Server anlegen", + "nav.logout": "Logout", + "nav.login": "Login", + "nav.register": "Registrieren", + "nav.logged_in_as": "Eingeloggt als", + "nav.console": "Konsole öffnen", + "footer.left": "Selfhosted VPS overview", + "footer.right": "PWA ready · Dark Mode", + "warning.no_encryption": "Hinweis: ENCRYPTION_KEY nicht gesetzt – Passwörter werden nicht gespeichert.", + "warning.no_encryption_short": "ENCRYPTION_KEY ist nicht gesetzt – eingegebene Management-Passwörter werden nicht gespeichert.", + "server_list.title": "Deine Server", + "server_list.subtitle": "Übersicht aller gemieteten VPS, dedizierten Server und Storage-Systeme.", + "stats.total": "Gesamt", + "stats.active": "aktive Server", + "stats.costs": "Laufende Kosten", + "stats.per_month": "pro Monat", + "stats.mixed_currencies": "(gemischte Währungen)", + "stats.expiring_soon": "Laufen bald aus", + "stats.expired": "Abgelaufen", + "stats.expiring_soon_hint": "≤ 30 Tage", + "stats.expired_hint": "Vertrag beendet", + "table.name": "Name", + "table.provider": "Provider", + "table.type": "Typ", + "table.location": "Location", + "table.ipv4": "IPv4", + "table.costs": "Kosten", + "table.action": "Aktion", + "status.expired": "abgelaufen", + "status.expiring": "läuft bald aus", + "server_list.empty": "Noch keine Server erfasst. Leg den ersten mit „Server anlegen“ oben rechts an.", + "price.month": "Monat", + "price.year": "Jahr", + "server_detail.server": "Server", + "server_detail.contract_end": "Vertragsende", + "server_detail.days_ago": "vor {days} Tagen", + "server_detail.today": "heute", + "server_detail.in_days": "in {days} Tagen", + "server_detail.edit": "Bearbeiten", + "server_detail.archive": "Archivieren", + "server_detail.archive_confirm": "Diesen Server archivieren?", + "section.net_costs": "Netz & Kosten", + "section.hardware": "Hardware", + "section.access": "Zugänge", + "section.notes": "Notizen", + "field.ipv4": "IPv4", + "field.ipv6": "IPv6", + "field.costs": "Kosten", + "field.contract": "Vertrag", + "field.cpu": "CPU", + "field.ram": "RAM", + "field.storage": "Storage", + "field.management": "Management", + "field.mgmt_user": "Mgmt-User", + "field.mgmt_password": "Mgmt-Passwort", + "field.ssh_user": "SSH User", + "field.ssh_key_hint": "SSH Key Hint", + "note.ssh_keys": "Hinweis: Es werden nur Key-Namen gespeichert, keine privaten SSH-Schlüssel.", + "notes.empty": "Keine Notizen.", + "mgmt.password_encrypted_missing": "verschlüsselt gespeichert (Key fehlt?)", + "back.overview": "Zurück zur Übersicht", + "updated_at": "Zuletzt aktualisiert:", + "form.title.new": "Neuen Server anlegen", + "form.title.edit": "Server bearbeiten", + "form.subtitle": "Trage alle relevanten Infos zu deinem VPS / Server ein.", + "section.general": "Allgemein", + "label.name": "Name", + "label.provider": "Provider", + "label.hostname": "Hostname", + "label.type": "Typ", + "label.location": "Location", + "label.tags": "Tags (kommagetrennt)", + "label.ipv4": "IPv4", + "label.ipv6": "IPv6", + "section.network": "Netzwerk", + "section.costs": "Kosten", + "label.amount": "Betrag", + "label.currency": "Währung", + "label.billing": "Abrechnung", + "label.contract_start": "Vertragsbeginn", + "label.contract_end": "Vertragsende", + "section.hardware.small": "Hardware", + "label.cpu_model": "CPU-Modell", + "label.cpu_cores": "CPU Cores", + "label.ram_mb": "RAM (MB)", + "label.storage_gb": "Storage (GB)", + "label.storage_type": "Storage-Typ", + "section.access.small": "Zugänge", + "hint.ssh": "SSH: hier nur Key-Namen oder Hints eintragen, keine privaten Keys.", + "label.mgmt_url": "Management URL", + "label.mgmt_user": "Management User", + "label.mgmt_password": "Management Passwort", + "label.ssh_user": "SSH User", + "label.ssh_key_hint": "SSH Key Hint", + "section.notes.small": "Notizen", + "btn.cancel": "Abbrechen", + "btn.save": "Speichern", + "btn.save_changes": "Änderungen speichern", + "map.title": "Server-Karte", + "map.subtitle": "Zeigt alle nicht archivierten Server mit gesetzter Location auf einer Karte. Für grobe Übersicht reicht die Stadt – keine exakten GPS-Daten notwendig.", + "map.note": "Hinweis: Marker werden anhand der Location-Namen grob auf der Weltkarte platziert. Mehrere Server in derselben Stadt werden leicht versetzt dargestellt, damit sie klickbar bleiben.", + "admin.title": "Admin-Dashboard", + "admin.subtitle": "Globale Übersicht über alle nicht archivierten Server und Benutzer.", + "admin.users": "Benutzer", + "admin.users_caption": "Accounts", + "admin.servers": "Server", + "admin.servers_caption": "nicht archiviert", + "admin.costs": "Laufende Kosten", + "admin.per_month": "pro Monat", + "admin.contract_status": "Vertragstatus", + "admin.expiring": "Bald auslaufend", + "admin.expired": "Abgelaufen", + "admin.by_provider": "Nach Provider", + "admin.provider": "Provider", + "admin.count": "Server", + "admin.monthly_costs": "Monatskosten", + "admin.expiring_soon": "Laufen bald aus", + "admin.expired_count": "Abgelaufen", + "admin.contracts_expiring": "Laufen bald aus (≤ 30 Tage)", + "admin.contracts_none_soon": "Keine Verträge laufen in den nächsten 30 Tagen aus.", + "admin.contracts_expired": "Abgelaufene Verträge", + "admin.contracts_none_expired": "Keine abgelaufenen Verträge gefunden.", + "users.title": "Benutzerverwaltung", + "users.subtitle": "Admins können Benutzer aktivieren/deaktivieren. Der eigene Account kann nicht deaktiviert werden.", + "users.username": "Benutzername", + "users.email": "E-Mail", + "users.role": "Rolle", + "users.status": "Status", + "users.action": "Aktion", + "role.admin": "Admin", + "role.user": "User", + "status.active": "aktiv", + "status.inactive": "deaktiviert", + "action.deactivate": "Deaktivieren", + "action.activate": "Aktivieren", + "users.own_account": "Eigener Account", + "login.title": "Login", + "login.subtitle": "Melde dich an, um deine Server zu verwalten.", + "login.username": "Benutzername", + "login.password": "Passwort", + "login.no_account": "Noch kein Account?", + "login.register": "Registrieren", + "login.submit": "Einloggen", + "register.title": "Registrieren", + "register.subtitle": "Erstelle einen neuen Account. Der erste Benutzer wird automatisch Admin.", + "register.username": "Benutzername", + "register.email": "E-Mail (optional)", + "register.password": "Passwort", + "register.password_confirm": "Passwort bestätigen", + "register.submit": "Account anlegen", + }, + "en": { + "brand.tagline": "Your rented servers at a glance", + "nav.admin": "Admin Dashboard", + "nav.users": "Users", + "nav.overview": "Overview", + "nav.map": "Map", + "nav.new_server": "Add server", + "nav.logout": "Logout", + "nav.login": "Login", + "nav.register": "Register", + "nav.logged_in_as": "Logged in as", + "nav.console": "Open console", + "footer.left": "Selfhosted VPS overview", + "footer.right": "PWA ready · Dark Mode", + "warning.no_encryption": "Note: ENCRYPTION_KEY not set – passwords will not be stored.", + "warning.no_encryption_short": "ENCRYPTION_KEY is not set – management passwords will not be stored.", + "server_list.title": "Your servers", + "server_list.subtitle": "Overview of all rented VPS, dedicated servers and storage systems.", + "stats.total": "Total", + "stats.active": "active servers", + "stats.costs": "Recurring costs", + "stats.per_month": "per month", + "stats.mixed_currencies": "(mixed currencies)", + "stats.expiring_soon": "Expiring soon", + "stats.expired": "Expired", + "stats.expiring_soon_hint": "≤ 30 days", + "stats.expired_hint": "Contract ended", + "table.name": "Name", + "table.provider": "Provider", + "table.type": "Type", + "table.location": "Location", + "table.ipv4": "IPv4", + "table.costs": "Costs", + "table.action": "Action", + "status.expired": "expired", + "status.expiring": "expiring soon", + "server_list.empty": "No servers yet. Create the first one via “Add server” in the top right.", + "price.month": "Month", + "price.year": "Year", + "server_detail.server": "Server", + "server_detail.contract_end": "Contract end", + "server_detail.days_ago": "{days} days ago", + "server_detail.today": "today", + "server_detail.in_days": "in {days} days", + "server_detail.edit": "Edit", + "server_detail.archive": "Archive", + "server_detail.archive_confirm": "Archive this server?", + "section.net_costs": "Network & Costs", + "section.hardware": "Hardware", + "section.access": "Access", + "section.notes": "Notes", + "field.ipv4": "IPv4", + "field.ipv6": "IPv6", + "field.costs": "Costs", + "field.contract": "Contract", + "field.cpu": "CPU", + "field.ram": "RAM", + "field.storage": "Storage", + "field.management": "Management", + "field.mgmt_user": "Mgmt user", + "field.mgmt_password": "Mgmt password", + "field.ssh_user": "SSH user", + "field.ssh_key_hint": "SSH key hint", + "note.ssh_keys": "Note: Only key names are stored, no private SSH keys.", + "notes.empty": "No notes.", + "mgmt.password_encrypted_missing": "stored encrypted (key missing?)", + "back.overview": "Back to overview", + "updated_at": "Last updated:", + "form.title.new": "Create new server", + "form.title.edit": "Edit server", + "form.subtitle": "Enter all relevant details about your VPS / server.", + "section.general": "General", + "label.name": "Name", + "label.provider": "Provider", + "label.hostname": "Hostname", + "label.type": "Type", + "label.location": "Location", + "label.tags": "Tags (comma-separated)", + "label.ipv4": "IPv4", + "label.ipv6": "IPv6", + "section.network": "Network", + "section.costs": "Costs", + "label.amount": "Amount", + "label.currency": "Currency", + "label.billing": "Billing", + "label.contract_start": "Contract start", + "label.contract_end": "Contract end", + "section.hardware.small": "Hardware", + "label.cpu_model": "CPU model", + "label.cpu_cores": "CPU cores", + "label.ram_mb": "RAM (MB)", + "label.storage_gb": "Storage (GB)", + "label.storage_type": "Storage type", + "section.access.small": "Access", + "hint.ssh": "SSH: only enter key names or hints, no private keys.", + "label.mgmt_url": "Management URL", + "label.mgmt_user": "Management user", + "label.mgmt_password": "Management password", + "label.ssh_user": "SSH user", + "label.ssh_key_hint": "SSH key hint", + "section.notes.small": "Notes", + "btn.cancel": "Cancel", + "btn.save": "Save", + "btn.save_changes": "Save changes", + "map.title": "Server map", + "map.subtitle": "Shows all non-archived servers with a location on a map. City names are sufficient for rough positioning.", + "map.note": "Note: Markers are placed roughly based on location names. Multiple servers in one city are slightly offset to remain clickable.", + "admin.title": "Admin dashboard", + "admin.subtitle": "Global overview of all non-archived servers and users.", + "admin.users": "Users", + "admin.users_caption": "Accounts", + "admin.servers": "Servers", + "admin.servers_caption": "not archived", + "admin.costs": "Recurring costs", + "admin.per_month": "per month", + "admin.contract_status": "Contract status", + "admin.expiring": "Expiring soon", + "admin.expired": "Expired", + "admin.by_provider": "By provider", + "admin.provider": "Provider", + "admin.count": "Servers", + "admin.monthly_costs": "Monthly costs", + "admin.expiring_soon": "Expiring soon", + "admin.expired_count": "Expired", + "admin.contracts_expiring": "Expiring soon (≤ 30 days)", + "admin.contracts_none_soon": "No contracts expiring in the next 30 days.", + "admin.contracts_expired": "Expired contracts", + "admin.contracts_none_expired": "No expired contracts found.", + "users.title": "User management", + "users.subtitle": "Admins can activate/deactivate users. You cannot deactivate your own account.", + "users.username": "Username", + "users.email": "Email", + "users.role": "Role", + "users.status": "Status", + "users.action": "Action", + "role.admin": "Admin", + "role.user": "User", + "status.active": "active", + "status.inactive": "deactivated", + "action.deactivate": "Deactivate", + "action.activate": "Activate", + "users.own_account": "Own account", + "login.title": "Login", + "login.subtitle": "Sign in to manage your servers.", + "login.username": "Username", + "login.password": "Password", + "login.no_account": "No account yet?", + "login.register": "Register", + "login.submit": "Log in", + "register.title": "Register", + "register.subtitle": "Create a new account. The first user becomes admin automatically.", + "register.username": "Username", + "register.email": "Email (optional)", + "register.password": "Password", + "register.password_confirm": "Confirm password", + "register.submit": "Create account", + }, +} + + +def resolve_locale(request) -> str: + """Detect locale from session override or Accept-Language; default to de.""" + session_lang = request.session.get("lang") + if session_lang in AVAILABLE_LANGUAGES: + return session_lang + + accept = request.headers.get("accept-language", "") + for part in accept.split(","): + code = part.split(";")[0].strip().lower() + if code.startswith("de"): + return "de" + if code.startswith("en"): + return "en" + return "de" + + +def translate(key: str, locale: str = "de", **kwargs) -> str: + """Return translated string for a key.""" + text = translations.get(locale, {}).get( + key, translations["en"].get(key, key) + ) + try: + return text.format(**kwargs) + except Exception: + return text diff --git a/app/main.py b/app/main.py index 6ecb272..1708921 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,9 @@ from .utils import ( ensure_csrf_token, validate_csrf, ) +from jinja2 import pass_context + +from .i18n import AVAILABLE_LANGUAGES, resolve_locale, translate from .auth import ( hash_password, verify_password, @@ -30,6 +33,7 @@ app = FastAPI(title="FleetLedger") app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") +templates.env.globals["languages"] = AVAILABLE_LANGUAGES # Session middleware (server-side session based on signed cookie) SESSION_SECRET = os.getenv("SESSION_SECRET") @@ -50,6 +54,23 @@ app.add_middleware( ) +@app.middleware("http") +async def add_locale_to_request(request: Request, call_next): + request.state.locale = resolve_locale(request) + response = await call_next(request) + return response + + +@pass_context +def _t(ctx, key: str, **kwargs) -> str: + request = ctx.get("request") + locale = getattr(request.state, "locale", "de") if request else "de" + return translate(key, locale, **kwargs) + + +templates.env.globals["t"] = _t + + @app.on_event("startup") def on_startup() -> None: """Initialize database on startup.""" @@ -74,6 +95,17 @@ def service_worker() -> FileResponse: ) +@app.get("/lang/{code}", include_in_schema=False) +def switch_language(code: str, request: Request): + """Persist preferred language in session and redirect back.""" + code = code.lower() + if code not in AVAILABLE_LANGUAGES: + return RedirectResponse("/", status_code=303) + request.session["lang"] = code + referer = request.headers.get("referer") or "/" + return RedirectResponse(referer, status_code=303) + + # ------------- Auth: Register / Login / Logout ------------- diff --git a/app/templates/admin_dashboard.html b/app/templates/admin_dashboard.html index 18394db..0e19340 100644 --- a/app/templates/admin_dashboard.html +++ b/app/templates/admin_dashboard.html @@ -3,9 +3,9 @@
- Globale Übersicht über alle nicht archivierten Server und Benutzer. + {{ t("admin.subtitle") }}
| Provider | -Server | -Monatskosten | -Laufen bald aus | -Abgelaufen | +{{ t("admin.provider") }} | +{{ t("admin.count") }} | +{{ t("admin.monthly_costs") }} | +{{ t("admin.expiring_soon") }} | +{{ t("admin.expired_count") }} |
|---|