diff --git a/.env.example b/.env.example index 1b83f133e..d6bc74d08 100644 --- a/.env.example +++ b/.env.example @@ -46,7 +46,7 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./app.db # shipped one of the named locales. Like every SM_ variable it takes precedence # over the value edited at /admin/settings, so leave it commented unless you # mean to pin the list for this process. -# SM_I18N_SUPPORTED_LOCALES=["en","es"] +# SM_I18N_SUPPORTED_LOCALES=["en","de","es"] # SM_I18N_DEFAULT_LOCALE=en # ── Process identity ──────────────────────────────────────────────────── diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7371ca064..f020b4b4c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -45,6 +45,7 @@ jobs: # Package metadata, READMEs, release matrix, and the hand-maintained ty # extra-paths / pytest testpaths lists (#520) must match what is on disk. - run: make ci-check-metadata + - run: make ci-check-locales # Backticked repo paths in docs/, CLAUDE.md, README.md and skills must exist (#519). - run: make ci-doc-paths # Fixed sleeps in tests are timing bets; poll with simple_module_test.wait_for. diff --git a/CHANGELOG.md b/CHANGELOG.md index b9807e03a..14631c448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ All notable changes to this project are documented in this file. The format is b - `host/client_app` no longer re-declares `@simple-module-py/ui`'s runtime dependencies, nor `@base-ui/react`, `@radix-ui/react-slot`, `autoprefixer` or `postcss` (nothing loaded them) (#521). ### Added +- German catalogues for all framework and module screens, available by adding `de` to `i18n_supported_locales`, + with enforced key and placeholder parity in CI (#492). - `make ci-doc-paths` (`scripts/check_doc_paths.py`, part of `make lint` and the `python-lint` job) fails when a backticked repo-rooted path in `docs/`, `CLAUDE.md`, `README.md` or `skills/**/SKILL.md` does not exist; tutorial diff --git a/Makefile b/Makefile index 2920ce56b..69eacca7e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated ci-check-metadata ci-doc-paths ci-docs-build ci-i18n-fresh ci-css-build ci-doctor ci-build-packages worker beat worker-docker +.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated ci-check-metadata ci-check-locales ci-doc-paths ci-docs-build ci-i18n-fresh ci-css-build ci-doctor ci-build-packages worker beat worker-docker # UV_SYNC_FLAGS is empty locally; CI passes --locked so a stale uv.lock fails the build. UV_SYNC_FLAGS ?= @@ -124,7 +124,7 @@ loadtest: ## Run locust against a server already on $(LOCUST_H loadtest-memray: ## Start uvicorn under memray, load-test, emit flamegraph scripts/loadtest_memray.sh $(LOCUST_ARGS) -lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-metadata ci-doc-paths ci-i18n-fresh ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated +lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-metadata ci-check-locales ci-doc-paths ci-i18n-fresh ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated # Kept granular so pr.yml can run them in parallel. ci-python-lint: @@ -154,6 +154,9 @@ ci-js-typecheck: gen-pages npx tsc --noEmit -p "$$cfg" || exit 1; \ done +ci-check-locales: + uv run python scripts/check_locales.py + # Package metadata, release matrix, ty extra-paths / pytest testpaths (#520), and READMEs. ci-check-metadata: uv run python scripts/check_metadata.py diff --git a/docs/framework/i18n.md b/docs/framework/i18n.md index 73fc691f2..5bf07f0f5 100644 --- a/docs/framework/i18n.md +++ b/docs/framework/i18n.md @@ -2,6 +2,11 @@ Translations live in each module as JSON files under `/locales/.json`. They're collected into a single `I18nRegistry` at boot and surfaced to React via the Inertia shared props. +The framework ships English (`en`) and German (`de`) catalogues. Only `en` is +enabled by default; add `de` to `i18n_supported_locales` (Settings screen or +`SM_I18N_SUPPORTED_LOCALES`) to offer German. Both have full key and placeholder parity enforced by `make ci-check-locales`. Other +languages are community-contributed; their differences are reported, not enforced. + ## Declaring locale files ```text diff --git a/framework/core/simple_module_core/feature_flags.py b/framework/core/simple_module_core/feature_flags.py index 9c6995c8b..da8befab8 100644 --- a/framework/core/simple_module_core/feature_flags.py +++ b/framework/core/simple_module_core/feature_flags.py @@ -34,6 +34,8 @@ class FeatureFlagDefinition: name: str description: str = "" default_enabled: bool = False + description_key: str = "" + """Catalog key for ``description``; empty or unresolved keeps the literal.""" class FeatureFlagRegistry: diff --git a/framework/core/simple_module_core/permissions.py b/framework/core/simple_module_core/permissions.py index 62c0363d1..4e079618b 100644 --- a/framework/core/simple_module_core/permissions.py +++ b/framework/core/simple_module_core/permissions.py @@ -85,6 +85,7 @@ class PermissionGroup: name: str permissions: list[str] = field(default_factory=list) + label_key: str = "" # catalog key for ``name``; empty or unresolved keeps the name class PermissionRegistry: @@ -174,12 +175,16 @@ def source_labels(self, name: str) -> dict[str, str]: """Human labels supplied by source *name*, keyed by permission string.""" return dict(self._source_output(name)[1]) if name in self._sources else {} - def add_group(self, name: str, permissions: list[str]) -> None: - """Register a group of related permissions.""" + def add_group(self, name: str, permissions: list[str], *, label_key: str = "") -> None: + """Register a group; ``label_key`` translates ``name`` per request (like menus).""" if name in self._groups: self._groups[name].permissions.extend(permissions) + if label_key: + self._groups[name].label_key = label_key else: - self._groups[name] = PermissionGroup(name=name, permissions=list(permissions)) + self._groups[name] = PermissionGroup( + name=name, permissions=list(permissions), label_key=label_key + ) self._invalidate() def add(self, permission: str) -> None: @@ -212,7 +217,7 @@ def all_permissions(self) -> list[str]: def groups(self) -> list[PermissionGroup]: """Static groups plus one group per source (merged by name).""" merged = { - n: PermissionGroup(name=n, permissions=list(g.permissions)) + n: PermissionGroup(name=n, permissions=list(g.permissions), label_key=g.label_key) for n, g in self._groups.items() } for name in self._sources: diff --git a/framework/hosting/simple_module_hosting/locales/de.json b/framework/hosting/simple_module_hosting/locales/de.json new file mode 100644 index 000000000..177b8a45e --- /dev/null +++ b/framework/hosting/simple_module_hosting/locales/de.json @@ -0,0 +1,27 @@ +{ + "setup": { + "title": "Installation einrichten", + "subtitle": "Noch ein paar Schritte, bevor diese Anwendung einsatzbereit ist.", + "connections": { + "heading": "Verbindungen", + "description": "Die für diese Installation benötigten Dienste werden geprüft.", + "retest": "Erneut testen", + "testing": "Wird getestet…" + }, + "migrations": { + "apply": "Migrationen anwenden" + }, + "steps": { + "heading": "Einrichtungsschritte", + "migrations": { + "title": "Datenbankmigrationen anwenden", + "description": "Datenbankschema auf die von diesem Code erwartete Version aktualisieren." + } + }, + "working": "In Bearbeitung…", + "done": "Fertig. Wird neu geladen…" + }, + "errors": { + "token_not_allowed": "API-Tokens können auf dieser Route nicht verwendet werden. Verwenden Sie eine angemeldete Sitzung oder eine Route, deren Berechtigung zum Geltungsbereich des Tokens gehört." + } +} diff --git a/framework/hosting/simple_module_hosting/ui_locales/de.json b/framework/hosting/simple_module_hosting/ui_locales/de.json new file mode 100644 index 000000000..db3a69fa4 --- /dev/null +++ b/framework/hosting/simple_module_hosting/ui_locales/de.json @@ -0,0 +1,110 @@ +{ + "errors": { + "generic_title": "Etwas ist schiefgelaufen", + "generic_description": "Auf der Seite ist ein unerwarteter Fehler aufgetreten. Laden Sie sie neu oder kehren Sie zur Startseite zurück.", + "reload_button": "Seite neu laden", + "go_home_button": "Zur Startseite" + }, + "nav": { + "admin": "Administration" + }, + "switcher": { + "label": "Sprache ändern", + "single_locale": "Nur {locale} ist aktiviert" + }, + "topbar": { + "log_out": "Abmelden" + }, + "relative_time": { + "unknown": "unbekannt", + "just_now": "gerade eben", + "seconds_ago": "vor {count} Sek.", + "minutes_ago": "vor {count} Min.", + "hours_ago": "vor {count} Std.", + "days_ago": "vor {count} T.", + "months_ago": "vor {count} Mon.", + "months_ago_one": "vor {count} Mon.", + "months_ago_other": "vor {count} Mon.", + "years_ago": "vor {count} J.", + "years_ago_one": "vor {count} J.", + "years_ago_other": "vor {count} J.", + "in_minutes": "in {count} Min.", + "in_hours": "in {count} Std.", + "in_days": "in {count} T.", + "expired": "abgelaufen" + }, + "nav_groups": { + "access": "Zugriff", + "appearance": "Darstellung", + "content": "Inhalt", + "system": "System", + "navigation": "Navigation", + "account": "Konto" + }, + "command_palette": { + "trigger": "Suchen", + "title": "Suchen", + "description": "Wechseln Sie zu einer Seite, auf die Sie Zugriff haben.", + "placeholder": "Wechseln zu…", + "empty": "Keine Treffer." + }, + "admin": { + "panel_badge": "Administrationsbereich", + "back_to_app": "Zurück zur App" + }, + "sidebar": { + "open": "Seitenleiste öffnen", + "close": "Seitenleiste schließen", + "drawer": "Navigationsmenü", + "back": "Zurück", + "skip_to_content": "Zum Inhalt springen" + }, + "public_nav": { + "docs": "Dokumentation", + "modules": "Module", + "github": "GitHub", + "open_dashboard": "Dashboard öffnen", + "sign_in": "Anmelden", + "open_menu": "Menü öffnen", + "close_menu": "Menü schließen" + }, + "demo": { + "banner_read_only": "Demomodus — Sie sind mit einem gemeinsam genutzten Konto angemeldet. Änderungen werden nicht gespeichert.", + "banner_writable": "Demomodus — Sie sind mit einem Konto angemeldet, das alle Besucher dieser Demo gemeinsam nutzen." + }, + "error_page": { + "correlation_id_copy": "Referenz-ID kopieren", + "forbidden_description": "Ihrer Rolle fehlt die für diese Seite benötigte Berechtigung. Bitten Sie einen Administrator, sie zu erteilen.", + "forbidden_permission": "Ihrer Rolle fehlt {permission}. Bitten Sie einen Administrator, diese Berechtigung zu erteilen.", + "forbidden_title": "Kein Zugriff", + "generic_description": "Ein unerwarteter Fehler ist aufgetreten.", + "generic_title": "Fehler", + "go_back": "Zurück", + "go_home": "Zur Startseite", + "invalid_request_description": "Die aufgerufene Adresse enthält Parameter, die nicht gelesen werden konnten.", + "invalid_request_title": "Ungültige Anfrage", + "maintenance_description": "Wir nehmen Änderungen vor und sind bald wieder da. Vielen Dank für Ihre Geduld.", + "maintenance_title": "Wartungsarbeiten", + "not_found_description": "Diese Seite oder dieser Datensatz existiert nicht. Möglicherweise wurde er gelöscht.", + "not_found_title": "Nicht gefunden", + "payload_too_large_description": "Die gesendeten Daten überschreiten die vom Server erlaubte Größe. Senden Sie weniger Daten und versuchen Sie es erneut.", + "payload_too_large_title": "Anfrage zu groß", + "rate_limited_description": "Sie haben in kurzer Zeit viele Anfragen gestellt. Warten Sie einen Moment und versuchen Sie es erneut.", + "rate_limited_title": "Zu viele Anfragen", + "retry": "Erneut versuchen", + "server_error_description": "Auf dem Server ist ein Fehler aufgetreten. Geben Sie diese ID an, wenn Sie ihn melden.", + "server_error_title": "Ein Fehler ist aufgetreten", + "session_expired_description": "Sie wurden wegen Inaktivität abgemeldet. Melden Sie sich erneut an, um fortzufahren.", + "session_expired_title": "Sitzung abgelaufen", + "sign_in": "Anmelden", + "unauthorized_description": "Sie müssen angemeldet sein, um diese Seite aufzurufen.", + "unauthorized_title": "Anmeldung erforderlich", + "unavailable_description": "Der Dienst ist vorübergehend nicht verfügbar. Bitte versuchen Sie es in Kürze erneut.", + "unavailable_title": "Dienst nicht verfügbar" + }, + "pagination": { + "showing": "{from}–{to} von {total}", + "previous": "Zurück", + "next": "Weiter" + } +} diff --git a/host/locales/de.json b/host/locales/de.json new file mode 100644 index 000000000..09b2dbbc1 --- /dev/null +++ b/host/locales/de.json @@ -0,0 +1,55 @@ +{ + "admin": { + "title": "Administration", + "description": "Benutzer, Zugriff, Darstellung und Systemeinstellungen verwalten.", + "empty": "Für Ihr Konto sind keine Administrationswerkzeuge verfügbar.", + "overview": "Übersicht" + }, + "landing": { + "badge": "✦ FastAPI + Inertia als Komplettpaket", + "hero_title_line1": "Modulare Python-Apps,", + "hero_title_line2": "aus einem Baukasten", + "hero_subtitle": "Jede Funktion ist ein eigenständiges Modul mit eigenen Routen, Migrationen, Berechtigungen und Inertia-Ansichten. Fügen Sie es hinzu, und der Host bindet es beim Start ein.", + "cta_dashboard": "Dashboard öffnen", + "cta_get_started": "Projekt erstellen", + "cta_docs": "Dokumentation lesen", + "features": { + "module_system_title": "Modulsystem", + "module_system_description": "Legen Sie ein Paket in modules/ ab. Seine URLs, Migrationen und Menüeinträge werden automatisch registriert.", + "auth_title": "Authentifizierung inklusive", + "auth_description": "E-Mail- und Cookie-Sitzungen, Einladungen, Passwortzurücksetzung und optionales Keycloak SSO sind sofort verfügbar.", + "schema_title": "Schema zuerst", + "schema_description": "Jedes Modul deklariert seine Modelle, Routen und Berechtigungen zentral. Der Host liest diese Deklaration beim Start.", + "inertia_title": "Inertia-Ansichten", + "inertia_description": "React-Seiten direkt aus Python-Views. Keine separate API-Schicht, die synchron gehalten werden muss.", + "diagnostics_title": "Diagnose", + "diagnostics_description": "Jedes Modul kann Zustandsprüfungen registrieren. make doctor und die Doctor-Seite zeigen alle Ergebnisse.", + "devtools_title": "Entwicklungswerkzeuge", + "devtools_description": "make new-module erstellt ein funktionsfähiges Modul mit Tests und Seitengerüst." + }, + "head_title": "Willkommen", + "copy_command": "Befehl kopieren", + "command_copied": "Kopiert", + "how_it_works_eyebrow": "So funktioniert es", + "how_it_works_heading": "Ein Prozess · viele Module · alles verbunden.", + "quickstart_eyebrow": "Schnellstart", + "quickstart_heading": "Funktionsfähige App in fünf Befehlen.", + "quickstart_body_prefix": "Starten Sie auf", + "quickstart_body_suffix": "mit bereits eingebundenen Benutzern, Dashboard und Berechtigungen. Melden Sie sich mit dem bei der Einrichtung erstellten Administratorkonto an.", + "module_users_description": "E-Mail- und Cookie-Sitzungen über fastapi-users", + "module_dashboard_description": "Startseite für angemeldete Benutzer mit Modulkacheln", + "module_permissions_description": "Berechtigungsregister pro Modul", + "cta_heading": "Läuft die App bereits?", + "cta_github": "GitHub →", + "copy_label": "Kopieren", + "terminal_helper": "Für den lokalen Betrieb ist kein Konto nötig. Die Anmeldung wird nur für die gehostete Administrationsoberfläche benötigt.", + "cta_body": "Öffnen Sie die Administrationsoberfläche, um Benutzer, Module und Hintergrundaufgaben zu verwalten.", + "cta_sign_in": "An der Administrationsoberfläche anmelden", + "copied_label": "✓ Kopiert" + }, + "offline": { + "title": "Sie sind offline", + "description": "Änderungen werden möglicherweise erst gespeichert, wenn die Verbindung wiederhergestellt ist.", + "restored": "Wieder online" + } +} diff --git a/modules/api_tokens/api_tokens/locales/de.json b/modules/api_tokens/api_tokens/locales/de.json new file mode 100644 index 000000000..494a3c71f --- /dev/null +++ b/modules/api_tokens/api_tokens/locales/de.json @@ -0,0 +1,105 @@ +{ + "nav": { + "api_tokens": "API-Tokens" + }, + "audit": { + "token": "API-Token" + }, + "errors": { + "session_required": "API-Tokens können nur in einer angemeldeten Browsersitzung verwaltet werden, nicht mit einem Token.", + "scopes_required": "Wählen Sie mindestens einen Geltungsbereich.", + "scope_not_held": "Sie können den Geltungsbereich „{value}“ nicht vergeben, da Sie ihn selbst nicht besitzen.", + "role_forbidden": "Ein Dienstschlüssel kann nicht die Rolle „{role}“ haben.", + "role_not_assignable": "Die Rolle „{value}“ kann keinem Dienstschlüssel zugewiesen werden.", + "lifetime_too_long": "Ein Token darf höchstens {days} Tage gültig sein.", + "invalid_kind": "Dieser Tokentyp kann hier nicht erstellt werden.", + "tenant_required": "Wählen Sie eine Organisation, bevor Sie deren Tokens verwalten.", + "not_found": "Token nicht gefunden.", + "account_required": "Für Ihr Konto gibt es keinen lokalen Benutzerdatensatz. Ein persönliches Zugriffstoken kann Sie daher nicht anmelden. Bitten Sie einen Administrator um einen Dienstschlüssel." + }, + "index": { + "title": "API-Tokens", + "description": "Mit persönlichen Zugriffstokens können Skripte und Werkzeuge die API in Ihrem Namen aufrufen, beschränkt auf die gewählten Geltungsbereiche.", + "usage": "Senden Sie ein Token im Authorization-Header: Authorization: Bearer ." + }, + "admin": { + "title": "API-Tokens", + "description": "Alle persönlichen Zugriffstokens und Dienstschlüssel dieser Organisation.", + "tenant_required": "Wählen Sie eine Organisation, um deren API-Tokens anzuzeigen und zu erstellen.", + "new_service_key": "Neuer Dienstschlüssel" + }, + "list": { + "name": "Name", + "token": "Token", + "kind": "Art", + "owner": "Inhaber", + "scopes": "Geltungsbereiche", + "roles": "Rollen", + "expires": "Läuft ab", + "last_used": "Zuletzt verwendet", + "status": "Status", + "actions": "Aktionen", + "never": "Nie", + "revoke": "Widerrufen", + "empty_title": "Noch keine API-Tokens", + "empty_description": "Erstellen Sie ein Token, um die API aus einem Skript oder einem anderen Dienst aufzurufen.", + "scope_count_one": "{count} Geltungsbereich", + "scope_count_other": "{count} Geltungsbereiche" + }, + "kind": { + "personal": "Persönlich", + "service": "Dienstschlüssel" + }, + "status": { + "active": "Aktiv", + "revoked": "Widerrufen", + "expired": "Abgelaufen" + }, + "form": { + "new_token": "Neues Token", + "create_title": "Persönliches Zugriffstoken erstellen", + "create_service_title": "Dienstschlüssel erstellen", + "create_description": "Das Token kann nur ausführen, was seine Geltungsbereiche erlauben, und niemals mehr als Sie selbst.", + "create_service_description": "Ein Dienstschlüssel handelt für keine Person. Er erhält die gewählten Rollen, beschränkt auf die gewählten Geltungsbereiche.", + "name": "Name", + "name_placeholder": "z. B. CI-Deployment", + "scopes": "Geltungsbereiche", + "scopes_hint": "Es werden nur Berechtigungen angezeigt, die Sie besitzen.", + "roles": "Rollen", + "roles_hint": "Die Administratorrolle kann keinem Dienstschlüssel zugewiesen werden.", + "no_roles": "Keine zuweisbaren Rollen.", + "expiry": "Läuft ab nach", + "days_one": "{count} Tag", + "days_other": "{count} Tage", + "submit": "Token erstellen", + "cancel": "Abbrechen" + }, + "created": { + "title": "Neues Token kopieren", + "warning": "Das Token wird nur jetzt angezeigt. Bewahren Sie es sicher auf — es kann nicht wiederhergestellt werden.", + "copy": "Kopieren", + "copied": "Kopiert", + "done": "Fertig" + }, + "revoke": { + "title": "„{name}“ widerrufen?", + "description": "Alles, was dieses Token verwendet, funktioniert ab der nächsten Anfrage nicht mehr. Dies kann nicht rückgängig gemacht werden.", + "confirm": "Widerrufen", + "cancel": "Abbrechen" + }, + "toasts": { + "created": "Token erstellt", + "revoked": "Token widerrufen", + "failed": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", + "revoked_all_one": "{count} Token widerrufen", + "revoked_all_other": "{count} Tokens widerrufen" + }, + "revoke_all": { + "button": "Alle widerrufen", + "title": "Alle Ihre Tokens widerrufen?", + "description": "Alle Ihre persönlichen Zugriffstokens verlieren ab der nächsten Anfrage ihre Gültigkeit, in jeder Organisation. Nutzen Sie dies bei einem möglichen Token-Leck: Passwortänderungen und das Abmelden aller Sitzungen beenden keine Tokens.", + "confirm": "Alle widerrufen", + "cancel": "Abbrechen" + }, + "permission_group": "API-Tokens" +} diff --git a/modules/api_tokens/api_tokens/locales/en.json b/modules/api_tokens/api_tokens/locales/en.json index 72073363e..27e2147d0 100644 --- a/modules/api_tokens/api_tokens/locales/en.json +++ b/modules/api_tokens/api_tokens/locales/en.json @@ -100,5 +100,6 @@ "description": "Every personal access token you hold stops working on its next request, in every organisation. Use this if a token may have leaked: changing your password or signing out everywhere does not end tokens.", "confirm": "Revoke all", "cancel": "Cancel" - } + }, + "permission_group": "API tokens" } diff --git a/modules/api_tokens/api_tokens/module.py b/modules/api_tokens/api_tokens/module.py index 8bc6bb620..a0d8daf40 100644 --- a/modules/api_tokens/api_tokens/module.py +++ b/modules/api_tokens/api_tokens/module.py @@ -87,7 +87,11 @@ def register_menu_items(self, registry: MenuRegistry) -> None: ) def register_permissions(self, registry: PermissionRegistry) -> None: - registry.add_group(c.PERM_GROUP, [c.PERM_USE, c.PERM_MANAGE]) + registry.add_group( + c.PERM_GROUP, + [c.PERM_USE, c.PERM_MANAGE], + label_key=f"{c.LOCALE_NAMESPACE}.permission_group", + ) registry.map_role(c.USER_ROLE, [c.PERM_USE]) for role in TenantRole: registry.map_role(tenant_role(role), [c.PERM_USE]) diff --git a/modules/audit_log/audit_log/locales/de.json b/modules/audit_log/audit_log/locales/de.json new file mode 100644 index 000000000..afcff861f --- /dev/null +++ b/modules/audit_log/audit_log/locales/de.json @@ -0,0 +1,64 @@ +{ + "browse": { + "title": "Änderungsprotokoll", + "description": "Änderungsverlauf auf Feldebene über alle Module", + "export_csv": "CSV exportieren", + "empty_title": "Keine Protokolleinträge", + "empty_description": "Änderungen an Objekten erscheinen hier automatisch.", + "no_match_title": "Keine Einträge für diese Filter", + "no_match_description": "Außerhalb der aktuellen Filter sind Einträge vorhanden.", + "clear_filters": "Filter zurücksetzen" + }, + "filters": { + "entity_type_label": "Objekttyp", + "entity_type_all": "Alle Typen", + "action_label": "Aktion", + "action_all": "Alle Aktionen", + "user_label": "Akteur", + "user_placeholder": "Alle Personen", + "date_range_label": "Zeitraum", + "date_range_any": "Beliebiges Datum", + "date_range_reset": "Datum zurücksetzen", + "apply": "Anwenden", + "clear": "Zurücksetzen", + "tenant_label": "Organisation", + "tenant_all": "Alle Organisationen", + "tenant_platform": "Plattform" + }, + "table": { + "timestamp": "Zeit", + "action": "Aktion", + "entity": "Objekt", + "user": "Akteur", + "changes": "Änderungen", + "tenant": "Organisation" + }, + "actions": { + "created": "erstellt", + "updated": "aktualisiert", + "deleted": "gelöscht", + "soft_deleted": "archiviert" + }, + "correlation": { + "view_related": "Zugehörige Einträge", + "view_related_title": "Alle Einträge derselben Anfrage anzeigen", + "banner_title_one": "Eine Aktion, {count} Eintrag", + "banner_title_other": "Eine Aktion, {count} Einträge", + "banner_description": "Alle Aufzeichnungen einer einzelnen Anfrage.", + "banner_clear": "Alle Einträge anzeigen" + }, + "changes": { + "fields_set": "{count} Felder gesetzt", + "show_more": "+{count} weitere Felder", + "show_less": "Weniger anzeigen", + "system_user": "System", + "no_changes": "keine Änderungen aufgezeichnet", + "unresolved_user": "Kein passendes Konto für diese ID", + "fields_set_one": "{count} Feld gesetzt", + "fields_set_other": "{count} Felder gesetzt" + }, + "nav": { + "audit_log": "Änderungsprotokoll" + }, + "permission_group": "Audit-Log" +} diff --git a/modules/audit_log/audit_log/locales/en.json b/modules/audit_log/audit_log/locales/en.json index 1f5d7cff7..de8a422ae 100644 --- a/modules/audit_log/audit_log/locales/en.json +++ b/modules/audit_log/audit_log/locales/en.json @@ -59,5 +59,6 @@ }, "nav": { "audit_log": "Audit log" - } + }, + "permission_group": "Audit log" } diff --git a/modules/audit_log/audit_log/module.py b/modules/audit_log/audit_log/module.py index d61c8785c..f3cef5700 100644 --- a/modules/audit_log/audit_log/module.py +++ b/modules/audit_log/audit_log/module.py @@ -58,7 +58,11 @@ def register_menu_items(self, registry: MenuRegistry) -> None: ) def register_permissions(self, registry: PermissionRegistry) -> None: - registry.add_group(PERM_GROUP, list(ALL_PERMISSIONS)) + registry.add_group( + PERM_GROUP, + list(ALL_PERMISSIONS), + label_key=f"{LOCALE_NAMESPACE}.permission_group", + ) async def on_startup(self, app: FastAPI) -> None: from simple_module_core.activity_feed import set_activity_feed diff --git a/modules/auth/auth/locales/de.json b/modules/auth/auth/locales/de.json new file mode 100644 index 000000000..dabacc3e4 --- /dev/null +++ b/modules/auth/auth/locales/de.json @@ -0,0 +1,6 @@ +{ + "errors": { + "not_authenticated": "Nicht authentifiziert", + "missing_permission": "Erforderliche Berechtigung fehlt: {permissions}" + } +} diff --git a/modules/background_tasks/background_tasks/locales/de.json b/modules/background_tasks/background_tasks/locales/de.json new file mode 100644 index 000000000..1e15d01f1 --- /dev/null +++ b/modules/background_tasks/background_tasks/locales/de.json @@ -0,0 +1,195 @@ +{ + "index": { + "title": "Hintergrundaufgaben", + "description": "Ausführungen überwachen und fehlgeschlagene oder blockierte Aufgaben erneut starten", + "search_placeholder": "Nach Aufgabenname suchen…", + "workers_button": "Worker", + "retry_all_button": "Alle fehlgeschlagenen erneut starten", + "outbox_button": "Ausgang" + }, + "strip": { + "queued": "Eingereiht", + "running": "In Ausführung", + "succeeded": "Erfolgreich", + "failed": "Fehlgeschlagen", + "stuck": "Blockiert", + "window_all": "Gesamter Zeitraum", + "window_24h": "Letzte 24 Std." + }, + "filters": { + "status_label": "Nach Status filtern", + "all": "Alle", + "failed": "Fehlgeschlagen", + "running": "In Ausführung", + "stuck": "Blockiert", + "queue_label": "Warteschlange:", + "queue_all": "alle", + "queue_aria": "Nach Warteschlange filtern", + "tenant_label": "Organisation:", + "tenant_all": "alle", + "tenant_platform": "Plattform", + "tenant_aria": "Nach Organisation filtern" + }, + "status": { + "queued": "eingereiht", + "running": "in Ausführung", + "success": "erfolgreich", + "failed": "fehlgeschlagen", + "stuck": "blockiert", + "revoked": "widerrufen", + "retrying": "wird erneut versucht" + }, + "table": { + "task": "Aufgabe", + "status": "Status", + "queue": "Warteschlange", + "queued_at": "Eingereiht", + "duration": "Dauer", + "actions": "Aktionen", + "retry": "Erneut versuchen", + "tenant": "Organisation" + }, + "detail": { + "head_title": "Aufgabendetails", + "description": "Ausführung {id} · Versuch {attempt} von {max}", + "attempt": "Versuch {attempt} von {max}", + "back_button": "Zurück zu den Ausführungen", + "retry_button": "Aufgabe erneut starten", + "meta": "Details", + "queue": "Warteschlange", + "worker": "Worker", + "celery_id": "Celery-ID", + "queued_at": "Eingereiht am", + "started_at": "Gestartet am", + "finished_at": "Beendet am", + "duration": "Dauer", + "heartbeat": "Lebenszeichen", + "retried_from": "Wiederholung von", + "exception": "Ausnahme", + "args": "Argumente", + "kwargs": "Schlüsselwortargumente", + "result": "Ergebnis", + "traceback": "Traceback", + "copy": "Kopieren", + "copied": "Kopiert", + "no_traceback": "Kein Traceback aufgezeichnet.", + "tenant": "Organisation" + }, + "retry_dialog": { + "title": "{name} erneut starten?", + "description": "Eine neue Ausführung wird mit denselben Argumenten eingereiht.", + "retried_before_one": "Diese Aufgabe wurde bereits einmal erneut gestartet.", + "retried_before_other": "Diese Aufgabe wurde bereits {count} Mal erneut gestartet.", + "args_label": "args", + "kwargs_label": "kwargs", + "no_args": "Diese Aufgabe benötigt keine Argumente.", + "cancel": "Abbrechen", + "confirm": "Wiederholung einreihen" + }, + "retry_all_dialog": { + "title": "Alle fehlgeschlagenen Aufgaben erneut starten?", + "description": "Alle fehlgeschlagenen oder blockierten Ausführungen der aktuellen Ansicht werden mit ihren ursprünglichen Argumenten erneut eingereiht. Einträge mit bereits laufender Wiederholung werden übersprungen.", + "scope_all_queues": "Alle Warteschlangen", + "scope_queue": "Warteschlange {queue}", + "scope_all_statuses": "Fehlgeschlagen und blockiert", + "cancel": "Abbrechen", + "confirm": "Wiederholungen einreihen", + "scope_search": "Suche „{query}“", + "scope_no_search": "Beliebiger Aufgabenname" + }, + "toasts": { + "retried": "Aufgabe „{name}“ erneut eingereiht", + "retry_failed": "Aufgabe konnte nicht erneut gestartet werden", + "bulk_retried_one": "{count} Wiederholung eingereiht", + "bulk_retried_other": "{count} Wiederholungen eingereiht", + "bulk_retry_failed": "Aufgaben konnten nicht erneut gestartet werden", + "bulk_remaining_one": "{count} weitere verbleibt — führen Sie die Aktion erneut aus.", + "bulk_remaining_other": "{count} weitere verbleiben — führen Sie die Aktion erneut aus." + }, + "worker_health": { + "broker_unreachable_title": "Broker nicht erreichbar", + "broker_unreachable_detail": "Bis der Broker wieder verfügbar ist, können keine Aufgaben eingereiht oder ausgeführt werden. Die Zahlen unten zeigen den zuletzt in der Datenbank gespeicherten Stand.", + "no_workers_title_one": "{count} Aufgabe wartet, kein Worker läuft", + "no_workers_title_other": "{count} Aufgaben warten, kein Worker läuft", + "no_workers_detail": "Der Broker ist verfügbar, aber kein Worker verarbeitet die Warteschlange. Dieser Rückstau wird daher nicht automatisch abgebaut.", + "view_workers": "Worker anzeigen" + }, + "tasks_empty": { + "filtered_title": "Keine Aufgaben für diese Filter", + "filtered_description": "Außerhalb der aktuellen Such-, Status- und Warteschlangenfilter sind Ausführungen vorhanden.", + "clear_filters": "Filter zurücksetzen", + "broker_unreachable_title": "Broker nicht erreichbar", + "broker_unreachable_description": "Bis der Broker wieder verfügbar ist, können keine Aufgaben eingereiht oder ausgeführt werden. Aufgaben gehen nicht verloren — sie werden nicht angenommen.", + "no_workers_title": "Kein Worker läuft", + "no_workers_description": "Der Broker ist verfügbar, aber kein Worker verarbeitet die Warteschlange. Jetzt eingereihte Aufgaben warten auf ihre Ausführung.", + "healthy_title": "Noch keine Aufgabe ausgeführt", + "healthy_description": "Registrierte Aufgaben erscheinen hier bei ihrer ersten Ausführung." + }, + "workers": { + "title": "Worker", + "description": "Mit dem Broker verbundene Celery-Worker", + "back_button": "Ausführungen", + "last_updated": "Zuletzt aktualisiert {time}", + "refresh": "Aktualisieren", + "online": "Online", + "offline": "Offline", + "uptime": "Laufzeit {uptime}", + "offline_state": "offline", + "active": "Aktiv", + "pool": "Pool", + "processed": "Verarbeitet", + "queues": "Warteschlangen", + "broker_unreachable_title": "Broker nicht erreichbar", + "broker_unreachable_description": "Die Verbindung zum Broker ist fehlgeschlagen. Aufgaben können nicht eingereiht oder ausgeführt werden. Prüfen Sie diese Einstellung und ob der Broker läuft.", + "no_error_reported": "Keine Fehlermeldung gemeldet.", + "no_workers_title": "Keine Worker verbunden", + "no_workers_description": "Der Broker ist erreichbar, aber kein Worker verarbeitet die Warteschlange. Starten Sie lokal einen Worker mit:", + "refresh_failed_status": "Worker konnten nicht aktualisiert werden (HTTP {status})", + "refresh_failed": "Worker konnten nicht aktualisiert werden", + "routes_title": "Aufgabenrouten", + "routes_description": "Welche Warteschlange für jedes Aufgabenmuster verwendet wird und welches Modul dies festgelegt hat.", + "routes_pattern": "Muster", + "routes_queue": "Warteschlange", + "routes_module": "Festgelegt durch", + "routes_override": "Betreiberüberschreibung", + "routes_default_queue": "Standardwarteschlange", + "routes_empty": "Keine Aufgabenrouten konfiguriert. Jede Aufgabe wird an die Standardwarteschlange gesendet. Fügen Sie Routen in task_config.py (TASK_ROUTES) eines Moduls hinzu." + }, + "nav": { + "background_tasks": "Hintergrundaufgaben" + }, + "audit": { + "task_execution": "Aufgabenausführung" + }, + "outbox": { + "title": "Ausgang", + "description": "Dauerhaft gespeicherte Ereignisse, die auf die Zustellung an ihre Handler warten", + "back_button": "Ausführungen", + "filter_all": "Alle nicht zugestellten", + "status_pending": "Ausstehend", + "status_running": "In Ausführung", + "status_dead": "Endgültig fehlgeschlagen", + "status_delivered": "Zugestellt", + "col_event": "Ereignis", + "col_handler": "Handler", + "col_tenant": "Organisation", + "col_status": "Status", + "col_attempts": "Versuche", + "col_next_attempt": "Nächster Versuch", + "col_last_error": "Letzter Fehler", + "col_actions": "Aktionen", + "platform": "Plattform", + "retry": "Erneut versuchen", + "discard": "Verwerfen", + "discard_confirm": "Dieses Ereignis für {handler} verwerfen? Es wird nie zugestellt.", + "empty_title": "Nichts ausstehend", + "empty_description": "Alle dauerhaft gespeicherten Ereignisse wurden zugestellt.", + "retried": "Ereignis erneut zur Zustellung eingereiht", + "retry_failed": "Ereignis konnte nicht erneut versucht werden", + "discarded": "Ereignis verworfen", + "discard_failed": "Ereignis konnte nicht verworfen werden", + "conflict": "Dieses Ereignis wird gerade zugestellt und kann daher nicht geändert werden. Die Liste wurde aktualisiert.", + "gone": "Dieses Ereignis existiert nicht mehr. Die Liste wurde aktualisiert." + }, + "permission_group": "Hintergrundaufgaben" +} diff --git a/modules/background_tasks/background_tasks/locales/en.json b/modules/background_tasks/background_tasks/locales/en.json index 9f8118f21..d4c1f500b 100644 --- a/modules/background_tasks/background_tasks/locales/en.json +++ b/modules/background_tasks/background_tasks/locales/en.json @@ -190,5 +190,6 @@ "discard_failed": "Failed to discard the event", "conflict": "This event is being delivered right now, so it cannot be changed. The list has been refreshed.", "gone": "This event no longer exists. The list has been refreshed." - } + }, + "permission_group": "Background tasks" } diff --git a/modules/background_tasks/background_tasks/module.py b/modules/background_tasks/background_tasks/module.py index 343949e29..c29069aca 100644 --- a/modules/background_tasks/background_tasks/module.py +++ b/modules/background_tasks/background_tasks/module.py @@ -89,7 +89,11 @@ def register_settings(self, app: FastAPI) -> None: ) def register_permissions(self, registry: PermissionRegistry) -> None: - registry.add_group(PERM_GROUP, [PERM_VIEW, PERM_MANAGE]) + registry.add_group( + PERM_GROUP, + [PERM_VIEW, PERM_MANAGE], + label_key="background_tasks.permission_group", + ) def register_audit_links(self, registry: AuditLinkRegistry) -> None: from simple_module_core.audit_links import AuditLink diff --git a/modules/branding/branding/locales/de.json b/modules/branding/branding/locales/de.json new file mode 100644 index 000000000..f18a70a48 --- /dev/null +++ b/modules/branding/branding/locales/de.json @@ -0,0 +1,68 @@ +{ + "manage": { + "title": "Branding", + "description": "Name, Farbe, Logos, Banner und Fußzeile — gelten für alle Seiten, auch ohne Anmeldung.", + "unsaved_changes_one": "{count} ungespeicherte Änderung", + "unsaved_changes_other": "{count} ungespeicherte Änderungen", + "discard": "Verwerfen", + "publish": "Branding veröffentlichen", + "publishing": "Wird veröffentlicht…", + "app_name_label": "App-Name", + "primary_color_label": "Primärfarbe", + "primary_color_invalid": "Geben Sie eine Hex-Farbe wie #0f766e oder #0fe ein.", + "design_pack_label": "Designpaket", + "design_pack_inline": "Designpaket: {name}", + "design_pack_none": "Keines (Basistokens)", + "design_pack_empty": "Kein installiertes Modul stellt ein Designpaket bereit.", + "logo_label": "Logo", + "preset_label": "Vorlagen", + "banner_label": "Ankündigungsbanner", + "banner_placeholder": "Wartungsfenster Sonntag 02:00–04:00 UTC", + "banner_severity_info": "Info", + "banner_severity_warning": "Warnung", + "banner_severity_danger": "kritisch", + "banner_severity_label": "Bannerschweregrad", + "logo_dark_label": "Logo (dunkle Hintergründe)", + "favicon_label": "Favicon", + "images_label": "Logo · dunkles Logo · Favicon", + "dropzone_upload": "hochladen", + "dropzone_favicon": "ico", + "remove_button": "Entfernen", + "publish_note": "Die Veröffentlichung übernimmt Text, Bilder und Fußzeile zusammen.", + "preview_title": "Live-Vorschau", + "preview_tab_app": "App", + "preview_tab_signin": "Anmeldung", + "preview_tab_email": "E-Mail", + "preview_tabs_label": "Vorschauansicht", + "preview_caption": "Die Vorschau zeigt die App-Oberfläche, die Anmeldekarte und die Kopfzeilen von Transaktions-E-Mails. Änderungen sind ohne Neuladen sichtbar.", + "preview_signin_heading": "Anmelden", + "preview_signin_action": "Anmelden", + "preview_email_subject": "Ihre Einladung", + "preview_email_action": "Einladung annehmen", + "saved_toast": "Branding aktualisiert", + "error_toast": "Branding konnte nicht aktualisiert werden", + "upload_error_toast": "Bild konnte nicht hochgeladen werden", + "footer_links_label": "Fußzeile", + "footer_text_placeholder": "© 2026 Acme Corp", + "footer_links_add": "+ Link hinzufügen", + "footer_link_label_label": "Linktext", + "footer_link_label_placeholder": "Datenschutz", + "footer_link_href_label": "Linkziel", + "footer_link_href_placeholder": "/privacy", + "footer_link_add_confirm": "Hinzufügen", + "footer_link_add_cancel": "Abbrechen" + }, + "nav": { + "branding": "Branding" + }, + "tenant_settings": { + "app_name": "Anwendungsname in Kopfzeile, Seitentiteln und E-Mails.", + "primary_color": "Akzentfarbe als #rrggbb. Zurücksetzen, um die Plattformfarbe zu verwenden.", + "design_pack": "Designpaket-Kennung. Zurücksetzen, um das Designpaket der Plattform zu verwenden.", + "footer_text": "Fußzeilentext. Zurücksetzen, um den Plattformtext anzuzeigen.", + "logo_file_id": "Logobild.", + "logo_dark_file_id": "Logo für dunkle Oberflächen; verwendet ersatzweise das eigene Logo dieser Organisation.", + "favicon_file_id": "Symbol für den Browser-Tab." + }, + "permission_group": "Branding" +} diff --git a/modules/branding/branding/locales/en.json b/modules/branding/branding/locales/en.json index 9762d25d8..06b2edba0 100644 --- a/modules/branding/branding/locales/en.json +++ b/modules/branding/branding/locales/en.json @@ -63,5 +63,6 @@ "logo_file_id": "Logo image.", "logo_dark_file_id": "Logo for dark surfaces; falls back to this organisation's own logo.", "favicon_file_id": "Browser tab icon." - } + }, + "permission_group": "Branding" } diff --git a/modules/branding/branding/module.py b/modules/branding/branding/module.py index b891209b9..5f911daa9 100644 --- a/modules/branding/branding/module.py +++ b/modules/branding/branding/module.py @@ -65,6 +65,7 @@ def register_permissions(self, registry: PermissionRegistry) -> None: registry.add_group( "Branding", [constants.PERM_VIEW, constants.PERM_MANAGE], + label_key="branding.permission_group", ) def register_public_routes(self, registry: PublicRouteRegistry) -> None: diff --git a/modules/dashboard/dashboard/locales/de.json b/modules/dashboard/dashboard/locales/de.json new file mode 100644 index 000000000..c3fbedee4 --- /dev/null +++ b/modules/dashboard/dashboard/locales/de.json @@ -0,0 +1,89 @@ +{ + "home": { + "title": "Dashboard", + "description": "Systemübersicht für diesen Arbeitsbereich", + "stats": { + "total_users": "Benutzer insgesamt", + "total_users_delta_one": "+{count} diesen Monat", + "total_users_delta_other": "+{count} diesen Monat", + "active_users": "Aktive Benutzer", + "active_users_delta": "↑ 7 T.", + "modules": "Module", + "modules_delta": "alle geladen", + "health": "Systemzustand" + }, + "system_info_title": "System", + "system_meta": "Python {version} · {count} Module · {health}", + "system_health_ok": "alle Prüfungen erfolgreich", + "system_health_none": "keine Prüfungen registriert", + "system_health_alert_one": "{count} Prüfung benötigt Aufmerksamkeit", + "system_health_alert_other": "{count} Prüfungen benötigen Aufmerksamkeit", + "welcome_message": "Willkommen bei SimpleModule", + "health_ok": "OK", + "health_alert": "{count} Warnung", + "health_all_good": "alles in Ordnung", + "health_see_doctor": "siehe Doctor", + "tile": { + "loaded_healthy": "geladen · funktionsfähig", + "loaded_degraded": "geladen · eingeschränkt", + "loaded_unhealthy": "geladen · gestört", + "loaded_no_checks": "geladen · keine Prüfungen", + "open": "Öffnen", + "no_view": "Keine Ansicht" + } + }, + "doctor": { + "title": "Doctor", + "description_before": "Dieselben Prüfungen wie", + "description_after": "— statische Analyse, Migrationen, Entwicklungsserver, Modulzustand.", + "copy_report": "Bericht kopieren", + "rerun": "Prüfungen erneut ausführen", + "stat_checks_passing": "Erfolgreiche Prüfungen", + "stat_checks_total": "/ {count}", + "stat_checks_unavailable": "nicht verfügbar", + "stat_modules_loaded": "Geladene Module", + "stat_pending_migrations": "Ausstehende Migrationen", + "stat_python": "Python", + "static_checks": "Statische Prüfungen", + "checks": { + "pages": "Verwaiste Seiten — jede Seite hat eine Route", + "metadata": "Modulmetadaten vollständig", + "coupling": "Keine Framework-Importe eines Plugin-Moduls", + "migrations": "Modelländerungen durch Migrationen abgedeckt", + "locales": "Einheitliche Sprachkataloge aller Module", + "inertia": "Seitenaktionen verwenden View-Routen statt der JSON-API", + "auth_provider": "Genau ein Authentifizierungsanbieter aktiv", + "styling": "Modul-Stylesheets befinden sich in der vorgesehenen Ebene" + }, + "status_pass": "bestanden", + "fix": "Beheben", + "checks_unavailable_title": "Diagnose läuft nur in der Entwicklungsumgebung", + "checks_unavailable_hint": "Diese Prüfungen lesen den Quellcode, der in einer bereitgestellten Version nicht enthalten ist.", + "recent_migrations": "Letzte Migrationen", + "generate": "Generieren", + "apply_pending": "Ausstehende anwenden", + "applied": "angewendet", + "pending": "ausstehend", + "migrations_empty": "Hier ist kein Migrationsverlauf verfügbar.", + "dev_server": "Entwicklungsserver", + "running": "in Ausführung", + "not_running": "läuft nicht", + "copied_command_toast": "Befehl in die Zwischenablage kopiert", + "copied_report_toast": "Bericht in die Zwischenablage kopiert", + "copy_failed_toast": "Zwischenablage nicht erreichbar", + "transcript_modules": "Module werden geprüft… {count} geladen", + "transcript_pages": "Seiten werden geprüft… {count} mit Route", + "transcript_warn": "Warnung: {message}", + "transcript_result": "✓ {passed} von {total} Prüfungen bestanden", + "transcript_skipped": "Diagnose wird außerhalb der Entwicklungsumgebung übersprungen", + "report_checks": "Prüfungen", + "report_checks_unavailable": "nicht verfügbar (Diagnose läuft nur in der Entwicklungsumgebung)", + "report_migrations": "Migrationen", + "report_dev_server": "Entwicklungsserver" + }, + "nav": { + "dashboard": "Dashboard", + "doctor": "Doctor" + }, + "permission_group": "Dashboard" +} diff --git a/modules/dashboard/dashboard/locales/en.json b/modules/dashboard/dashboard/locales/en.json index fdcb84a3d..39194916e 100644 --- a/modules/dashboard/dashboard/locales/en.json +++ b/modules/dashboard/dashboard/locales/en.json @@ -84,5 +84,6 @@ "nav": { "dashboard": "Dashboard", "doctor": "Doctor" - } + }, + "permission_group": "Dashboard" } diff --git a/modules/dashboard/dashboard/module.py b/modules/dashboard/dashboard/module.py index 8582f19ad..d9619e939 100644 --- a/modules/dashboard/dashboard/module.py +++ b/modules/dashboard/dashboard/module.py @@ -45,7 +45,11 @@ def register_permissions(self, registry: PermissionRegistry) -> None: # ``admin`` holds it through ``*`` and no other role is mapped — not # even ``user`` on a single-tenant install, or any self-registered # account could read it (#464). Grant it to a role in the role editor. - registry.add_group(PERM_GROUP, [PERM_VIEW]) + registry.add_group( + PERM_GROUP, + [PERM_VIEW], + label_key="dashboard.permission_group", + ) def register_menu_items(self, registry: MenuRegistry) -> None: registry.add( diff --git a/modules/feature_flags/feature_flags/endpoints/views.py b/modules/feature_flags/feature_flags/endpoints/views.py index b481aa7d1..5d3c61883 100644 --- a/modules/feature_flags/feature_flags/endpoints/views.py +++ b/modules/feature_flags/feature_flags/endpoints/views.py @@ -9,6 +9,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Request +from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.inertia_deps import InertiaDep from simple_module_hosting.permissions import RequiresPermission from simple_module_inertia import InertiaResponse @@ -75,9 +76,10 @@ async def browse( inertia: InertiaDep, service: FeatureFlagServiceDep, registry: FeatureFlagRegistryDep, + translator: TranslatorDep, tenant_id: str | None = None, ) -> InertiaResponse: - flags = await service.list_flags(registry, tenant_id=tenant_id) + flags = await service.list_flags(registry, tenant_id=tenant_id, translate=translator.t) tenants = await service.list_tenants_with_overrides() return await inertia.render( PAGE_BROWSE, diff --git a/modules/feature_flags/feature_flags/locales/de.json b/modules/feature_flags/feature_flags/locales/de.json new file mode 100644 index 000000000..e1ebea259 --- /dev/null +++ b/modules/feature_flags/feature_flags/locales/de.json @@ -0,0 +1,56 @@ +{ + "browse": { + "title": "Funktionsschalter", + "description": "Laufzeitschalter. Eine Organisationsüberschreibung hat Vorrang vor dem Systemwert.", + "empty_title": "Keine Funktionsschalter registriert", + "empty_description": "Module deklarieren Schalter über register_feature_flags(). Installieren Sie eines, um sie hier zu sehen.", + "scope_label": "Geltungsbereich", + "viewing_system": "Systemweite Schalter — gelten für jede Organisation, sofern sie nicht überschrieben werden.", + "viewing_tenant": "Überschreibungen für Organisation {tenant_id} — nicht gesetzte Schalter verwenden den Systemwert.", + "view_history": "Änderungsverlauf anzeigen →", + "audit_note": "Jede Umschaltung wird mit Akteur und vorherigem Wert im Änderungsprotokoll erfasst.", + "tenant_id_label": "Organisations-ID", + "tenant_id_placeholder": "z. B. acme", + "scope_system": "System", + "scope_custom": "Andere…", + "go": "Anzeigen", + "cancel": "Abbrechen" + }, + "table": { + "name": "Name", + "description": "Beschreibung", + "system": "System", + "effective": "Wirksam", + "actions": "Aktionen", + "on": "ein", + "off": "aus", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "overridden": "Überschreibung", + "following_default": "Standardwert gilt", + "following_system": "Systemwert gilt", + "clear_override": "Überschreibung entfernen" + }, + "confirm": { + "enable_title": "„{name}“ aktivieren?", + "disable_title": "„{name}“ deaktivieren?", + "scope_system": "Dies ändert den Wert sofort für jede Organisation.", + "scope_tenant": "Dies setzt eine Überschreibung nur für die Organisation „{tenant_id}“. Andere Organisationen verwenden weiterhin den Systemwert.", + "enable_action": "Aktivieren", + "disable_action": "Deaktivieren", + "cancel": "Abbrechen" + }, + "toasts": { + "enabled": "„{name}“ aktiviert", + "disabled": "„{name}“ deaktiviert", + "cleared": "Überschreibung für „{name}“ entfernt", + "toggle_failed": "Funktionsschalter konnte nicht aktualisiert werden" + }, + "audit": { + "override": "Funktionsschalterüberschreibung" + }, + "nav": { + "feature_flags": "Funktionsschalter" + }, + "permission_group": "Feature-Flags" +} diff --git a/modules/feature_flags/feature_flags/locales/en.json b/modules/feature_flags/feature_flags/locales/en.json index 44a2cdee5..936887e31 100644 --- a/modules/feature_flags/feature_flags/locales/en.json +++ b/modules/feature_flags/feature_flags/locales/en.json @@ -51,5 +51,6 @@ }, "nav": { "feature_flags": "Feature flags" - } + }, + "permission_group": "Feature flags" } diff --git a/modules/feature_flags/feature_flags/module.py b/modules/feature_flags/feature_flags/module.py index d4e785f29..2bc990849 100644 --- a/modules/feature_flags/feature_flags/module.py +++ b/modules/feature_flags/feature_flags/module.py @@ -119,6 +119,7 @@ def register_permissions(self, registry: PermissionRegistry) -> None: PERM_FEATURE_FLAGS_VIEW, PERM_FEATURE_FLAGS_MANAGE, ], + label_key="feature_flags.permission_group", ) def locale_dirs(self) -> dict[str, Path]: diff --git a/modules/feature_flags/feature_flags/service.py b/modules/feature_flags/feature_flags/service.py index 8d2be3555..a21e9334f 100644 --- a/modules/feature_flags/feature_flags/service.py +++ b/modules/feature_flags/feature_flags/service.py @@ -7,6 +7,8 @@ from __future__ import annotations +from collections.abc import Callable + from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -21,16 +23,25 @@ def _registry_tenant_id(scope: str, scope_id: str) -> str | None: return scope_id if scope == SCOPE_TENANT else None +def _description(flag: FeatureFlagDefinition, translate: Callable[[str], str] | None) -> str: + # Translator.t() echoes an unknown key back; keep the literal in that case. + if not flag.description_key or translate is None: + return flag.description + translated = translate(flag.description_key) + return flag.description if translated == flag.description_key else translated + + def _build_view( flag: FeatureFlagDefinition, *, enabled: bool, overridden: bool, system_enabled: bool | None = None, + translate: Callable[[str], str] | None = None, ) -> FeatureFlagView: return FeatureFlagView( name=flag.name, - description=flag.description, + description=_description(flag, translate), default_enabled=flag.default_enabled, enabled=enabled, overridden=overridden, @@ -73,7 +84,10 @@ async def list_tenants_with_overrides(self) -> list[str]: return list(result.scalars()) async def list_flags( - self, registry: FeatureFlagRegistry, tenant_id: str | None = None + self, + registry: FeatureFlagRegistry, + tenant_id: str | None = None, + translate: Callable[[str], str] | None = None, ) -> list[FeatureFlagView]: """Join registered definitions with persisted overrides for admin display. @@ -95,7 +109,12 @@ async def list_flags( system_enabled = system_value if system_value is not None else flag.default_enabled if tenant_id is None: views.append( - _build_view(flag, enabled=system_enabled, overridden=system_value is not None) + _build_view( + flag, + enabled=system_enabled, + overridden=system_value is not None, + translate=translate, + ) ) continue tenant_value = registry.tenant_override(flag.name, tenant_id) @@ -105,6 +124,7 @@ async def list_flags( enabled=tenant_value if tenant_value is not None else system_enabled, overridden=tenant_value is not None, system_enabled=system_enabled, + translate=translate, ) ) return views diff --git a/modules/feature_flags/tests/test_flag_description_i18n.py b/modules/feature_flags/tests/test_flag_description_i18n.py new file mode 100644 index 000000000..39c48a08b --- /dev/null +++ b/modules/feature_flags/tests/test_flag_description_i18n.py @@ -0,0 +1,38 @@ +"""Feature flag descriptions are translated server-side (TEST-016).""" + +from __future__ import annotations + +from feature_flags.service import FeatureFlagService +from simple_module_core.feature_flags import FeatureFlagDefinition, FeatureFlagRegistry +from simple_module_core.i18n import Translator + +PUBLIC_UPLOADS = "file_storage.public_uploads" + + +def _translator(app, locale: str) -> Translator: + registry = app.state.sm.i18n_registry + if locale not in registry.supported_locales: # test hosts default to ["en"] + registry.supported_locales = [*registry.supported_locales, locale] + registry.load() + return Translator(registry, locale=locale, default_locale="en") + + +async def _description(app, db_session, locale: str) -> str: + svc = FeatureFlagService(db_session) + views = await svc.list_flags(app.state.sm.feature_flags, translate=_translator(app, locale).t) + return next(v.description for v in views if v.name == PUBLIC_UPLOADS) + + +async def test_description_follows_the_locale(app, db_session): + english = await _description(app, db_session, "en") + german = await _description(app, db_session, "de") + assert english == "Allow unauthenticated uploads (future)." + assert german != english + assert "Uploads" in german + + +async def test_unresolved_key_keeps_the_literal(db_session): + reg = FeatureFlagRegistry() + reg.add(FeatureFlagDefinition(name="x", description="Literal", description_key="nope.key")) + views = await FeatureFlagService(db_session).list_flags(reg, translate=lambda k: k) + assert views[0].description == "Literal" diff --git a/modules/file_storage/file_storage/locales/de.json b/modules/file_storage/file_storage/locales/de.json new file mode 100644 index 000000000..20a545b01 --- /dev/null +++ b/modules/file_storage/file_storage/locales/de.json @@ -0,0 +1,108 @@ +{ + "audit": { + "file": "Datei" + }, + "browse": { + "title": "Dateispeicher", + "head_title": "Dateispeicher", + "subtitle": "Backend {backend} · {used} belegt · {max} pro Datei", + "subtitle_quota": "Backend {backend} · {used} von {quota} belegt · {max} pro Datei", + "upload_button": "Dateien hochladen", + "delete_selected": "Ausgewählte löschen", + "uploading": "Wird hochgeladen…", + "empty_title": "Noch keine Dateien", + "empty_description": "Laden Sie Ihre erste Datei hoch.", + "no_match_title": "Keine passenden Dateien", + "no_match_description": "Keine Dateien entsprechen den aktuellen Filtern für Suche, Typ oder hochladende Person.", + "clear_filters": "Filter zurücksetzen", + "previous": "Zurück", + "next": "Weiter", + "showing": "{from}–{to} von {total}", + "selected_showing_one": "{count} ausgewählt · {from}–{to} von {total}", + "selected_showing_other": "{count} ausgewählt · {from}–{to} von {total}" + }, + "table": { + "filename": "Dateiname", + "size": "Größe", + "type": "Typ", + "uploaded_by": "Hochgeladen von", + "when": "Zeitpunkt", + "actions": "Aktionen", + "select_all": "Alle Dateien auf dieser Seite auswählen", + "select_row": "{name} auswählen", + "public": "Öffentlich" + }, + "actions": { + "download": "Herunterladen", + "make_public": "Öffentlich machen", + "make_private": "Privat machen" + }, + "delete_dialog": { + "title_one": "„{name}“ löschen?", + "title_other": "{count} Dateien löschen?", + "description_one": "Dies entfernt die Datei aus dem Backend {backend}. Bereits geteilte Links funktionieren sofort nicht mehr.", + "description_other": "Dies entfernt {count} Dateien aus dem Backend {backend}. Bereits geteilte Links funktionieren sofort nicht mehr.", + "cancel": "Abbrechen", + "confirm_one": "Datei löschen", + "confirm_other": "Dateien löschen" + }, + "toasts": { + "upload_failed": "Hochladen fehlgeschlagen", + "deleted_one": "„{name}“ gelöscht", + "deleted_other": "{count} Dateien gelöscht", + "delete_failed": "Datei konnte nicht gelöscht werden", + "already_deleted_one": "Diese Datei wurde bereits gelöscht", + "already_deleted_other": "Diese {count} Dateien wurden bereits gelöscht", + "deleted_partial": "{deleted} von {total} Dateien gelöscht; die übrigen waren bereits gelöscht", + "made_public": "„{name}“ ist jetzt öffentlich", + "made_private": "„{name}“ ist jetzt privat", + "visibility_failed": "Sichtbarkeit konnte nicht geändert werden", + "uploaded_count_one": "{count} Datei hochgeladen", + "uploaded_count_other": "{count} Dateien hochgeladen", + "upload_failed_named": "„{name}“ konnte nicht hochgeladen werden" + }, + "errors": { + "not_found": "Datei nicht gefunden", + "too_large": "Datei überschreitet die Größenbegrenzung von {max_size} pro Upload", + "bad_type": "Dieser Dateityp ist nicht erlaubt", + "backend_error": "Fehler im Speicher-Backend", + "bad_image": "Dieses Bild konnte nicht verarbeitet werden", + "quota_exceeded": "Diese Datei würde das Speicherkontingent überschreiten", + "direct_upload_unsupported": "Das Speicher-Backend unterstützt keine fortsetzbaren Uploads", + "bad_part": "Dieser Upload-Teil ist für diese Datei ungültig", + "parts_mismatch": "Die hochgeladenen Teile stimmen nicht mit den gesendeten Daten überein. Setzen Sie den Upload fort, um den Rest zu senden", + "size_mismatch": "Die zusammengesetzte Datei hat die Größe {actual}, erwartet wurde {expected}. Laden Sie sie erneut hoch", + "upload_session_closed": "Dieser Upload ist nicht mehr aktiv. Starten Sie ihn erneut" + }, + "filters": { + "search_placeholder": "Dateinamen suchen…", + "type_label": "Typ", + "type_all": "Alle Typen", + "type_selected": "Typ: {value} ({count})", + "uploader_label": "Hochgeladen von", + "uploader_all": "Alle Personen", + "uploader_selected": "Hochgeladen von: {value}" + }, + "upload": { + "drop_here": "Dateien hier ablegen", + "browse_hint": "oder zum Auswählen klicken · {types} · max. {size}", + "any_type": "beliebiger Typ", + "card_title": "Laufende Uploads", + "card_subtitle": "Bleibt beim Filtern oder Blättern der Tabelle sichtbar", + "failed_reason": "Fehlgeschlagen — {reason}", + "retry": "Erneut versuchen", + "in_progress": "{name} wird hochgeladen", + "cancel": "Upload abbrechen", + "dismiss": "Schließen", + "resume_prompt": "Bereits teilweise hochgeladen — {percent}% abgeschlossen", + "resume": "Upload fortsetzen", + "start_over": "Neu beginnen" + }, + "nav": { + "files": "Dateien" + }, + "permission_group": "Dateien", + "feature_flags": { + "public_uploads": "Nicht authentifizierte Uploads erlauben (künftig)." + } +} diff --git a/modules/file_storage/file_storage/locales/en.json b/modules/file_storage/file_storage/locales/en.json index c67f9f74f..1f3dfd228 100644 --- a/modules/file_storage/file_storage/locales/en.json +++ b/modules/file_storage/file_storage/locales/en.json @@ -100,5 +100,9 @@ }, "nav": { "files": "Files" + }, + "permission_group": "Files", + "feature_flags": { + "public_uploads": "Allow unauthenticated uploads (future)." } } diff --git a/modules/file_storage/file_storage/module.py b/modules/file_storage/file_storage/module.py index e4e194ecb..80f06ca9a 100644 --- a/modules/file_storage/file_storage/module.py +++ b/modules/file_storage/file_storage/module.py @@ -142,6 +142,7 @@ def register_permissions(self, registry: PermissionRegistry) -> None: constants.Permission.DELETE, constants.Permission.MANAGE, ], + label_key=f"{constants.LOCALE_NAMESPACE}.permission_group", ) read_write = [constants.Permission.UPLOAD, constants.Permission.DOWNLOAD] # Deleting is an organisation-admin act (#383). The platform ``user`` @@ -213,6 +214,7 @@ def register_feature_flags(self, registry: FeatureFlagRegistry) -> None: FeatureFlagDefinition( name=constants.FeatureFlag.PUBLIC_UPLOADS, description="Allow unauthenticated uploads (future).", + description_key=f"{constants.LOCALE_NAMESPACE}.feature_flags.public_uploads", default_enabled=False, ) ) diff --git a/modules/keycloak/keycloak/locales/de.json b/modules/keycloak/keycloak/locales/de.json new file mode 100644 index 000000000..47d74931f --- /dev/null +++ b/modules/keycloak/keycloak/locales/de.json @@ -0,0 +1,25 @@ +{ + "login": { + "redirecting": "Weiterleitung zu Ihrem Identitätsanbieter", + "title": "Anmeldung läuft", + "body_prefix": "Weiterleitung zu", + "body_suffix": ". Nach der Anmeldung kehren Sie direkt zurück.", + "body_generic": "Weiterleitung zu Ihrem Identitätsanbieter. Nach der Anmeldung kehren Sie direkt zurück.", + "manual": "Keine Weiterleitung? Manuell fortfahren", + "progress_label": "Weiterleitung" + }, + "logout": { + "title": "Sie sind abgemeldet", + "message": "Ihre App-Sitzung und Ihre Keycloak-Sitzung wurden beendet. Schließen Sie auf einem gemeinsam genutzten Computer zur Sicherheit den Browser.", + "sign_in_again": "Erneut anmelden", + "back_to_site": "Zurück zur Website" + }, + "errors": { + "callback_failed": "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "invalid_state": "Ungültiger Authentifizierungsstatus. Bitte versuchen Sie es erneut.", + "token_validation_failed": "Tokenvalidierung fehlgeschlagen." + }, + "nav": { + "logout": "Abmelden" + } +} diff --git a/modules/permissions/permissions/contracts/schemas.py b/modules/permissions/permissions/contracts/schemas.py index c83c6fe91..e455bf263 100644 --- a/modules/permissions/permissions/contracts/schemas.py +++ b/modules/permissions/permissions/contracts/schemas.py @@ -12,6 +12,8 @@ class PermissionGroupOut(SQLModel): """A named group of related permission keys (one per module).""" name: str + label: str = "" + """Display name in the request's locale; ``name`` when no translation exists.""" permissions: list[str] labels: dict[str, str] = Field(default_factory=dict) """Human labels for keys a runtime permission source supplied one for.""" diff --git a/modules/permissions/permissions/endpoints/views.py b/modules/permissions/permissions/endpoints/views.py index cfeae1ba2..dbf41d0d5 100644 --- a/modules/permissions/permissions/endpoints/views.py +++ b/modules/permissions/permissions/endpoints/views.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Request from pydantic import ValidationError +from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.inertia_deps import InertiaDep from simple_module_hosting.inertia_utils import ( redirect_back_with_errors, @@ -45,12 +46,13 @@ async def browse() -> RedirectResponse: async def edit_role( role_id: uuid.UUID, inertia: InertiaDep, + translator: TranslatorDep, service: PermissionService = Depends(get_permission_service), ) -> InertiaResponse | RedirectResponse: assignment = await service.get_role_permissions(role_id) if assignment is None: return RedirectResponse(_ADMIN_URL, status_code=303) - groups = service.list_registered_groups() + groups = service.list_registered_groups(translator.t) return await inertia.render( "Permissions/RoleEdit", { @@ -91,12 +93,13 @@ async def update_role( async def edit_user( user_id: uuid.UUID, inertia: InertiaDep, + translator: TranslatorDep, service: PermissionService = Depends(get_permission_service), ) -> InertiaResponse | RedirectResponse: assignment = await service.get_user_permissions(user_id) if assignment is None: return RedirectResponse(_ADMIN_URL, status_code=303) - groups = service.list_registered_groups() + groups = service.list_registered_groups(translator.t) return await inertia.render( "Permissions/UserEdit", { diff --git a/modules/permissions/permissions/group_labels.py b/modules/permissions/permissions/group_labels.py new file mode 100644 index 000000000..370a68634 --- /dev/null +++ b/modules/permissions/permissions/group_labels.py @@ -0,0 +1,31 @@ +"""Registry groups as the permission editors show them, in the request's locale.""" + +from __future__ import annotations + +from collections.abc import Callable + +from simple_module_core.permissions import PermissionGroup, PermissionRegistry + +from permissions.contracts.schemas import PermissionGroupOut + + +def _label(group: PermissionGroup, translate: Callable[[str], str] | None) -> str: + # Translator.t() echoes an unknown key back; keep the literal name then. + if not group.label_key or translate is None: + return group.name + translated = translate(group.label_key) + return group.name if translated == group.label_key else translated + + +def localized_groups( + registry: PermissionRegistry, translate: Callable[[str], str] | None = None +) -> list[PermissionGroupOut]: + return [ + PermissionGroupOut( + name=g.name, + label=_label(g, translate), + permissions=sorted(g.permissions), + labels=registry.source_labels(g.name), + ) + for g in registry.groups + ] diff --git a/modules/permissions/permissions/locales/de.json b/modules/permissions/permissions/locales/de.json new file mode 100644 index 000000000..f5bef5f5b --- /dev/null +++ b/modules/permissions/permissions/locales/de.json @@ -0,0 +1,83 @@ +{ + "audit": { + "role_permission": "Rollenberechtigung" + }, + "browse": { + "title": "Berechtigungen", + "description": "Berechtigungen werden aus allen installierten Modulen automatisch ermittelt. Weisen Sie sie Rollen zu oder vergeben Sie sie direkt an einzelne Benutzer.", + "roles_heading": "Rollen", + "no_roles": "Noch keine Rollen vorhanden.", + "users_heading": "Benutzer", + "no_users": "Keine passenden Benutzer.", + "search_placeholder": "Benutzer nach E-Mail oder Name suchen", + "count_label": "{count} zugewiesen", + "count_label_one": "{count} zugewiesen", + "count_label_other": "{count} zugewiesen", + "direct_count_label": "{count} direkt", + "direct_count_label_one": "{count} direkt", + "direct_count_label_other": "{count} direkt", + "edit_link": "Bearbeiten", + "registry_heading": "Register", + "registry_hint": "Module deklarieren diese über ModuleBase.register_permissions. Diese Liste wird bei jedem Start aus dem Code neu erstellt.", + "group_description": "Registrierte Schlüssel", + "no_permissions": "Keine Module haben Berechtigungen registriert." + }, + "table": { + "role": "Rolle", + "user": "Benutzer", + "name": "Name", + "description": "Beschreibung", + "assigned": "Zugewiesen", + "direct": "Direkt", + "actions": "Aktionen" + }, + "toasts": { + "saved": "Berechtigungen gespeichert", + "save_failed": "Berechtigungen konnten nicht gespeichert werden" + }, + "edit": { + "title": "Rolle bearbeiten: {role}", + "description": "Berechtigungen für alle Benutzer mit dieser Rolle umschalten.", + "empty": "Kein installiertes Modul hat Berechtigungen registriert.", + "no_matches": "Keine Berechtigungen entsprechen diesem Filter.", + "submit_button": "Rolle speichern", + "reset_button": "Zurücksetzen", + "cancel_link": "Abbrechen", + "head_title": "Rolle bearbeiten", + "filter_placeholder": "Module oder Berechtigungen filtern…", + "granted_only": "Nur erteilte", + "granted_summary": "{granted} von {total} erteilt", + "toggle_group_label": "Alle Berechtigungen für {group} umschalten", + "leave_warning": "Sie haben ungespeicherte Änderungen an dieser Rolle. Ohne Speichern verlassen?" + }, + "user_edit": { + "title": "Berechtigungen — {email}", + "subtitle": "{name} · wirksame Berechtigungen aus Rollen und direkten Vergaben", + "subtitle_no_name": "Wirksame Berechtigungen aus Rollen und direkten Vergaben", + "roles_label": "Rollen", + "no_roles": "Keine Rollen zugewiesen", + "empty": "Kein installiertes Modul hat Berechtigungen registriert.", + "no_matches": "Keine Berechtigungen entsprechen diesem Filter.", + "inherited_hint": "Durch Rolle erteilt: {roles}. Das Ausschalten hier widerruft diese Berechtigung nicht. Bearbeiten Sie stattdessen die Rolle.", + "submit_button": "Vergaben speichern", + "cancel_link": "Abbrechen", + "direct_summary": "Direkte Vergaben", + "effective_summary": "Wirksam", + "legend_direct": "direkte Vergabe", + "legend_role": "aus Rolle", + "group_effective": "{granted} wirksam / {total}", + "direct_badge": "direkt", + "via_role": "erteilt durch {roles}", + "direct_toggle_label": "{key} diesem Benutzer direkt erteilen", + "head_title": "Benutzer bearbeiten", + "leave_warning": "Sie haben ungespeicherte Änderungen an diesen Vergaben. Ohne Speichern verlassen?" + }, + "errors": { + "role_not_found": "Rolle nicht gefunden.", + "user_not_found": "Benutzer nicht gefunden." + }, + "filters": { + "modules_placeholder": "Module filtern…" + }, + "permission_group": "Berechtigungen" +} diff --git a/modules/permissions/permissions/locales/en.json b/modules/permissions/permissions/locales/en.json index ae5306d1e..471c47e3e 100644 --- a/modules/permissions/permissions/locales/en.json +++ b/modules/permissions/permissions/locales/en.json @@ -78,5 +78,6 @@ }, "filters": { "modules_placeholder": "Filter modules…" - } + }, + "permission_group": "Permissions" } diff --git a/modules/permissions/permissions/module.py b/modules/permissions/permissions/module.py index f00c65c52..dd78658ca 100644 --- a/modules/permissions/permissions/module.py +++ b/modules/permissions/permissions/module.py @@ -83,6 +83,7 @@ def register_permissions(self, registry: PermissionRegistry) -> None: registry.add_group( PERMISSION_GROUP, [PERM_VIEW, PERM_MANAGE], + label_key="permissions.permission_group", ) # Direct per-user grants join the framework's one resolution, so every # module's RequiresPermission, the menu and the frontend honour them. diff --git a/modules/permissions/permissions/pages/components/GroupHeading.tsx b/modules/permissions/permissions/pages/components/GroupHeading.tsx index 841927a8a..1c65423a6 100644 --- a/modules/permissions/permissions/pages/components/GroupHeading.tsx +++ b/modules/permissions/permissions/pages/components/GroupHeading.tsx @@ -14,7 +14,7 @@ export function GroupHeading({ group }: { group: PermissionGroup }) { const tag = groupTag(group); return (

- {group.name} + {group.label || group.name} {/* Whitespace-only text is not laid out in a flex container, but it does land in the accessible name — "Files file_storage", not "Filesfile_storage". */} {tag && ' '} diff --git a/modules/permissions/permissions/pages/components/RoleGroupCard.tsx b/modules/permissions/permissions/pages/components/RoleGroupCard.tsx index 492efac57..b1e9ebd0d 100644 --- a/modules/permissions/permissions/pages/components/RoleGroupCard.tsx +++ b/modules/permissions/permissions/pages/components/RoleGroupCard.tsx @@ -39,7 +39,9 @@ export function RoleGroupCard({ group, permissions, assigned, onToggle, onToggle onToggleGroup(group, !all)} - aria-label={t(keys.permissions.edit.toggle_group_label, { group: group.name })} + aria-label={t(keys.permissions.edit.toggle_group_label, { + group: group.label || group.name, + })} className={`size-[17px] rounded-[5px] ${ state === 'indeterminate' ? // The vendored checkbox always draws a tick; a partial module diff --git a/modules/permissions/permissions/pages/components/permission-groups.ts b/modules/permissions/permissions/pages/components/permission-groups.ts index be42d0c47..4104711a3 100644 --- a/modules/permissions/permissions/pages/components/permission-groups.ts +++ b/modules/permissions/permissions/pages/components/permission-groups.ts @@ -8,6 +8,8 @@ export interface PermissionGroup { name: string; + /** `name` in the request's locale; falls back to `name` when absent. */ + label?: string; permissions: string[]; /** Human labels a runtime permission source supplied, keyed by permission. */ labels?: Record; @@ -66,6 +68,7 @@ export function filterGroups( const moduleMatches = needle === '' || group.name.toLowerCase().includes(needle) || + (group.label ?? '').toLowerCase().includes(needle) || groupPrefix(group).includes(needle); if (!moduleMatches && !matchKeys) continue; const permissions = group.permissions.filter( diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py index 718750e7d..2c0997284 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/service.py @@ -3,6 +3,7 @@ from __future__ import annotations import uuid +from collections.abc import Callable from typing import TYPE_CHECKING from simple_module_core.permissions import ADMIN_ROLE, WILDCARD @@ -17,6 +18,7 @@ UserOut, UserPermissionsOut, ) +from permissions.group_labels import localized_groups from permissions.models import RolePermission, UserPermission if TYPE_CHECKING: @@ -57,15 +59,10 @@ def __init__( # ── Registry read-outs ───────────────────────────────────── - def list_registered_groups(self) -> list[PermissionGroupOut]: - return [ - PermissionGroupOut( - name=g.name, - permissions=sorted(g.permissions), - labels=self.registry.source_labels(g.name), - ) - for g in self.registry.groups - ] + def list_registered_groups( + self, translate: Callable[[str], str] | None = None + ) -> list[PermissionGroupOut]: + return localized_groups(self.registry, translate) def _registered_keys(self) -> set[str]: return set(self.registry.all_permissions) diff --git a/modules/permissions/tests/test_permission_group_i18n.py b/modules/permissions/tests/test_permission_group_i18n.py new file mode 100644 index 000000000..070496b9d --- /dev/null +++ b/modules/permissions/tests/test_permission_group_i18n.py @@ -0,0 +1,40 @@ +"""Permission group headings are translated server-side (TEST-015).""" + +from __future__ import annotations + +from permissions.service import PermissionService +from simple_module_core.i18n import Translator +from simple_module_core.permissions import PermissionRegistry + + +def _translator(app, locale: str) -> Translator: + registry = app.state.sm.i18n_registry + if locale not in registry.supported_locales: # test hosts default to ["en"] + registry.supported_locales = [*registry.supported_locales, locale] + registry.load() + return Translator(registry, locale=locale, default_locale="en") + + +def test_add_group_keeps_label_key(): + registry = PermissionRegistry() + registry.add_group("Reports", ["reports.view"], label_key="reports.permission_group") + registry.add_group("Reports", ["reports.edit"]) + (group,) = registry.groups + assert group.label_key == "reports.permission_group" + assert group.permissions == ["reports.view", "reports.edit"] + + +async def test_group_labels_follow_the_locale(app, db_session): + service = PermissionService(db_session, app.state.sm.permissions) + german = {g.name: g.label for g in service.list_registered_groups(_translator(app, "de").t)} + english = {g.name: g.label for g in service.list_registered_groups(_translator(app, "en").t)} + assert german["Files"] == "Dateien" + assert german["Feature flags"] == "Feature-Flags" + assert english["Files"] == "Files" + # Every registered group resolves to a label, never a raw catalog key. + assert all(label and "permission_group" not in label for label in german.values()) + + +async def test_without_translator_label_is_the_name(app, db_session): + service = PermissionService(db_session, app.state.sm.permissions) + assert all(g.label == g.name for g in service.list_registered_groups()) diff --git a/modules/settings/settings/locales/de.json b/modules/settings/settings/locales/de.json new file mode 100644 index 000000000..d721e102d --- /dev/null +++ b/modules/settings/settings/locales/de.json @@ -0,0 +1,151 @@ +{ + "browse": { + "title": "Einstellungen", + "new_button": "Neue Überschreibung", + "empty_title": "Noch keine Einstellungen", + "empty_description": "Erstellen Sie Ihre erste Einstellung.", + "no_match_title": "Keine passenden Überschreibungen", + "no_match_description": "Kein Schlüssel entspricht dieser Suche in diesem Geltungsbereich.", + "edit_link": "Bearbeiten", + "delete_link": "Löschen", + "delete_title": "Überschreibung löschen", + "delete_description": "„{key}“ wird entfernt. Der Schlüssel verwendet dann den Wert des nächsten nachrangigen Geltungsbereichs. Dies kann nicht rückgängig gemacht werden.", + "delete_confirm_button": "Überschreibung löschen", + "delete_failed": "Die Überschreibung konnte nicht gelöscht werden.", + "delete_managed_error": "„{setting}“ wird an anderer Stelle verwaltet und kann hier nicht gelöscht werden. Entfernen Sie den Wert unter {clear_via}.", + "description": "Datenbanküberschreibungen. Rangfolge: Benutzer vor Organisation vor System vor Umgebungsstandard.", + "search_placeholder": "Schlüssel suchen…", + "scope_filter_label": "Überschreibungen nach Geltungsbereich filtern", + "scope_all": "Alle", + "showing": "{from}–{to} von {total}", + "previous": "Zurück", + "next": "Weiter" + }, + "table": { + "scope": "Geltungsbereich", + "key": "Schlüssel", + "value": "Wert", + "value_type": "Typ", + "actions": "Aktionen" + }, + "scopes": { + "system": "System", + "tenant": "Organisation", + "user": "Benutzer" + }, + "value_types": { + "string": "string", + "bool": "bool", + "int": "int", + "float": "float", + "json": "json" + }, + "value_types_short": { + "string": "str", + "bool": "bool", + "int": "int", + "float": "float", + "json": "json" + }, + "form": { + "scope_label": "Geltungsbereich", + "scope_id_label": "Geltungsbereichs-ID", + "scope_id_placeholder": "Für den systemweiten Geltungsbereich leer lassen", + "key_label": "Schlüssel", + "key_placeholder": "z. B. feature.enabled", + "value_type_label": "Typ", + "value_label": "Wert", + "value_placeholder": "Wert eingeben", + "description_label": "Beschreibung", + "description_placeholder": "Grund für diese Überschreibung.", + "key_unknown_warning": "Kein installiertes Modul deklariert diesen Schlüssel. Er wird gespeichert, aber nur gelesen, wenn ein Modul ihn abfragt.", + "cancel_button": "Abbrechen", + "bool_true": "true", + "bool_false": "false", + "suggestions_header": "Von Modulen registriert", + "suggestion_env": "{type} · Umgebung {env_var}", + "suggestion_default": "{type} · Standard {default}" + }, + "resolved": { + "title": "Ermittelter Wert", + "this_override": "diese Überschreibung", + "env_fallback": "Umgebungsersatzwert", + "module_default": "Modulstandard", + "not_read": "nicht gelesen", + "not_set": "nicht gesetzt", + "unset": "nicht gesetzt", + "no_restart_note": "Speichern wirkt ab der nächsten Anfrage. Kein Neustart nötig.", + "restart_note": "Dieser Schlüssel wird einmal beim Start gelesen. Die App muss neu gestartet werden, bevor der neue Wert gilt." + }, + "create": { + "title": "Neue Überschreibung", + "description": "Die Werteingabe richtet sich nach dem Typ. Beim Bearbeiten ist der Schlüssel gesperrt.", + "submit_button": "Überschreibung speichern", + "head_title": "Neue Überschreibung" + }, + "edit": { + "title": "Einstellung bearbeiten", + "submit_button": "Speichern" + }, + "modules": { + "title": "Moduleinstellungen", + "description": "Aus den deklarierten Einstellungen des Moduls generiert. Umgebungsersatzwert wird je Feld angezeigt.", + "heading_suffix": "Einstellungen", + "back_link": "Zurück zu den Überschreibungen", + "empty_title": "Keine Module mit Einstellungen", + "no_fields": "Keine Felder deklariert.", + "secret_badge": "geheim", + "browse_link": "Formulare pro Modul", + "search_placeholder": "Module suchen…", + "field_count_one": "{count} Feld", + "field_count_other": "{count} Felder", + "overridden_count_one": "{count} überschrieben", + "overridden_count_other": "{count} überschrieben", + "unsaved_count_one": "{count} ungespeichert", + "unsaved_count_other": "{count} ungespeichert", + "browse_free_form_link": "Rohe Überschreibungen anzeigen →", + "table": { + "field": "Feld", + "env_var": "Umgebungsvariable", + "value": "Wert", + "default": "Standard", + "description": "Beschreibung" + }, + "test_connection": "Verbindung zu {name} testen", + "test_connection_generic": "Verbindung testen", + "testing": "Wird getestet…", + "last_test_success": "Letzter Test erfolgreich {ago}", + "last_test_failure": "Letzter Test fehlgeschlagen {ago}", + "env_var_hint": "Name der Umgebungsvariable für dieses Feld, verwendet von „smpy settings import-from-env“", + "env_var_not_read_hint": "{env_var} ist nur eine Bezeichnung. Dieses Modul liest seine Einstellungen ausschließlich aus der Datenbank, nie aus der Umgebung", + "source_db": "in der DB überschrieben", + "source_db_ignored_for_env": "in der DB gespeichert · Umgebungsvariable hat Vorrang", + "secret_write_only": "nur schreibbar · wird nie zurückgegeben", + "head_title": "Moduleinstellungen", + "managed_title": "Auf eigener Seite verwaltet", + "managed_description": "{module} hat eine eigene Einstellungsseite. Bearbeiten Sie die Werte dort, damit Validierung und Vorschau greifen.", + "managed_open": "Einstellungen für {module} öffnen" + }, + "modules_form": { + "save": "Speichern", + "saving": "Wird gespeichert…", + "saved_toast": "Einstellungen gespeichert", + "validation_failed": "Diese Einstellungen konnten nicht gespeichert werden", + "save_failed": "Speichern fehlgeschlagen. Versuchen Sie es erneut.", + "requires_restart": "Neustart erforderlich", + "reset_to_default": "Zurücksetzen", + "set_new_value": "Neuen Wert setzen", + "invalid_json": "Ungültiges JSON — wird beim Speichern abgelehnt.", + "not_set": "Nicht gesetzt (Standard)", + "option_on": "Ein", + "option_off": "Aus", + "env_pinned": "Durch eine vorrangige Umgebungsvariable festgelegt. Ändern Sie den Wert dort." + }, + "nav": { + "settings": "Einstellungen" + }, + "audit": { + "setting": "Einstellung" + }, + "permission_group": "Einstellungen" +} diff --git a/modules/settings/settings/locales/en.json b/modules/settings/settings/locales/en.json index 0c84b116c..8b0fc1920 100644 --- a/modules/settings/settings/locales/en.json +++ b/modules/settings/settings/locales/en.json @@ -146,5 +146,6 @@ }, "audit": { "setting": "Setting" - } + }, + "permission_group": "Settings" } diff --git a/modules/settings/settings/module.py b/modules/settings/settings/module.py index 1ec458e69..df158af16 100644 --- a/modules/settings/settings/module.py +++ b/modules/settings/settings/module.py @@ -117,7 +117,11 @@ def register_menu_items(self, registry: MenuRegistry) -> None: def register_permissions(self, registry: PermissionRegistry) -> None: from simple_module_core.tenancy import TenantRole, tenant_role - registry.add_group(PERM_GROUP, list(ALL_PERMISSIONS)) + registry.add_group( + PERM_GROUP, + list(ALL_PERMISSIONS), + label_key=f"{LOCALE_NAMESPACE}.permission_group", + ) # Tenant owners and admins edit their own tenant's overridable keys # (#382). The one permission for it — ``tenants`` no longer ships its # own unused ``tenants.settings.manage``. Members get nothing here. diff --git a/modules/tenants/tenants/components/AdminTenantsTable.tsx b/modules/tenants/tenants/components/AdminTenantsTable.tsx index fd0553db7..bd28498e3 100644 --- a/modules/tenants/tenants/components/AdminTenantsTable.tsx +++ b/modules/tenants/tenants/components/AdminTenantsTable.tsx @@ -1,4 +1,4 @@ -import { keys, useT } from '@simple-module-py/i18n'; +import { activeLocale, keys, useT } from '@simple-module-py/i18n'; import { Badge } from '@simple-module-py/ui/components/ui/badge'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { @@ -83,7 +83,9 @@ export function AdminTenantsTable({ tenants, onChanged }: Props) { {tenant.members} - {tenant.created_at ? new Date(tenant.created_at).toLocaleDateString() : '—'} + {tenant.created_at + ? new Date(tenant.created_at).toLocaleDateString(activeLocale()) + : '—'} {tenant.status === 'active' ? ( diff --git a/modules/tenants/tenants/components/InvitationsList.tsx b/modules/tenants/tenants/components/InvitationsList.tsx index 70f11cd69..6a1a1dee5 100644 --- a/modules/tenants/tenants/components/InvitationsList.tsx +++ b/modules/tenants/tenants/components/InvitationsList.tsx @@ -1,4 +1,4 @@ -import { keys, useT } from '@simple-module-py/i18n'; +import { activeLocale, keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@simple-module-py/ui/components/ui/card'; import { X } from 'lucide-react'; @@ -66,7 +66,7 @@ export function InvitationsList({ invitations, onChanged }: Props) {
{t(ROLE_KEY[invitation.role])} ·{' '} {t(keys.tenants.members.pending_expires, { - date: new Date(invitation.expires_at).toLocaleDateString(), + date: new Date(invitation.expires_at).toLocaleDateString(activeLocale()), })}
diff --git a/modules/tenants/tenants/components/MembersTable.tsx b/modules/tenants/tenants/components/MembersTable.tsx index 5a7b9a706..6fc25c840 100644 --- a/modules/tenants/tenants/components/MembersTable.tsx +++ b/modules/tenants/tenants/components/MembersTable.tsx @@ -1,4 +1,4 @@ -import { keys, useT } from '@simple-module-py/i18n'; +import { activeLocale, keys, useT } from '@simple-module-py/i18n'; import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActionDialog'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { NativeSelect, NativeSelectOption } from '@simple-module-py/ui/components/ui/native-select'; @@ -142,7 +142,9 @@ export function MembersTable({ )}
- {member.joined_at ? new Date(member.joined_at).toLocaleDateString() : '—'} + {member.joined_at + ? new Date(member.joined_at).toLocaleDateString(activeLocale()) + : '—'} {canManage && ( diff --git a/modules/tenants/tenants/locales/de.json b/modules/tenants/tenants/locales/de.json new file mode 100644 index 000000000..551519f12 --- /dev/null +++ b/modules/tenants/tenants/locales/de.json @@ -0,0 +1,162 @@ +{ + "nav": { + "organisations": "Organisationen", + "members": "Mitglieder", + "tenants": "Organisationen", + "settings": "Organisationseinstellungen" + }, + "roles": { + "owner": "Inhaber", + "admin": "Administrator", + "member": "Mitglied" + }, + "status": { + "active": "Aktiv", + "suspended": "Gesperrt" + }, + "index": { + "head_title": "Organisationen", + "title": "Organisationen", + "description": "Ihre Organisationen und die Möglichkeit, eine neue zu erstellen.", + "active_badge": "Aktiv", + "switch_button": "Wechseln", + "members_link": "Mitglieder", + "notice_tenant_required": "Erstellen oder wählen Sie eine Organisation, um fortzufahren.", + "notice_suspended": "Diese Organisation wurde gesperrt. Wenden Sie sich an Ihren Administrator oder erstellen Sie eine neue.", + "empty_title": "Noch keine Organisationen", + "empty_description": "Sie sind noch kein Mitglied einer Organisation.", + "create_title": "Organisation erstellen", + "create_name_label": "Name", + "create_name_placeholder": "Acme Inc.", + "create_slug_label": "Kennung (optional)", + "create_slug_placeholder": "acme", + "create_slug_hint": "Kleinbuchstaben, Zahlen und Bindestriche. Wird bei leerem Feld aus dem Namen generiert.", + "create_submit": "Organisation erstellen", + "create_submitting": "Wird erstellt…", + "toast_created": "Organisation erstellt", + "toast_switched": "Organisation gewechselt", + "toast_switch_failed": "Organisation konnte nicht gewechselt werden", + "notice_suspended_switched": "{name} wurde gesperrt. Sie arbeiten jetzt in {active}. Wenden Sie sich zur Wiederherstellung an Ihren Administrator." + }, + "members": { + "head_title": "Mitglieder", + "title": "Mitglieder", + "description": "Verwalten Sie die Mitglieder und Berechtigungen von {name}.", + "seats_used": "{used} / {limit} Plätze belegt", + "seats_unlimited": "{count} Plätze belegt (unbegrenzt)", + "seats_unlimited_one": "{count} Platz belegt (unbegrenzt)", + "seats_unlimited_other": "{count} Plätze belegt (unbegrenzt)", + "table_email": "E-Mail", + "table_role": "Rolle", + "table_joined": "Beigetreten", + "table_actions": "Aktionen", + "you_suffix": " (Sie)", + "remove_button": "Entfernen", + "leave_button": "Organisation verlassen", + "leave_confirm_title": "Diese Organisation verlassen?", + "leave_confirm_description": "Sie verlieren den Zugriff auf {name} und die zugehörigen Ressourcen.", + "leave_confirm_confirm": "Verlassen", + "leave_confirm_cancel": "Abbrechen", + "remove_confirm_title": "Dieses Mitglied entfernen?", + "remove_confirm_description": "Diese Person verliert sofort den Zugriff auf {name}.", + "remove_confirm_confirm": "Entfernen", + "remove_confirm_cancel": "Abbrechen", + "toast_role_updated": "Rolle aktualisiert", + "toast_removed": "Mitglied entfernt", + "toast_left": "Sie haben die Organisation verlassen", + "invite_title": "Person einladen", + "invite_email_label": "E-Mail", + "invite_email_placeholder": "person@example.com", + "invite_role_label": "Rolle", + "invite_submit": "Einladung senden", + "invite_submitting": "Wird gesendet…", + "invite_emailed": "Einladung per E-Mail an {email} gesendet", + "invite_share_link": "Nicht per E-Mail gesendet — teilen Sie diesen Link", + "invite_link_title": "Einladungslink", + "invite_link_description": "Teilen Sie diesen Link mit der eingeladenen Person. Er wird nicht erneut angezeigt.", + "invite_link_copy": "Link kopieren", + "toast_link_copied": "Einladungslink kopiert", + "toast_copy_failed": "Link konnte nicht kopiert werden", + "toast_invited": "Einladung gesendet", + "pending_title": "Ausstehende Einladungen", + "pending_empty": "Keine ausstehenden Einladungen.", + "pending_expires": "Läuft am {date} ab", + "revoke_button": "Widerrufen", + "toast_revoked": "Einladung widerrufen" + }, + "accept": { + "head_title": "Einladung annehmen", + "invalid_title": "Ungültiger Einladungslink", + "invalid_description": "Dieser Einladungslink ist ungültig. Bitten Sie die einladende Person um einen neuen Link.", + "expired_title": "Diese Einladung ist abgelaufen", + "expired_description": "Bitten Sie die Person, die Sie zu {tenant} eingeladen hat, Ihnen eine neue Einladung zu senden.", + "accepted_title": "Diese Einladung wurde bereits verwendet", + "accepted_description": "Diese Einladung zu {tenant} wurde bereits angenommen.", + "title": "{tenant} beitreten", + "description": "Sie wurden eingeladen, {tenant} als {role} beizutreten.", + "mismatch_title": "Mit einer anderen E-Mail-Adresse angemeldet", + "mismatch_description": "Diese Einladung wurde an {email} gesendet, aber Sie sind als {signed_in_as} angemeldet. Melden Sie sich ab und als {email} wieder an, um sie anzunehmen.", + "accept_button": "Einladung annehmen", + "accepting": "Wird angenommen…", + "toast_accepted": "Sie sind {tenant} beigetreten" + }, + "admin": { + "head_title": "Organisationen", + "title": "Organisationen", + "description": "Alle Organisationen auf dieser Plattform.", + "search_placeholder": "Organisationen suchen…", + "table_name": "Name", + "table_slug": "Kennung", + "table_status": "Status", + "table_members": "Mitglieder", + "table_created": "Erstellt", + "table_actions": "Aktionen", + "suspend_button": "Sperren", + "reactivate_button": "Reaktivieren", + "empty_title": "Keine Organisationen gefunden", + "empty_description": "Keine Organisation entspricht Ihrer Suche.", + "previous": "Zurück", + "next": "Weiter", + "toast_suspended": "Organisation gesperrt", + "toast_reactivated": "Organisation reaktiviert", + "toast_action_failed": "Organisation konnte nicht aktualisiert werden" + }, + "errors": { + "slug_taken": "Diese Kennung wird bereits verwendet.", + "self_service_disabled": "Das Erstellen von Organisationen ist deaktiviert. Bitten Sie einen Plattformadministrator, eine für Sie zu erstellen.", + "validation_failed": "Bitte prüfen Sie das Formular und versuchen Sie es erneut.", + "tenant_suspended": "Diese Organisation ist gesperrt.", + "not_found": "Diese Organisation wurde nicht gefunden.", + "last_owner": "Der letzte Inhaber kann nicht entfernt werden oder austreten. Übertragen Sie zuerst die Inhaberschaft.", + "owner_required": "Nur ein Inhaber kann dies tun.", + "member_not_found": "Dieses Mitglied wurde nicht gefunden.", + "already_invited": "Für diese E-Mail-Adresse gibt es bereits eine ausstehende Einladung.", + "plan_limit": "Platzlimit erreicht ({limit}). Wechseln Sie den Tarif, um weitere Mitglieder hinzuzufügen.", + "invitation_not_found": "Diese Einladung wurde nicht gefunden.", + "invitation_expired": "Diese Einladung ist abgelaufen.", + "invitation_used": "Diese Einladung wurde bereits verwendet.", + "invitation_email_mismatch": "Diese Einladung muss mit der eingeladenen E-Mail-Adresse angenommen werden.", + "generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", + "already_member": "Diese Person ist bereits Mitglied dieser Organisation.", + "tenant_manager_required": "Nur ein Inhaber oder Administrator dieser Organisation kann dies tun.", + "tenant_isolation": "Diese Aktion ist organisationsübergreifend nicht erlaubt." + }, + "settings": { + "head_title": "Organisationseinstellungen", + "title": "Organisationseinstellungen", + "description": "Einstellungen, die {name} selbst ändern kann. Unveränderte Werte bleiben auf dem Plattformwert.", + "empty_title": "Nichts zu konfigurieren", + "empty_description": "Die Plattform erlaubt Organisationen derzeit keine eigenen Einstellungen.", + "overridden": "Überschrieben", + "inherited": "Plattformwert", + "inherited_value": "Plattformwert: {value}", + "none": "(leer)", + "save": "Speichern", + "reset": "Auf Plattformwert zurücksetzen", + "toast_saved": "Einstellung gespeichert", + "toast_reset": "Einstellung auf Plattformwert zurückgesetzt", + "toast_failed": "Einstellung konnte nicht gespeichert werden", + "toast_uploaded": "Bild hochgeladen" + }, + "permission_group": "Mandanten" +} diff --git a/modules/tenants/tenants/locales/en.json b/modules/tenants/tenants/locales/en.json index 04811b26f..2f132feec 100644 --- a/modules/tenants/tenants/locales/en.json +++ b/modules/tenants/tenants/locales/en.json @@ -43,7 +43,9 @@ "title": "Members", "description": "Manage who belongs to {name} and what they can do.", "seats_used": "{used} / {limit} seats used", - "seats_unlimited": "{used} seats used (unlimited)", + "seats_unlimited": "{count} seats used (unlimited)", + "seats_unlimited_one": "{count} seat used (unlimited)", + "seats_unlimited_other": "{count} seats used (unlimited)", "table_email": "Email", "table_role": "Role", "table_joined": "Joined", @@ -155,5 +157,6 @@ "toast_reset": "Setting reset to the platform value", "toast_failed": "Could not save the setting", "toast_uploaded": "Image uploaded" - } + }, + "permission_group": "Tenants" } diff --git a/modules/tenants/tenants/module.py b/modules/tenants/tenants/module.py index c1ba6270d..8efb5ad76 100644 --- a/modules/tenants/tenants/module.py +++ b/modules/tenants/tenants/module.py @@ -161,6 +161,7 @@ def register_permissions(self, registry: PermissionRegistry) -> None: c.PERM_PLATFORM_VIEW, c.PERM_PLATFORM_MANAGE, ], + label_key="tenants.permission_group", ) for role, perms in c.ROLE_PERMISSIONS.items(): registry.map_role(c.tenant_role(role), perms) diff --git a/modules/tenants/tenants/pages/Members.tsx b/modules/tenants/tenants/pages/Members.tsx index 7c9757e39..bfe64afff 100644 --- a/modules/tenants/tenants/pages/Members.tsx +++ b/modules/tenants/tenants/pages/Members.tsx @@ -40,7 +40,7 @@ function Members() { const seatsLine = seats.limit === null - ? t(keys.tenants.members.seats_unlimited, { used: seats.used }) + ? t(keys.tenants.members.seats_unlimited, { count: seats.used }) : t(keys.tenants.members.seats_used, { used: seats.used, limit: seats.limit }); return ( diff --git a/modules/users/users/locales/de.json b/modules/users/users/locales/de.json new file mode 100644 index 000000000..0f6e31140 --- /dev/null +++ b/modules/users/users/locales/de.json @@ -0,0 +1,419 @@ +{ + "accept_invite": { + "head_title": "Einladung annehmen", + "invited_generic": "Sie wurden zu {app} eingeladen", + "already_used": "Diese Einladung wurde bereits verwendet. Melden Sie sich an oder nutzen Sie „Passwort vergessen“, falls Sie Ihr Passwort nicht kennen.", + "heading": "Einladung annehmen", + "submit": "Arbeitsbereich beitreten", + "submitting": "Wird aktiviert…", + "error_bad_token": "Einladungslink ist abgelaufen oder ungültig. Bitte fordern Sie eine neue Einladung an.", + "error_failed": "Einladung konnte nicht angenommen werden. Bitte versuchen Sie es erneut.", + "invited_by": "{inviter} hat Sie zu {app} eingeladen", + "subtitle": "Legen Sie zum Abschluss ein Passwort fest. Ihre E-Mail-Adresse und Rolle sind durch die Einladung vorgegeben.", + "summary_email": "E-Mail", + "summary_role": "Rolle", + "summary_expires": "Läuft ab", + "helper": "Die Annahme bestätigt Ihre E-Mail-Adresse — kein zweiter Schritt nötig.", + "expired_title": "Link abgelaufen", + "expired_body": "Dieser Einladungslink ist abgelaufen oder war nie gültig. Bitten Sie einen Administrator um einen neuen." + }, + "account_status": { + "disable_button": "Konto deaktivieren", + "enable_button": "Konto aktivieren", + "disable_confirm_title": "{email} deaktivieren?", + "disable_confirm_body": "Diese Person kann sich erst wieder anmelden, wenn Sie das Konto erneut aktivieren.", + "disable_action": "Deaktivieren", + "copy_reset_link": "Link zum Zurücksetzen kopieren", + "reset_confirm_title": "Link zum Zurücksetzen für {email} generieren?", + "reset_confirm_body": "Ein einmaliger Link zum Zurücksetzen des Passworts wird in Ihre Zwischenablage kopiert. Alle zuvor für diesen Benutzer ausgestellten Links werden ungültig.", + "reset_action": "Generieren", + "external_note": "Dieses Konto meldet sich über einen externen Identitätsanbieter (SSO) an und hat kein Passwort. Daher kann kein Link zum Zurücksetzen generiert werden.", + "unlock_button": "Entsperren" + }, + "add_people": { + "title": "Personen hinzufügen", + "description": "Per E-Mail einladen oder direkt ein Konto mit einem von Ihnen festgelegten Passwort erstellen.", + "tablist_label": "Art des Hinzufügens", + "mode_invite": "Per E-Mail einladen", + "mode_create": "Konto erstellen", + "submitting": "In Bearbeitung…", + "submit_invite": "{count} Einladung senden", + "submit_invite_one": "{count} Einladung senden", + "submit_invite_other": "{count} Einladungen senden", + "submit_create": "Benutzer erstellen", + "toast_invites_sent": "{count} Einladung gesendet", + "toast_invites_sent_one": "{count} Einladung gesendet", + "toast_invites_sent_other": "{count} Einladungen gesendet", + "toast_user_created": "Benutzer erstellt", + "error_invite_failed": "Einladungen konnten nicht gesendet werden", + "error_create_failed": "Benutzer konnte nicht erstellt werden", + "mailer_banner_prefix": "Mailversand:", + "mailer_banner_suffix": "— Einladungslinks werden im Serverprotokoll ausgegeben statt per E-Mail gesendet. Jede Ergebniszeile unten enthält eine Schaltfläche zum Kopieren des Links.", + "configure_smtp": "SMTP konfigurieren" + }, + "audit": { + "user": "Benutzer" + }, + "common": { + "email": "E-Mail", + "full_name": "Vollständiger Name", + "display_name": "Anzeigename", + "password": "Passwort", + "new_password": "Neues Passwort", + "confirm_password": "Passwort bestätigen", + "roles": "Rollen", + "cancel": "Abbrechen", + "save_changes": "Änderungen speichern", + "saving": "Wird gespeichert…", + "discard": "Verwerfen", + "back_to_users": "Zurück zu den Benutzern", + "empty_value": "—", + "error_occurred": "Ein Fehler ist aufgetreten", + "error_try_again": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", + "passwords_no_match": "Passwörter stimmen nicht überein.", + "password_placeholder": "Mindestens 8 Zeichen", + "email_placeholder": "you@example.com", + "name_placeholder": "Ihr Name", + "external_badge": "Extern · SSO", + "sign_in": "Anmelden", + "back_to_sign_in": "Zurück zur Anmeldung", + "show_password": "Anzeigen", + "hide_password": "Ausblenden", + "password_hint": "Mindestens 8 Zeichen, nicht nur Zahlen.", + "strength_weak": "schwach", + "strength_ok": "mittel", + "strength_strong": "stark" + }, + "create_fields": { + "email_placeholder": "teammate@example.com", + "full_name_optional": "Vollständiger Name (optional)", + "name_placeholder": "Jane Doe", + "password_placeholder": "Mindestens 8 Zeichen", + "password_hint": "Mindestens 8 Zeichen, nicht nur Zahlen. Das Konto ist sofort aktiv und bestätigt. Teilen Sie das Passwort auf sicherem Weg." + }, + "danger_zone": { + "title": "Gefahrenbereich", + "self_note": "Sie können Ihr eigenes Konto nicht löschen.", + "description": "Das Löschen entfernt das Konto und seine Sitzungen. Protokolleinträge bleiben erhalten. Beim Bearbeiten Ihres eigenen Kontos gesperrt.", + "delete_button": "Benutzer löschen", + "deleting": "Wird gelöscht…", + "confirm_title": "{email} löschen?", + "confirm_body": "Sitzungen enden sofort und das Konto kann nicht wiederhergestellt werden. Protokolleinträge bleiben erhalten.", + "confirm_prompt": "E-Mail-Adresse zur Bestätigung eingeben", + "toast_deleted": "Benutzer gelöscht", + "toast_failed": "Benutzer konnte nicht gelöscht werden" + }, + "details_card": { + "title": "Details", + "name_placeholder": "Jane Doe" + }, + "edit": { + "description_fallback": "Benutzerkonto bearbeiten", + "unsaved_changes": "{count} ungespeicherte Änderung", + "leave_warning": "Sie haben ungespeicherte Änderungen an diesem Benutzer. Ohne Speichern verlassen?", + "toast_saved": "Änderungen gespeichert", + "toast_verified": "Benutzer als bestätigt markiert", + "toast_verify_failed": "Benutzer konnte nicht als bestätigt markiert werden", + "toast_reset_copied": "Link zum Zurücksetzen in die Zwischenablage kopiert", + "toast_reset_failed": "Link zum Zurücksetzen konnte nicht generiert werden", + "toast_disabled": "Benutzer deaktiviert", + "toast_enabled": "Benutzer aktiviert", + "toast_disable_failed": "Benutzer konnte nicht deaktiviert werden", + "toast_enable_failed": "Benutzer konnte nicht aktiviert werden", + "error_details": "Details konnten nicht aktualisiert werden", + "error_roles": "Rollen konnten nicht aktualisiert werden", + "error_partial": "Details wurden gespeichert, aber die Rollen konnten nicht aktualisiert werden. Versuchen Sie erneut zu speichern.", + "subtitle": "{email} · beigetreten {joined} · letzte Anmeldung {lastLogin}", + "never": "nie", + "unsaved_changes_one": "{count} ungespeicherte Änderung", + "unsaved_changes_other": "{count} ungespeicherte Änderungen", + "toast_unlocked": "Konto entsperrt", + "toast_unlock_failed": "Konto konnte nicht entsperrt werden" + }, + "empty": { + "solo_title": "Bisher gibt es nur Ihr Konto", + "invite_description": "Laden Sie Teammitglieder per E-Mail ein oder erstellen Sie Konten mit selbst festgelegten Passwörtern.", + "add_people": "Personen hinzufügen", + "filtered_title": "Keine Benutzer für diese Filter", + "filtered_description": "Niemand in diesem Arbeitsbereich entspricht der aktuellen Suche und den Filtern.", + "clear_filters": "Filter zurücksetzen", + "empty_title": "Noch keine Benutzer" + }, + "filters": { + "status_trigger": "Status: {value}", + "role_trigger": "Rolle: {value}", + "value_all": "alle", + "value_active": "aktiv", + "value_unverified": "unbestätigt", + "value_invited": "eingeladen", + "value_disabled": "deaktiviert" + }, + "forgot_password": { + "head_title": "Passwort vergessen", + "heading": "Passwort vergessen", + "subtitle": "Wir senden Ihnen einen einmaligen Link, der {minutes} Minuten gültig ist.", + "submit": "Link zum Zurücksetzen senden", + "submitting": "Wird gesendet…", + "sent_title": "Prüfen Sie Ihr Postfach", + "sent_body_prefix": "Falls ein Konto für", + "sent_body_suffix": " existiert, ist ein Link zum Zurücksetzen unterwegs. Diese Meldung erscheint in jedem Fall.", + "console_mailer_note": "Konsolen-Mailversand: Der Link steht im Serverprotokoll.", + "resend_in": "Erneut senden in {countdown}", + "resend": "Link erneut senden", + "resend_failed": "Senden ist derzeit nicht möglich. Versuchen Sie es in einer Minute erneut." + }, + "index": { + "title": "Benutzer", + "description": "Personen mit Zugriff auf diesen Arbeitsbereich", + "add_people": "Personen hinzufügen", + "stat_members": "Mitglieder", + "stat_active": "Aktiv", + "stat_pending": "Ausstehende Einladungen", + "stat_roles": "Rollen", + "tab_users": "Benutzer", + "tab_roles": "Rollen", + "search_placeholder": "Nach Name oder E-Mail suchen…", + "col_member": "Mitglied", + "col_role": "Rolle", + "col_status": "Status", + "col_last_seen": "Zuletzt gesehen", + "previous": "Zurück", + "next": "Weiter", + "showing_range": "{from}–{to} von {total}", + "view_label": "Anzeige auswählen", + "add_people_short": "+ Hinzufügen" + }, + "invite_fields": { + "label": "E-Mail-Adressen", + "chip_placeholder": "Liste einfügen oder Adresse eingeben und Enter drücken…", + "counter": "{count} Adressen · {invalid} ungültig", + "counter_clean": "{count} Adressen", + "remove_chip": "{email} entfernen", + "roles_label": "Rollen für alle Personen dieser Gruppe", + "message_label": "Nachricht (optional)", + "message_placeholder": "Wird der Einladungs-E-Mail hinzugefügt." + }, + "invite_results": { + "title": "Letzte Gruppe", + "meta_sent": "{count} gesendet", + "meta_failed": "{count} fehlgeschlagen", + "copy_link": "Link kopieren", + "retry": "Erneut versuchen", + "retrying": "Wird erneut versucht…", + "empty": "Hier erscheinen die Ergebnisse Ihrer letzten Gruppe.", + "toast_copied": "Einladungslink kopiert", + "copy_failed": "Kopieren fehlgeschlagen. Markieren und kopieren Sie den Link manuell.", + "expiry_note": "Einladungen laufen nach {count} Tag ab. Ausstehende Einladungen erscheinen in der Benutzertabelle mit der Aktion „Erneut senden“.", + "expiry_note_one": "Einladungen laufen nach {count} Tag ab. Ausstehende Einladungen erscheinen in der Benutzertabelle mit der Aktion „Erneut senden“.", + "expiry_note_other": "Einladungen laufen nach {count} Tagen ab. Ausstehende Einladungen erscheinen in der Benutzertabelle mit der Aktion „Erneut senden“." + }, + "login": { + "head_title": "Anmelden", + "heading": "Anmelden", + "subtitle": "Verwenden Sie die E-Mail-Adresse und das Passwort Ihres Arbeitsbereichs.", + "forgot_link": "Passwort vergessen?", + "submit": "Anmelden", + "submitting": "Anmeldung läuft…", + "no_account": "Kein Konto?", + "sign_up": "Registrieren", + "dev_divider": "Schnellanmeldung für die Entwicklung", + "resend_verification": "Bestätigungs-E-Mail erneut senden", + "verification_resent": "Bestätigungs-E-Mail erneut gesendet. Bitte prüfen Sie Ihr Postfach.", + "error_rate_limited": "Zu viele Versuche. Bitte versuchen Sie es in einigen Minuten erneut.", + "error_invalid_credentials": "Ungültige E-Mail-Adresse oder ungültiges Passwort.", + "remember_me": "{days} Tage angemeldet bleiben", + "divider_or": "oder", + "continue_with": "Mit {name} fortfahren", + "no_account_suffix": "— oder bitten Sie einen Administrator um eine Einladung.", + "aside_heading": "Eine Administrationsoberfläche für alle installierten Module.", + "aside_body": "Benutzer, Berechtigungen, Einstellungen, Dateien, Hintergrundaufgaben und Änderungsverlauf — beim Start vollständig eingebunden.", + "aside_check_sessions": "Sitzungen, Einladungen und Passwortzurücksetzung integriert", + "aside_check_sso": "Keycloak SSO bei Bedarf", + "waiting_title": "Ihre Bestätigung steht aus", + "waiting_body": "Bestätigen Sie Ihre E-Mail-Adresse vor der Anmeldung. Bis dahin sind keine weiteren Bereiche zugänglich.", + "waiting_back": "Anderes Konto verwenden", + "no_account_invite_only": "Kein Konto? Bitten Sie einen Administrator um eine Einladung.", + "resend_failed": "Senden ist derzeit nicht möglich. Versuchen Sie es in einer Minute erneut.", + "demo_divider": "Nur umsehen?", + "demo_submitting": "Demo wird geöffnet…", + "demo_hint_read_only": "Gemeinsam genutzte Demokonten. Änderungen werden nicht gespeichert.", + "demo_hint_writable": "Gemeinsam genutzte Demokonten — alle Besucher sehen dieselben Daten.", + "demo_error": "Dieses Demokonto ist derzeit nicht verfügbar. Bitte versuchen Sie es in Kürze erneut.", + "demo_admin": "Als Administrator erkunden", + "demo_admin_hint": "Benutzer, Rollen, Einstellungen, Module — die gesamte Administration.", + "demo_user": "Als Standardbenutzer erkunden", + "demo_user_hint": "Die App aus Sicht der Menschen, für die Sie sie entwickeln." + }, + "metadata_card": { + "title": "Konto", + "sign_in": "Anmeldung", + "local_badge": "lokal · Passwort", + "created": "Erstellt", + "disabled_at": "Deaktiviert am", + "verified": "Bestätigt", + "yes": "ja", + "no": "nein", + "mark_verified": "Als bestätigt markieren", + "external_value": "extern · SSO", + "locked_until": "Gesperrt bis" + }, + "nav": { + "logout": "Abmelden", + "profile": "Profil", + "users": "Benutzer" + }, + "profile": { + "head_title": "Profil", + "title": "Ihr Profil", + "description": "Name, E-Mail, Passwort und aktive Sitzungen", + "avatar_hint": "Avatare verwenden das Branding-Logo. Kein individueller Upload pro Benutzer.", + "toast_updated": "Profil aktualisiert", + "toast_failed": "Profil konnte nicht aktualisiert werden", + "details_title": "Details", + "save_details": "Details speichern", + "password_title": "Passwort", + "password_current": "Aktuell", + "password_new": "Neu", + "password_confirm": "Bestätigen", + "change_password": "Passwort ändern", + "changing_password": "Wird geändert…", + "toast_password_changed": "Passwort geändert", + "toast_password_failed": "Ihr Passwort konnte nicht geändert werden", + "password_hint": "Mindestens 8 Zeichen, nicht nur Zahlen", + "sessions_title": "Sitzungen", + "this_browser": "Dieser Browser", + "this_browser_on": "Dieser Browser · {browser} auf {os}", + "signed_in_ago": "Angemeldet {ago}", + "signed_in_now": "In dieser Sitzung angemeldet", + "sign_out_everywhere": "Überall abmelden", + "sign_out_confirm_title": "Überall abmelden?", + "sign_out_confirm_body": "Alle Browser und Apps, in denen Sie angemeldet sind, werden abgemeldet, auch dieser Browser. Sie müssen sich erneut anmelden.", + "sign_out_confirm_action": "Überall abmelden", + "toast_signed_out_failed": "Die anderen Sitzungen konnten nicht abgemeldet werden", + "preferences_title": "Voreinstellungen", + "language": "Sprache", + "theme": "Design", + "theme_light": "Hell", + "theme_dark": "Dunkel", + "theme_system": "System" + }, + "recent_activity": { + "title": "Letzte Aktivitäten", + "see_all": "Alle im Änderungsprotokoll anzeigen →", + "empty": "Für dieses Konto wurde noch nichts aufgezeichnet.", + "summary": { + "created": "{kind} {label} erstellt", + "updated": "{kind} {label} aktualisiert", + "deleted": "{kind} {label} gelöscht", + "other": "{action} {kind} {label}", + "changed_fields": "{fields} bei {label} geändert", + "changed_count_one": "{count} Feld bei {label} geändert", + "changed_count_other": "{count} Felder bei {label} geändert" + } + }, + "register": { + "head_title": "Ihr Konto erstellen", + "heading": "Ihr Konto erstellen", + "submit": "Konto erstellen", + "submitting": "Konto wird erstellt…", + "have_account": "Sie haben bereits ein Konto?", + "success_title": "Prüfen Sie Ihr Postfach", + "success_body": "Wir haben einen Bestätigungslink an {email} gesendet.", + "success_ready_title": "Konto erstellt", + "success_ready_body": "Ihr Konto für {email} ist bereit. Melden Sie sich an, um fortzufahren.", + "error_exists": "Ein Konto mit dieser E-Mail-Adresse existiert bereits.", + "error_password_length": "Das Passwort muss mindestens 8 Zeichen ohne Leerzeichen enthalten.", + "error_password_contains_email": "Das Passwort darf Ihre E-Mail-Adresse nicht enthalten.", + "error_password_numeric": "Das Passwort darf nicht nur aus Zahlen bestehen.", + "error_password_invalid": "Dieses Passwort wird nicht akzeptiert. Versuchen Sie ein längeres, vielfältigeres Passwort.", + "error_email_invalid": "Geben Sie eine gültige E-Mail-Adresse ein. Reservierte Domains und Testdomains werden nicht akzeptiert.", + "error_failed": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "intro": "Die offene Registrierung ist für diese Instanz aktiviert. Neue Konten erhalten die Standardbenutzerrolle. Ein Administrator kann weitere Rollen vergeben.", + "bullet_verification": "E-Mail-Bestätigung vor der ersten Anmeldung erforderlich", + "bullet_no_verification": "Sie können sich direkt nach dem Erstellen Ihres Kontos anmelden", + "bullet_close": "Ein Administrator kann die Registrierung schließen", + "name_placeholder": "Optional" + }, + "reset_password": { + "head_title": "Passwort zurücksetzen", + "heading": "Neues Passwort festlegen", + "submit": "Speichern und anmelden", + "submitting": "Wird zurückgesetzt…", + "error_failed": "Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.", + "no_token": "Kein Token zum Zurücksetzen gefunden. Bitte verwenden Sie den Link aus Ihrer E-Mail.", + "confirm_label": "Bestätigen", + "expired_title": "Link abgelaufen", + "expired_body": "Links zum Zurücksetzen sind {minutes} Minuten und nur einmal gültig. Fordern Sie einen neuen an — der alte Link ist jetzt ungültig.", + "expired_action": "Neuen Link anfordern", + "saved_title": "Passwort gespeichert", + "saved_description": "Ihr neues Passwort ist festgelegt. Melden Sie sich damit an, um fortzufahren." + }, + "role_picker": { + "default_label": "Rollen" + }, + "roles_card": { + "manage_permissions": "Berechtigungen verwalten →" + }, + "roles_tab": { + "empty": "Keine Rollen definiert", + "system_badge": "System", + "no_description": "Keine Beschreibung.", + "edit_role": "Rolle bearbeiten", + "member_count": "{count} Mitglied", + "member_count_one": "{count} Mitglied", + "member_count_other": "{count} Mitglieder" + }, + "setup": { + "administrator": { + "title": "Administrator erstellen", + "description": "Ein Konto zur Anmeldung und Verwaltung dieser Installation.", + "email": "E-Mail", + "password": "Passwort", + "full_name": "Vollständiger Name", + "submit": "Administrator erstellen" + } + }, + "user_row": { + "status_disabled": "deaktiviert", + "status_invited": "eingeladen", + "status_active": "aktiv", + "sso_badge": "SSO", + "status_unverified": "unbestätigt", + "invited_meta": "eingeladen {ago} · läuft ab {until}", + "resend": "Erneut senden", + "resend_aria": "Einladung erneut an {email} senden", + "actions_aria": "Aktionen für {email}", + "open_aria": "{email} öffnen", + "action_edit": "Bearbeiten", + "action_copy_reset": "Link zum Zurücksetzen kopieren", + "action_disable": "Deaktivieren", + "action_enable": "Aktivieren", + "toast_resent": "Einladung erneut an {email} gesendet", + "toast_resend_link": "Einladungslink kopiert — dieser Mailversand protokolliert E-Mails nur", + "toast_resend_failed": "Einladung konnte nicht erneut gesendet werden", + "toast_reset_copied": "Link zum Zurücksetzen in die Zwischenablage kopiert", + "toast_reset_failed": "Link zum Zurücksetzen konnte nicht generiert werden", + "toast_disabled": "Benutzer deaktiviert", + "toast_enabled": "Benutzer aktiviert", + "toast_status_failed": "Kontostatus konnte nicht geändert werden", + "locked_badge": "gesperrt" + }, + "verify_email": { + "head_title": "E-Mail bestätigen", + "pending_title": "Ihre E-Mail-Adresse wird bestätigt…", + "pending_description": "Bitte warten Sie, während wir Ihre E-Mail-Adresse bestätigen.", + "success_title": "E-Mail bestätigt", + "success_description": "Ihre Adresse ist bestätigt. Sie können sich jetzt anmelden.", + "already_title": "Bereits bestätigt", + "already_description": "Dieses Konto ist bereits bestätigt. Sie können sich anmelden.", + "success_action": "Zur Anmeldung", + "expired_title": "Link abgelaufen", + "expired_description": "Bestätigungslinks sind {days} Tage gültig. Wir können einen neuen an dieselbe Adresse senden.", + "resend": "Bestätigung erneut senden", + "resent": "Bestätigungs-E-Mail gesendet.", + "incomplete_title": "Link unvollständig", + "incomplete_description": "Dieser Link ist unvollständig. Öffnen Sie ihn erneut aus der E-Mail.", + "resend_failed": "Senden ist derzeit nicht möglich. Versuchen Sie es in einer Minute erneut." + }, + "permission_group": "Benutzer" +} diff --git a/modules/users/users/locales/en.json b/modules/users/users/locales/en.json index ebd91214d..cd1080160 100644 --- a/modules/users/users/locales/en.json +++ b/modules/users/users/locales/en.json @@ -414,5 +414,6 @@ "incomplete_title": "Link incomplete", "incomplete_description": "This link is incomplete — open it from the email again.", "resend_failed": "We could not send it just now. Try again in a minute." - } + }, + "permission_group": "Users" } diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 8fc3386b1..bddd7c949 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -141,6 +141,7 @@ def register_permissions(self, registry: PermissionRegistry) -> None: registry.add_group( "Users", [PERM_USERS_MANAGE, PERM_USERS_SELF_PROFILE], + label_key="users.permission_group", ) registry.map_role(USER_ROLE_NAME, [PERM_USERS_SELF_PROFILE]) diff --git a/modules/users/users/pages/Users/Edit.tsx b/modules/users/users/pages/Users/Edit.tsx index 75571b254..15bb52414 100644 --- a/modules/users/users/pages/Users/Edit.tsx +++ b/modules/users/users/pages/Users/Edit.tsx @@ -1,5 +1,5 @@ import { Head, usePage } from '@inertiajs/react'; -import { keys, useT } from '@simple-module-py/i18n'; +import { activeLocale, keys, useT } from '@simple-module-py/i18n'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; import { useRelativeTime } from '@simple-module-py/ui/hooks/use-relative-time'; import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; @@ -29,7 +29,7 @@ function joinedMonth(value: string | null): string { if (!value) return ''; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return ''; - return parsed.toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); + return parsed.toLocaleDateString(activeLocale(), { month: 'short', year: 'numeric' }); } /** diff --git a/modules/users/users/pages/Users/components/RecentActivityCard.tsx b/modules/users/users/pages/Users/components/RecentActivityCard.tsx index 11f65b075..aa2d0d781 100644 --- a/modules/users/users/pages/Users/components/RecentActivityCard.tsx +++ b/modules/users/users/pages/Users/components/RecentActivityCard.tsx @@ -1,5 +1,5 @@ import { Link } from '@inertiajs/react'; -import { keys, useT } from '@simple-module-py/i18n'; +import { activeLocale, keys, useT } from '@simple-module-py/i18n'; import { SectionTitle } from '@simple-module-py/ui/components/SectionTitle'; import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; @@ -24,14 +24,14 @@ interface Props { function stamp(iso: string): string { const parsed = new Date(iso); if (Number.isNaN(parsed.getTime())) return iso; - const time = parsed.toLocaleTimeString(undefined, { + const time = parsed.toLocaleTimeString(activeLocale(), { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); if (parsed.toDateString() === new Date().toDateString()) return time; - const date = parsed.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); + const date = parsed.toLocaleDateString(activeLocale(), { day: 'numeric', month: 'short' }); return `${date} ${time.slice(0, 5)}`; } diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 61ee34058..774dcb122 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -61,6 +61,7 @@ export default { 'api_tokens.list.status': '', 'api_tokens.list.token': '', 'api_tokens.nav.api_tokens': '', + 'api_tokens.permission_group': '', 'api_tokens.revoke.cancel': '', 'api_tokens.revoke.confirm': '', 'api_tokens.revoke.description': '', @@ -119,6 +120,7 @@ export default { 'audit_log.filters.user_label': '', 'audit_log.filters.user_placeholder': '', 'audit_log.nav.audit_log': '', + 'audit_log.permission_group': '', 'audit_log.table.action': '', 'audit_log.table.changes': '', 'audit_log.table.entity': '', @@ -199,6 +201,7 @@ export default { 'background_tasks.outbox.status_pending': '', 'background_tasks.outbox.status_running': '', 'background_tasks.outbox.title': '', + 'background_tasks.permission_group': '', 'background_tasks.retry_all_dialog.cancel': '', 'background_tasks.retry_all_dialog.confirm': '', 'background_tasks.retry_all_dialog.description': '', @@ -341,6 +344,7 @@ export default { 'branding.manage.unsaved_changes_other': '', 'branding.manage.upload_error_toast': '', 'branding.nav.branding': '', + 'branding.permission_group': '', 'branding.tenant_settings.app_name': '', 'branding.tenant_settings.design_pack': '', 'branding.tenant_settings.favicon_file_id': '', @@ -422,6 +426,7 @@ export default { 'dashboard.home.welcome_message': '', 'dashboard.nav.dashboard': '', 'dashboard.nav.doctor': '', + 'dashboard.permission_group': '', 'feature_flags.audit.override': '', 'feature_flags.browse.audit_note': '', 'feature_flags.browse.cancel': '', @@ -446,6 +451,7 @@ export default { 'feature_flags.confirm.scope_system': '', 'feature_flags.confirm.scope_tenant': '', 'feature_flags.nav.feature_flags': '', + 'feature_flags.permission_group': '', 'feature_flags.table.actions': '', 'feature_flags.table.clear_override': '', 'feature_flags.table.description': '', @@ -502,6 +508,7 @@ export default { 'file_storage.errors.size_mismatch': '', 'file_storage.errors.too_large': '', 'file_storage.errors.upload_session_closed': '', + 'file_storage.feature_flags.public_uploads': '', 'file_storage.filters.search_placeholder': '', 'file_storage.filters.type_all': '', 'file_storage.filters.type_label': '', @@ -510,6 +517,7 @@ export default { 'file_storage.filters.uploader_label': '', 'file_storage.filters.uploader_selected': '', 'file_storage.nav.files': '', + 'file_storage.permission_group': '', 'file_storage.table.actions': '', 'file_storage.table.filename': '', 'file_storage.table.public': '', @@ -653,6 +661,7 @@ export default { 'permissions.errors.role_not_found': '', 'permissions.errors.user_not_found': '', 'permissions.filters.modules_placeholder': '', + 'permissions.permission_group': '', 'permissions.table.actions': '', 'permissions.table.assigned': '', 'permissions.table.description': '', @@ -775,6 +784,7 @@ export default { 'settings.modules_form.set_new_value': '', 'settings.modules_form.validation_failed': '', 'settings.nav.settings': '', + 'settings.permission_group': '', 'settings.resolved.env_fallback': '', 'settings.resolved.module_default': '', 'settings.resolved.no_restart_note': '', @@ -903,6 +913,8 @@ export default { 'tenants.members.remove_confirm_title': '', 'tenants.members.revoke_button': '', 'tenants.members.seats_unlimited': '', + 'tenants.members.seats_unlimited_one': '', + 'tenants.members.seats_unlimited_other': '', 'tenants.members.seats_used': '', 'tenants.members.table_actions': '', 'tenants.members.table_email': '', @@ -921,6 +933,7 @@ export default { 'tenants.nav.organisations': '', 'tenants.nav.settings': '', 'tenants.nav.tenants': '', + 'tenants.permission_group': '', 'tenants.roles.admin': '', 'tenants.roles.member': '', 'tenants.roles.owner': '', @@ -1250,6 +1263,7 @@ export default { 'users.nav.logout': '', 'users.nav.profile': '', 'users.nav.users': '', + 'users.permission_group': '', 'users.profile.avatar_hint': '', 'users.profile.change_password': '', 'users.profile.changing_password': '', diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 0231ca26f..eacbe4c1e 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -74,6 +74,15 @@ export function updateI18n(opts: ConfigureOptions): void { } } +/** + * The active UI language, for `Intl` / `toLocale*String` calls. Passing + * `undefined` there follows the browser's locale, which shows US dates on a + * German page when the browser is en-US. + */ +export function activeLocale(): string | undefined { + return i18next.language || undefined; +} + export { t } from 'i18next'; export { useTranslation as useT } from 'react-i18next'; export { keys } from './keys.generated'; diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index fab915734..2cc9bd489 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -82,6 +82,7 @@ export const keys = { nav: { api_tokens: 'api_tokens.nav.api_tokens', }, + permission_group: 'api_tokens.permission_group', revoke: { cancel: 'api_tokens.revoke.cancel', confirm: 'api_tokens.revoke.confirm', @@ -164,6 +165,7 @@ export const keys = { nav: { audit_log: 'audit_log.nav.audit_log', }, + permission_group: 'audit_log.permission_group', table: { action: 'audit_log.table.action', changes: 'audit_log.table.changes', @@ -264,6 +266,7 @@ export const keys = { status_running: 'background_tasks.outbox.status_running', title: 'background_tasks.outbox.title', }, + permission_group: 'background_tasks.permission_group', retry_all_dialog: { cancel: 'background_tasks.retry_all_dialog.cancel', confirm: 'background_tasks.retry_all_dialog.confirm', @@ -435,6 +438,7 @@ export const keys = { nav: { branding: 'branding.nav.branding', }, + permission_group: 'branding.permission_group', tenant_settings: { app_name: 'branding.tenant_settings.app_name', design_pack: 'branding.tenant_settings.design_pack', @@ -534,6 +538,7 @@ export const keys = { dashboard: 'dashboard.nav.dashboard', doctor: 'dashboard.nav.doctor', }, + permission_group: 'dashboard.permission_group', }, feature_flags: { audit: { @@ -568,6 +573,7 @@ export const keys = { nav: { feature_flags: 'feature_flags.nav.feature_flags', }, + permission_group: 'feature_flags.permission_group', table: { actions: 'feature_flags.table.actions', clear_override: 'feature_flags.table.clear_override', @@ -644,6 +650,9 @@ export const keys = { too_large: 'file_storage.errors.too_large', upload_session_closed: 'file_storage.errors.upload_session_closed', }, + feature_flags: { + public_uploads: 'file_storage.feature_flags.public_uploads', + }, filters: { search_placeholder: 'file_storage.filters.search_placeholder', type_all: 'file_storage.filters.type_all', @@ -656,6 +665,7 @@ export const keys = { nav: { files: 'file_storage.nav.files', }, + permission_group: 'file_storage.permission_group', table: { actions: 'file_storage.table.actions', filename: 'file_storage.table.filename', @@ -854,6 +864,7 @@ export const keys = { filters: { modules_placeholder: 'permissions.filters.modules_placeholder', }, + permission_group: 'permissions.permission_group', table: { actions: 'permissions.table.actions', assigned: 'permissions.table.assigned', @@ -1005,6 +1016,7 @@ export const keys = { nav: { settings: 'settings.nav.settings', }, + permission_group: 'settings.permission_group', resolved: { env_fallback: 'settings.resolved.env_fallback', module_default: 'settings.resolved.module_default', @@ -1154,6 +1166,8 @@ export const keys = { remove_confirm_title: 'tenants.members.remove_confirm_title', revoke_button: 'tenants.members.revoke_button', seats_unlimited: 'tenants.members.seats_unlimited', + seats_unlimited_one: 'tenants.members.seats_unlimited_one', + seats_unlimited_other: 'tenants.members.seats_unlimited_other', seats_used: 'tenants.members.seats_used', table_actions: 'tenants.members.table_actions', table_email: 'tenants.members.table_email', @@ -1175,6 +1189,7 @@ export const keys = { settings: 'tenants.nav.settings', tenants: 'tenants.nav.tenants', }, + permission_group: 'tenants.permission_group', roles: { admin: 'tenants.roles.admin', member: 'tenants.roles.member', @@ -1576,6 +1591,7 @@ export const keys = { profile: 'users.nav.profile', users: 'users.nav.users', }, + permission_group: 'users.permission_group', profile: { avatar_hint: 'users.profile.avatar_hint', change_password: 'users.profile.change_password', diff --git a/scripts/check_locales.py b/scripts/check_locales.py new file mode 100644 index 000000000..7a3667737 --- /dev/null +++ b/scripts/check_locales.py @@ -0,0 +1,106 @@ +"""Check catalogue keys and interpolation placeholders against adjacent en.json files. + +Full parity is required for FULL_PARITY languages, including missing catalogues. +Other translations are community-contributed: report differences without failing. +""" + +from __future__ import annotations + +import json +import os +import re +from collections import Counter +from pathlib import Path + +FULL_PARITY = ("de",) +ROOT = Path(__file__).resolve().parent.parent +PLACEHOLDER = re.compile(r"\{\{[^{}]+\}\}|(? list[Path]: + """Find all English locale catalogues without traversing dependencies.""" + found = [] + for directory, dirs, files in os.walk(root): + dirs[:] = sorted(d for d in dirs if d not in {"node_modules", ".git", ".venv"}) + path = Path(directory) + if "en.json" in files and "locales" in str(path.relative_to(root)): + found.append(path / "en.json") + return sorted(found) + + +def flatten(data: object, prefix: str = "") -> dict[str, str]: + """Flatten nested objects, rejecting non-string leaves and ambiguous keys.""" + if not isinstance(data, dict): + raise ValueError(f"{prefix or ''}: expected an object") + result: dict[str, str] = {} + for key, value in data.items(): + name = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + entries = flatten(value, name) + elif isinstance(value, str): + entries = {name: value} + else: + raise ValueError(f"{name}: expected a string") + if result.keys() & entries.keys(): + raise ValueError(f"{name}: duplicate flattened key") + result.update(entries) + return result + + +def load(path: Path) -> dict[str, str]: + return flatten(json.loads(path.read_text(encoding="utf-8"))) + + +def differences(source: dict[str, str], target: dict[str, str]) -> list[str]: + problems = [] + for key in sorted(source.keys() - target.keys()): + problems.append(f"missing key: {key}") + for key in sorted(target.keys() - source.keys()): + problems.append(f"extra key: {key}") + for key in sorted(source.keys() & target.keys()): + expected = Counter(PLACEHOLDER.findall(source[key])) + actual = Counter(PLACEHOLDER.findall(target[key])) + if expected != actual: + problems.append( + f"placeholder mismatch: {key}: expected {dict(expected)}, got {dict(actual)}" + ) + return problems + + +def check(root: Path = ROOT) -> int: + failures = reports = checked = 0 + sources = catalogues(root) + for source_path in sources: + try: + source = load(source_path) + except (OSError, ValueError) as exc: + print(f"FAIL {source_path.relative_to(root)}: {exc}") + failures += 1 + continue + targets = set(source_path.parent.glob("*.json")) - {source_path} + targets.update(source_path.with_name(f"{lang}.json") for lang in FULL_PARITY) + for target_path in sorted(targets): + enforced = target_path.stem in FULL_PARITY + try: + problems = differences(source, load(target_path)) + except (OSError, ValueError) as exc: + problems = [str(exc)] + checked += 1 + label = "FAIL" if enforced else "REPORT" + if problems: + failures += int(enforced) + reports += int(not enforced) + print(f"{label} {target_path.relative_to(root)}:") + for problem in problems: + print(f" {problem}") + else: + print(f"OK {target_path.relative_to(root)} ({len(source)} keys)") + print( + f"Checked {checked} translations across {len(sources)} English catalogues: " + f"{failures} enforced failures, {reports} community reports." + ) + return int(failures > 0) + + +if __name__ == "__main__": + raise SystemExit(check()) diff --git a/scripts/tests/test_check_locales.py b/scripts/tests/test_check_locales.py new file mode 100644 index 000000000..7620e595f --- /dev/null +++ b/scripts/tests/test_check_locales.py @@ -0,0 +1,56 @@ +"""The locale gate enforces German while keeping community reports advisory.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +spec = importlib.util.spec_from_file_location( + "check_locales", Path(__file__).resolve().parents[1] / "check_locales.py" +) +assert spec is not None and spec.loader is not None +locales = importlib.util.module_from_spec(spec) +spec.loader.exec_module(locales) + + +def write_catalogue(root: Path, lang: str, data: dict) -> None: + directory = root / "module" / "locales" + directory.mkdir(parents=True, exist_ok=True) + (directory / f"{lang}.json").write_text(json.dumps(data), encoding="utf-8") + + +def test_german_required_community_advisory(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + write_catalogue(tmp_path, "en", {"hello": "Hello {name}"}) + assert locales.check(tmp_path) == 1 + assert "FAIL module/locales/de.json" in capsys.readouterr().out + write_catalogue(tmp_path, "de", {"hello": "Hallo {name}"}) + write_catalogue(tmp_path, "es", {"extra": "Hola"}) + assert locales.check(tmp_path) == 0 + output = capsys.readouterr().out + assert "REPORT module/locales/es.json" in output + assert "missing key: hello" in output + assert "extra key: extra" in output + + +def test_nested_plural_keys_and_placeholder_identity() -> None: + source = locales.flatten( + {"items": {"count_one": "{count} for {{name}}", "count_other": "{count} for {{name}}"}} + ) + target = locales.flatten( + {"items": {"count_one": "{count} für {{name}}", "count_other": "{count} für {name}"}} + ) + problems = locales.differences(source, target) + assert len(problems) == 1 + assert "placeholder mismatch: items.count_other" in problems[0] + assert locales.differences({"x": "{name} {name}"}, {"x": "{name}"}) + + +def test_invalid_german_and_dependency_exclusion(tmp_path: Path) -> None: + write_catalogue(tmp_path, "en", {"hello": "Hello"}) + write_catalogue(tmp_path, "de", {"hello": 42}) + write_catalogue(tmp_path / "node_modules", "en", {"ignored": "Ignored"}) + assert len(locales.catalogues(tmp_path)) == 1 + assert locales.check(tmp_path) == 1