diff --git a/admin_ui/index.html b/admin_ui/index.html index 595e546..c2bde3d 100644 --- a/admin_ui/index.html +++ b/admin_ui/index.html @@ -1,8 +1,8 @@ - + - RustDesk Admin + {{T_APP_TITLE}} @@ -23,38 +23,51 @@
-
Loading…
+
{{T_LOADING}}
diff --git a/admin_ui/login.html b/admin_ui/login.html index eb43263..f347253 100644 --- a/admin_ui/login.html +++ b/admin_ui/login.html @@ -1,8 +1,8 @@ - + - Sign in — RustDesk Admin + {{T_SIGNIN}} — {{T_TITLE}} @@ -13,8 +13,8 @@
-

RustDesk Admin

-

Sign in to manage the server.

+

{{T_TITLE}}

+

{{T_SUBTITLE}}

- +
- +
"# +"#, + msg = t(lang, "login.totp_required"), ); // We don't need a session yet — caller will resubmit with the // same username/password plus the code. (No nonce involved on @@ -88,10 +91,10 @@ pub async fn login( // Verify the supplied code. let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) { Ok(b) => b, - Err(_) => return error_fragment("Internal TOTP error"), + Err(_) => return error_fragment(t(lang, "login.internal_totp")), }; if !ok { - return error_fragment("Bad TOTP code"); + return error_fragment(t(lang, "login.bad_totp")); } } diff --git a/src/api/admin/i18n.rs b/src/api/admin/i18n.rs new file mode 100644 index 0000000..df2c3cf --- /dev/null +++ b/src/api/admin/i18n.rs @@ -0,0 +1,1996 @@ +//! Multi-language support for the admin dashboard. +//! +//! Language is selected per-browser via the `admin_lang` cookie (set by the +//! language switcher in the sidebar / login page). Falls back to the +//! `Accept-Language` header, then English. +//! +//! All translations live in this file as a big `match` table — no external +//! resource files, no `phf` dependency, easy to grep. To add a new key, +//! drop a new arm in `t()`. To add a new language, extend the `Lang` enum +//! and add a branch to every `match lang` block. +//! +//! Format-string keys use `{0}` / `{1}` placeholders that callers fill in +//! via `tf1` / `tf2`. We don't use `format!()` because the template comes +//! from a runtime lookup (not a literal). + +use crate::api::error::ApiError; +use async_trait::async_trait; +use axum::extract::{FromRequest, RequestParts}; +use axum::http::header; + +/// Supported UI languages. Default = English. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Lang { + En, + De, + Fr, + Ro, + Es, +} + +impl Lang { + pub fn code(self) -> &'static str { + match self { + Lang::En => "en", + Lang::De => "de", + Lang::Fr => "fr", + Lang::Ro => "ro", + Lang::Es => "es", + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "en" | "en-us" | "en-gb" => Some(Lang::En), + "de" | "de-de" | "de-ch" | "de-at" => Some(Lang::De), + "fr" | "fr-fr" | "fr-ch" | "fr-be" | "fr-ca" => Some(Lang::Fr), + "ro" | "ro-ro" | "ro-md" => Some(Lang::Ro), + "es" | "es-es" | "es-mx" | "es-ar" | "es-cl" | "es-co" | "es-pe" | "es-419" => { + Some(Lang::Es) + } + _ => None, + } + } + + /// Cookie name we look at and write. + pub const COOKIE: &'static str = "admin_lang"; +} + +impl Default for Lang { + fn default() -> Self { + Lang::En + } +} + +/// Resolve the user's preferred language from the `admin_lang` cookie first, +/// then `Accept-Language`, otherwise English. Pure function so both the +/// extractor and standalone serve handlers can call it. +pub fn lang_from_headers(headers: &axum::http::HeaderMap) -> Lang { + if let Some(cookie_hdr) = headers.get(header::COOKIE) { + if let Ok(s) = cookie_hdr.to_str() { + for pair in s.split(';') { + if let Some((name, value)) = pair.trim().split_once('=') { + if name.trim() == Lang::COOKIE { + if let Some(l) = Lang::parse(value.trim()) { + return l; + } + } + } + } + } + } + if let Some(al) = headers.get(header::ACCEPT_LANGUAGE) { + if let Ok(s) = al.to_str() { + // First language tag in the list (ignore q= weights — good enough). + for tag in s.split(',') { + let primary = tag.split(';').next().unwrap_or("").trim(); + if let Some(l) = Lang::parse(primary) { + return l; + } + } + } + } + Lang::En +} + +#[async_trait] +impl FromRequest for Lang { + type Rejection = ApiError; + + async fn from_request(req: &mut RequestParts) -> Result { + Ok(lang_from_headers(req.headers())) + } +} + +// ---------- translations ---------- + +/// Look up a translation key. Returns `` if the key is +/// unknown — that's deliberately ugly so any gap shows up in the UI rather +/// than silently falling back to English. +pub fn t(lang: Lang, key: &str) -> &'static str { + let (en, de, fr, ro, es) = match key { + // ---- generic ---- + "common.loading" => ( + "Loading…", + "Wird geladen…", + "Chargement…", + "Se încarcă…", + "Cargando…", + ), + "common.create" => ("Create", "Erstellen", "Créer", "Creează", "Crear"), + "common.save" => ("Save", "Speichern", "Enregistrer", "Salvează", "Guardar"), + "common.delete" => ("Delete", "Löschen", "Supprimer", "Șterge", "Eliminar"), + "common.add" => ("Add", "Hinzufügen", "Ajouter", "Adaugă", "Añadir"), + "common.remove" => ("Remove", "Entfernen", "Retirer", "Elimină", "Quitar"), + "common.update" => ( + "Update", + "Aktualisieren", + "Mettre à jour", + "Actualizează", + "Actualizar", + ), + "common.cancel" => ("Cancel", "Abbrechen", "Annuler", "Anulează", "Cancelar"), + "common.confirm" => ( + "Confirm", + "Bestätigen", + "Confirmer", + "Confirmă", + "Confirmar", + ), + "common.actions" => ("Actions", "Aktionen", "Actions", "Acțiuni", "Acciones"), + "common.back" => ( + "← Back", + "← Zurück", + "← Retour", + "← Înapoi", + "← Volver", + ), + "common.never" => ("never", "nie", "jamais", "niciodată", "nunca"), + "common.already_gone" => ( + "Already gone.", + "Bereits entfernt.", + "Déjà supprimé.", + "Deja șters.", + "Ya se eliminó.", + ), + "common.not_found" => ( + "Not found.", + "Nicht gefunden.", + "Introuvable.", + "Negăsit.", + "No encontrado.", + ), + "common.language" => ("Language", "Sprache", "Langue", "Limbă", "Idioma"), + + // ---- shell / sidebar ---- + "shell.app_title" => ( + "RustDesk Admin", + "RustDesk Admin", + "RustDesk Admin", + "RustDesk Admin", + "RustDesk Admin", + ), + "nav.users" => ( + "Users", + "Benutzer", + "Utilisateurs", + "Utilizatori", + "Usuarios", + ), + "nav.devices" => ( + "Devices", + "Geräte", + "Appareils", + "Dispozitive", + "Dispositivos", + ), + "nav.groups" => ( + "Device groups", + "Gerätegruppen", + "Groupes d'appareils", + "Grupuri de dispozitive", + "Grupos de dispositivos", + ), + "nav.strategies" => ( + "Strategies", + "Strategien", + "Stratégies", + "Strategii", + "Estrategias", + ), + "nav.address_books" => ( + "Address books", + "Adressbücher", + "Carnets d'adresses", + "Agende", + "Libretas de direcciones", + ), + "nav.audit" => ( + "Audit log", + "Audit-Protokoll", + "Journal d'audit", + "Jurnal de audit", + "Registro de auditoría", + ), + "nav.deploy" => ( + "Deploy", + "Bereitstellen", + "Déployer", + "Implementare", + "Implementación", + ), + "nav.profile" => ( + "My profile", + "Mein Profil", + "Mon profil", + "Profilul meu", + "Mi perfil", + ), + "nav.signout" => ( + "Sign out", + "Abmelden", + "Se déconnecter", + "Deconectare", + "Cerrar sesión", + ), + "nav.signed_in_as" => ( + "Signed in as", + "Angemeldet als", + "Connecté en tant que", + "Conectat ca", + "Conectado como", + ), + + // ---- login ---- + "login.title" => ( + "RustDesk Admin", + "RustDesk Admin", + "RustDesk Admin", + "RustDesk Admin", + "RustDesk Admin", + ), + "login.subtitle" => ( + "Sign in to manage the server.", + "Melden Sie sich an, um den Server zu verwalten.", + "Connectez-vous pour gérer le serveur.", + "Conectați-vă pentru a administra serverul.", + "Inicia sesión para administrar el servidor.", + ), + "login.username" => ( + "Username", + "Benutzername", + "Nom d'utilisateur", + "Utilizator", + "Usuario", + ), + "login.password" => ( + "Password", + "Passwort", + "Mot de passe", + "Parolă", + "Contraseña", + ), + "login.totp_label" => ( + "6-digit TOTP code", + "6-stelliger TOTP-Code", + "Code TOTP à 6 chiffres", + "Cod TOTP din 6 cifre", + "Código TOTP de 6 dígitos", + ), + "login.signin" => ( + "Sign in", + "Anmelden", + "Se connecter", + "Conectare", + "Iniciar sesión", + ), + "login.or" => ("or", "oder", "ou", "sau", "o"), + "login.signin_with" => ( + "Sign in with", + "Anmelden mit", + "Se connecter avec", + "Conectare cu", + "Iniciar sesión con", + ), + "login.bad_credentials" => ( + "Bad credentials", + "Ungültige Anmeldedaten", + "Identifiants incorrects", + "Date de autentificare incorecte", + "Credenciales incorrectas", + ), + "login.account_disabled" => ( + "Account disabled", + "Konto deaktiviert", + "Compte désactivé", + "Cont dezactivat", + "Cuenta deshabilitada", + ), + "login.admin_required" => ( + "Admin access required", + "Administrator-Zugriff erforderlich", + "Accès administrateur requis", + "Acces de administrator necesar", + "Se requiere acceso de administrador", + ), + "login.bad_totp" => ( + "Bad TOTP code", + "Ungültiger TOTP-Code", + "Code TOTP incorrect", + "Cod TOTP incorect", + "Código TOTP incorrecto", + ), + "login.totp_required" => ( + "Enter your 6-digit authenticator code.", + "Geben Sie den 6-stelligen Authentifizierungs-Code ein.", + "Saisissez votre code d'authentification à 6 chiffres.", + "Introduceți codul de autentificare din 6 cifre.", + "Introduce tu código de autenticación de 6 dígitos.", + ), + "login.internal_totp" => ( + "Internal TOTP error", + "Interner TOTP-Fehler", + "Erreur TOTP interne", + "Eroare TOTP internă", + "Error TOTP interno", + ), + + // ---- users ---- + "users.heading" => ( + "Users", + "Benutzer", + "Utilisateurs", + "Utilizatori", + "Usuarios", + ), + "users.create_heading" => ( + "Create user", + "Benutzer erstellen", + "Créer un utilisateur", + "Creează utilizator", + "Crear usuario", + ), + "users.username" => ( + "username", + "Benutzername", + "nom d'utilisateur", + "utilizator", + "usuario", + ), + "users.display_name" => ( + "display name", + "Anzeigename", + "nom affiché", + "nume afișat", + "nombre visible", + ), + "users.email_optional" => ( + "email (optional)", + "E-Mail (optional)", + "e-mail (optionnel)", + "email (opțional)", + "correo (opcional)", + ), + "users.password" => ( + "password", + "Passwort", + "mot de passe", + "parolă", + "contraseña", + ), + "users.admin_label" => ("admin", "Administrator", "admin", "admin", "admin"), + "users.username_required" => ( + "Username required", + "Benutzername erforderlich", + "Nom d'utilisateur requis", + "Numele de utilizator este obligatoriu", + "Se requiere el usuario", + ), + "users.password_min" => ( + "Password must be at least 4 characters", + "Das Passwort muss mindestens 4 Zeichen lang sein", + "Le mot de passe doit contenir au moins 4 caractères", + "Parola trebuie să aibă cel puțin 4 caractere", + "La contraseña debe tener al menos 4 caracteres", + ), + "users.created" => ( + "Created user '{0}'.", + "Benutzer „{0}\" erstellt.", + "Utilisateur « {0} » créé.", + "Utilizator „{0}\" creat.", + "Usuario «{0}» creado.", + ), + "users.profile_updated" => ( + "Profile updated.", + "Profil aktualisiert.", + "Profil mis à jour.", + "Profil actualizat.", + "Perfil actualizado.", + ), + "users.password_updated" => ( + "Password updated.", + "Passwort aktualisiert.", + "Mot de passe mis à jour.", + "Parolă actualizată.", + "Contraseña actualizada.", + ), + "users.user_not_found" => ( + "User not found.", + "Benutzer nicht gefunden.", + "Utilisateur introuvable.", + "Utilizator negăsit.", + "Usuario no encontrado.", + ), + "users.user_deleted" => ( + "User deleted.", + "Benutzer gelöscht.", + "Utilisateur supprimé.", + "Utilizator șters.", + "Usuario eliminado.", + ), + "users.cant_revoke_self" => ( + "You can't revoke your own admin flag here. Edit another admin's row instead.", + "Sie können Ihren eigenen Administrator-Status hier nicht entziehen. Bearbeiten Sie stattdessen einen anderen Administrator.", + "Vous ne pouvez pas retirer votre propre statut d'administrateur ici. Modifiez la ligne d'un autre administrateur.", + "Nu vă puteți revoca propriul statut de administrator aici. Editați rândul altui administrator.", + "No puedes revocar tu propio rol de administrador desde aquí. Edita la fila de otro administrador.", + ), + "users.cant_disable_self" => ( + "You can't disable your own account from here.", + "Sie können Ihr eigenes Konto hier nicht deaktivieren.", + "Vous ne pouvez pas désactiver votre propre compte d'ici.", + "Nu vă puteți dezactiva propriul cont de aici.", + "No puedes deshabilitar tu propia cuenta desde aquí.", + ), + "users.cant_delete_self" => ( + "You can't delete the account you're signed in with.", + "Sie können das Konto, mit dem Sie angemeldet sind, nicht löschen.", + "Vous ne pouvez pas supprimer le compte avec lequel vous êtes connecté.", + "Nu puteți șterge contul cu care sunteți conectat.", + "No puedes eliminar la cuenta con la que has iniciado sesión.", + ), + "users.totp_removed" => ( + "TOTP removed.", + "TOTP entfernt.", + "TOTP supprimé.", + "TOTP eliminat.", + "TOTP eliminado.", + ), + "users.no_totp" => ( + "User had no TOTP.", + "Benutzer hatte kein TOTP.", + "L'utilisateur n'avait pas de TOTP.", + "Utilizatorul nu avea TOTP.", + "El usuario no tenía TOTP.", + ), + "users.oidc_password_disabled" => ( + "This account is linked to OIDC — set the password at the identity provider instead.", + "Dieses Konto ist mit OIDC verknüpft — legen Sie das Passwort beim Identitätsanbieter fest.", + "Ce compte est lié à OIDC — définissez le mot de passe chez le fournisseur d'identité.", + "Acest cont este legat de OIDC — setați parola la furnizorul de identitate.", + "Esta cuenta está vinculada a OIDC — establece la contraseña en el proveedor de identidad.", + ), + "users.col_username" => ( + "Username", + "Benutzername", + "Nom d'utilisateur", + "Utilizator", + "Usuario", + ), + "users.col_display_name" => ( + "Display name", + "Anzeigename", + "Nom affiché", + "Nume afișat", + "Nombre visible", + ), + "users.col_email" => ("Email", "E-Mail", "E-mail", "Email", "Correo"), + "users.col_status" => ("Status", "Status", "Statut", "Status", "Estado"), + "users.col_admin" => ( + "Admin", + "Administrator", + "Admin", + "Admin", + "Administrador", + ), + "users.col_totp" => ("TOTP", "TOTP", "TOTP", "TOTP", "TOTP"), + "users.col_last_seen" => ( + "Last seen", + "Zuletzt gesehen", + "Dernière connexion", + "Văzut ultima dată", + "Última conexión", + ), + "users.status_active" => ("active", "aktiv", "actif", "activ", "activo"), + "users.status_disabled" => ( + "disabled", + "deaktiviert", + "désactivé", + "dezactivat", + "deshabilitado", + ), + "users.status_unverified" => ( + "unverified", + "unbestätigt", + "non vérifié", + "neverificat", + "sin verificar", + ), + "users.totp_enrolled" => ( + "enrolled", + "registriert", + "activé", + "activat", + "activado", + ), + "users.password_set" => ( + "Set", + "Setzen", + "Définir", + "Setează", + "Establecer", + ), + "users.new_password" => ( + "new password", + "neues Passwort", + "nouveau mot de passe", + "parolă nouă", + "nueva contraseña", + ), + "users.oidc_disabled" => ( + "Linked to OIDC — password sign-in is disabled.", + "Mit OIDC verknüpft — Passwort-Anmeldung ist deaktiviert.", + "Lié à OIDC — la connexion par mot de passe est désactivée.", + "Legat de OIDC — autentificarea cu parolă este dezactivată.", + "Vinculado a OIDC — el inicio de sesión con contraseña está deshabilitado.", + ), + "users.save_profile" => ( + "Save profile", + "Profil speichern", + "Enregistrer le profil", + "Salvează profil", + "Guardar perfil", + ), + "users.grant_admin" => ( + "Grant admin", + "Admin gewähren", + "Donner admin", + "Acordă admin", + "Conceder admin", + ), + "users.revoke_admin" => ( + "Revoke admin", + "Admin entziehen", + "Retirer admin", + "Revocă admin", + "Revocar admin", + ), + "users.disable_user" => ( + "Disable user", + "Benutzer deaktivieren", + "Désactiver l'utilisateur", + "Dezactivează utilizator", + "Deshabilitar usuario", + ), + "users.enable_user" => ( + "Enable user", + "Benutzer aktivieren", + "Activer l'utilisateur", + "Activează utilizator", + "Habilitar usuario", + ), + "users.disable_totp" => ( + "Disable TOTP", + "TOTP deaktivieren", + "Désactiver TOTP", + "Dezactivează TOTP", + "Deshabilitar TOTP", + ), + "users.confirm_disable_totp" => ( + "Disable TOTP for {0}? They'll be able to sign in without a 6-digit code until they re-enroll.", + "TOTP für {0} deaktivieren? Diese Person kann sich ohne 6-stelligen Code anmelden, bis TOTP erneut aktiviert wird.", + "Désactiver TOTP pour {0} ? Cette personne pourra se connecter sans code à 6 chiffres jusqu'à la réinscription.", + "Dezactivați TOTP pentru {0}? Acest utilizator se va putea conecta fără cod din 6 cifre până la reînregistrare.", + "¿Deshabilitar TOTP para {0}? Podrá iniciar sesión sin un código de 6 dígitos hasta que vuelva a activarlo.", + ), + "users.delete_user" => ( + "Delete user", + "Benutzer löschen", + "Supprimer l'utilisateur", + "Șterge utilizator", + "Eliminar usuario", + ), + "users.confirm_delete" => ( + "Delete user {0}? This cascades into their tokens, group memberships and AB shares.", + "Benutzer {0} löschen? Das entfernt auch seine Tokens, Gruppenmitgliedschaften und Adressbuch-Freigaben.", + "Supprimer l'utilisateur {0} ? Cela supprime aussi ses jetons, appartenances aux groupes et partages de carnet.", + "Ștergeți utilizatorul {0}? Aceasta elimină și tokenurile, apartenențele la grupuri și partajările de agendă.", + "¿Eliminar al usuario {0}? Esto también elimina sus tokens, pertenencias a grupos y compartidos de libreta.", + ), + "users.totp_enrolled_for" => ( + "TOTP enrolled for {0}", + "TOTP registriert für {0}", + "TOTP activé pour {0}", + "TOTP activat pentru {0}", + "TOTP activado para {0}", + ), + "users.secret_b32" => ( + "Secret (base32):", + "Geheimnis (Base32):", + "Secret (base32) :", + "Secret (base32):", + "Secreto (base32):", + ), + "users.otpauth_url" => ( + "otpauth URL:", + "otpauth-URL:", + "URL otpauth :", + "URL otpauth:", + "URL otpauth:", + ), + "users.otpauth_hint" => ( + "Show this once to the user (or scan the URL as a QR code) — it isn't displayed again.", + "Zeigen Sie dies dem Benutzer einmal (oder scannen Sie die URL als QR-Code) — sie wird nicht erneut angezeigt.", + "Montrez ceci à l'utilisateur une seule fois (ou scannez l'URL comme QR code) — elle ne sera plus affichée.", + "Afișați acest lucru o singură dată utilizatorului (sau scanați URL-ul ca un cod QR) — nu va mai fi afișat din nou.", + "Muéstralo al usuario una sola vez (o escanea la URL como código QR) — no se mostrará de nuevo.", + ), + + // ---- devices ---- + "devices.heading" => ( + "Devices", + "Geräte", + "Appareils", + "Dispozitive", + "Dispositivos", + ), + "devices.tagline" => ( + "Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).", + "Trennung / Sysinfo werden beim nächsten Heartbeat des Peers ausgelöst (~15 s).", + "Déconnexion / sysinfo sont appliqués au prochain heartbeat du pair (~15 s).", + "Deconectarea / sysinfo se aplică la următorul heartbeat al peer-ului (~15 s).", + "La desconexión y sysinfo forzadas se aplican en el próximo heartbeat del par (~15 s).", + ), + "devices.col_peer_id" => ( + "Peer ID", + "Peer-ID", + "ID du pair", + "ID peer", + "ID del par", + ), + "devices.col_owner" => ( + "Owner", + "Besitzer", + "Propriétaire", + "Proprietar", + "Propietario", + ), + "devices.col_hostname" => ( + "Hostname", + "Hostname", + "Nom d'hôte", + "Hostname", + "Nombre de host", + ), + "devices.col_user" => ("User", "Benutzer", "Utilisateur", "Utilizator", "Usuario"), + "devices.col_unattended_pwd" => ( + "Unattended pwd", + "Unbeaufsichtigtes Pwd", + "Mot de passe sans surveillance", + "Parolă nesupravegheată", + "Contraseña desatendida", + ), + "devices.col_os" => ("OS", "Betriebssystem", "OS", "SO", "SO"), + "devices.col_version" => ("Version", "Version", "Version", "Versiune", "Versión"), + "devices.col_last_heartbeat" => ( + "Last heartbeat", + "Letzter Heartbeat", + "Dernier heartbeat", + "Ultimul heartbeat", + "Último heartbeat", + ), + "devices.col_conns" => ( + "Conns", + "Verbindungen", + "Connexions", + "Conexiuni", + "Conexiones", + ), + "devices.no_devices" => ( + "No devices have heartbeated yet.", + "Noch keine Geräte mit Heartbeat.", + "Aucun appareil n'a encore envoyé de heartbeat.", + "Niciun dispozitiv nu a trimis încă heartbeat.", + "Ningún dispositivo ha enviado heartbeat aún.", + ), + "devices.online" => ( + "Online — last heartbeat {0}s ago", + "Online — letzter Heartbeat vor {0}s", + "En ligne — dernier heartbeat il y a {0}s", + "Online — ultimul heartbeat acum {0}s", + "En línea — último heartbeat hace {0}s", + ), + "devices.no_heartbeat" => ( + "No heartbeat recorded", + "Kein Heartbeat aufgezeichnet", + "Aucun heartbeat enregistré", + "Niciun heartbeat înregistrat", + "Sin heartbeat registrado", + ), + "devices.offline" => ( + "Offline — last heartbeat {0} ago", + "Offline — letzter Heartbeat vor {0}", + "Hors ligne — dernier heartbeat il y a {0}", + "Offline — ultimul heartbeat acum {0}", + "Sin conexión — último heartbeat hace {0}", + ), + "devices.connect_web" => ( + "Connect (web client)", + "Verbinden (Web-Client)", + "Connecter (client web)", + "Conectează (client web)", + "Conectar (cliente web)", + ), + "devices.details" => ( + "Details & inventory", + "Details & Inventar", + "Détails & inventaire", + "Detalii și inventar", + "Detalles e inventario", + ), + "devices.force_disconnect" => ( + "Force disconnect", + "Trennung erzwingen", + "Forcer la déconnexion", + "Forțează deconectarea", + "Forzar desconexión", + ), + "devices.force_sysinfo" => ( + "Force sysinfo refresh", + "Sysinfo-Aktualisierung erzwingen", + "Forcer la mise à jour sysinfo", + "Forțează reîmprospătarea sysinfo", + "Forzar actualización sysinfo", + ), + "devices.confirm_disconnect" => ( + "Disconnect all active sessions on {0}?", + "Alle aktiven Sitzungen von {0} trennen?", + "Déconnecter toutes les sessions actives de {0} ?", + "Deconectați toate sesiunile active de pe {0}?", + "¿Desconectar todas las sesiones activas en {0}?", + ), + "devices.delete_device" => ( + "Delete device", + "Gerät löschen", + "Supprimer l'appareil", + "Șterge dispozitivul", + "Eliminar dispositivo", + ), + "devices.confirm_delete" => ( + "Delete {0}? This removes the dashboard row and the rendezvous identity. Audit logs and recordings are kept.", + "{0} löschen? Damit werden der Dashboard-Eintrag und die Rendezvous-Identität entfernt. Audit-Protokolle und Aufzeichnungen bleiben erhalten.", + "Supprimer {0} ? Cela supprime la ligne du tableau de bord et l'identité rendezvous. Les journaux d'audit et enregistrements sont conservés.", + "Ștergeți {0}? Aceasta elimină rândul din panou și identitatea rendezvous. Jurnalele de audit și înregistrările sunt păstrate.", + "¿Eliminar {0}? Esto elimina la fila del panel y la identidad rendezvous. Se conservan los registros de auditoría y grabaciones.", + ), + "devices.queued_disconnect" => ( + "Queued disconnect for {0} (conns={1})", + "Trennung für {0} eingereiht (conns={1})", + "Déconnexion mise en file pour {0} (conns={1})", + "Deconectare pusă în coadă pentru {0} (conns={1})", + "Desconexión en cola para {0} (conns={1})", + ), + "devices.queued_sysinfo" => ( + "Queued sysinfo refresh for {0}", + "Sysinfo-Aktualisierung für {0} eingereiht", + "Mise à jour sysinfo mise en file pour {0}", + "Reîmprospătare sysinfo pusă în coadă pentru {0}", + "Actualización sysinfo en cola para {0}", + ), + "devices.deleted" => ( + "Deleted device {0}.", + "Gerät {0} gelöscht.", + "Appareil {0} supprimé.", + "Dispozitiv {0} șters.", + "Dispositivo {0} eliminado.", + ), + "devices.already_gone" => ( + "Device already gone.", + "Gerät bereits entfernt.", + "Appareil déjà supprimé.", + "Dispozitivul a fost deja șters.", + "El dispositivo ya se eliminó.", + ), + "devices.back" => ( + "← Back to devices", + "← Zurück zu Geräten", + "← Retour aux appareils", + "← Înapoi la dispozitive", + "← Volver a dispositivos", + ), + "devices.detail_view" => ( + "Detail view", + "Detailansicht", + "Vue détaillée", + "Vizualizare detaliată", + "Vista detallada", + ), + "devices.device_label" => ( + "Device", + "Gerät", + "Appareil", + "Dispozitiv", + "Dispositivo", + ), + "devices.detail_active_user" => ( + "Active user", + "Aktiver Benutzer", + "Utilisateur actif", + "Utilizator activ", + "Usuario activo", + ), + "devices.detail_agent" => ("Agent", "Agent", "Agent", "Agent", "Agente"), + "devices.detail_os_runtime" => ( + "OS (runtime)", + "Betriebssystem (Laufzeit)", + "OS (runtime)", + "SO (runtime)", + "SO (runtime)", + ), + "devices.detail_cpu_runtime" => ( + "CPU (runtime)", + "CPU (Laufzeit)", + "CPU (runtime)", + "CPU (runtime)", + "CPU (runtime)", + ), + "devices.detail_memory_runtime" => ( + "Memory (runtime)", + "Speicher (Laufzeit)", + "Mémoire (runtime)", + "Memorie (runtime)", + "Memoria (runtime)", + ), + "devices.inventory" => ( + "Inventory", + "Inventar", + "Inventaire", + "Inventar", + "Inventario", + ), + "devices.inventory_pending" => ( + "Inventory data not yet reported. The agent collects it on startup and uploads on the next sysinfo cycle (≤120 s).", + "Inventardaten noch nicht gemeldet. Der Agent sammelt sie beim Start und überträgt sie beim nächsten Sysinfo-Zyklus (≤120 s).", + "Données d'inventaire non encore signalées. L'agent les collecte au démarrage et les transmet au prochain cycle sysinfo (≤120 s).", + "Datele de inventar nu au fost încă raportate. Agentul le colectează la pornire și le încarcă la următorul ciclu sysinfo (≤120 s).", + "Aún no se han reportado datos de inventario. El agente los recopila al iniciar y los envía en el próximo ciclo sysinfo (≤120 s).", + ), + "devices.inventory_unsupported" => ( + "Inventory data is only reported by HelloAgent. This device is running {0}; the standard RustDesk client does not collect hardware or BitLocker inventory.", + "Inventardaten werden nur von HelloAgent gemeldet. Auf diesem Gerät läuft {0}; der Standard-RustDesk-Client erfasst keine Hardware- oder BitLocker-Inventardaten.", + "Les données d'inventaire ne sont rapportées que par HelloAgent. Cet appareil exécute {0} ; le client RustDesk standard ne collecte pas l'inventaire matériel ou BitLocker.", + "Datele de inventar sunt raportate doar de HelloAgent. Acest dispozitiv rulează {0}; clientul RustDesk standard nu colectează inventar hardware sau BitLocker.", + "Los datos de inventario solo los reporta HelloAgent. Este dispositivo está ejecutando {0}; el cliente RustDesk estándar no recopila inventario de hardware ni BitLocker.", + ), + "devices.serial_number" => ( + "Serial number", + "Seriennummer", + "Numéro de série", + "Număr de serie", + "Número de serie", + ), + "devices.manufacturer" => ( + "Manufacturer", + "Hersteller", + "Fabricant", + "Producător", + "Fabricante", + ), + "devices.model" => ("Model", "Modell", "Modèle", "Model", "Modelo"), + "devices.windows_domain" => ( + "Windows domain", + "Windows-Domäne", + "Domaine Windows", + "Domeniu Windows", + "Dominio de Windows", + ), + "devices.os_distro" => ( + "OS distribution", + "OS-Distribution", + "Distribution OS", + "Distribuție SO", + "Distribución del SO", + ), + "devices.os_release" => ( + "OS release", + "OS-Release", + "Version OS", + "Versiune SO", + "Versión del SO", + ), + "devices.cpu_model" => ( + "CPU model", + "CPU-Modell", + "Modèle CPU", + "Model CPU", + "Modelo de CPU", + ), + "devices.cpu_speed" => ( + "CPU speed (GHz)", + "CPU-Takt (GHz)", + "Vitesse CPU (GHz)", + "Viteză CPU (GHz)", + "Velocidad de CPU (GHz)", + ), + "devices.cpu_phys_cores" => ( + "CPU physical cores", + "Physische CPU-Kerne", + "Cœurs physiques CPU", + "Nuclee fizice CPU", + "Núcleos físicos de CPU", + ), + "devices.cpu_logical_cores" => ( + "CPU logical cores", + "Logische CPU-Kerne", + "Cœurs logiques CPU", + "Nuclee logice CPU", + "Núcleos lógicos de CPU", + ), + "devices.ram_gb" => ("RAM (GB)", "RAM (GB)", "RAM (Go)", "RAM (GB)", "RAM (GB)"), + "devices.disks" => ( + "Disks", + "Festplatten", + "Disques", + "Discuri", + "Discos", + ), + "devices.disk_name" => ("Name", "Name", "Nom", "Nume", "Nombre"), + "devices.disk_model" => ("Model", "Modell", "Modèle", "Model", "Modelo"), + "devices.disk_size" => ( + "Size (GB)", + "Größe (GB)", + "Taille (Go)", + "Dimensiune (GB)", + "Tamaño (GB)", + ), + "devices.disk_media" => ("Media", "Medium", "Support", "Suport", "Soporte"), + "devices.network_interfaces" => ( + "Network interfaces", + "Netzwerkschnittstellen", + "Interfaces réseau", + "Interfețe de rețea", + "Interfaces de red", + ), + "devices.nic_description" => ( + "Description", + "Beschreibung", + "Description", + "Descriere", + "Descripción", + ), + "devices.nic_status" => ("Status", "Status", "Statut", "Status", "Estado"), + "devices.wifi_current" => ( + "Wi-Fi (current connection)", + "WLAN (aktuelle Verbindung)", + "Wi-Fi (connexion actuelle)", + "Wi-Fi (conexiune curentă)", + "Wi-Fi (conexión actual)", + ), + "devices.wifi_signal" => ("Signal", "Signal", "Signal", "Semnal", "Señal"), + "devices.wifi_auth" => ( + "Authentication", + "Authentifizierung", + "Authentification", + "Autentificare", + "Autenticación", + ), + "devices.wifi_cipher" => ( + "Cipher", + "Verschlüsselung", + "Chiffrement", + "Cifru", + "Cifrado", + ), + "devices.wifi_rate" => ("Rate", "Datenrate", "Débit", "Rată", "Velocidad"), + "devices.wifi_not_connected" => ( + "Not connected to a Wi-Fi network.", + "Nicht mit einem WLAN-Netzwerk verbunden.", + "Non connecté à un réseau Wi-Fi.", + "Neconectat la o rețea Wi-Fi.", + "Sin conexión a una red Wi-Fi.", + ), + "devices.wifi_nearby" => ( + "Nearby SSIDs ({0})", + "Verfügbare SSIDs ({0})", + "SSID à proximité ({0})", + "SSID-uri din apropiere ({0})", + "SSID cercanos ({0})", + ), + "devices.public_ip" => ( + "Public IP (egress, last lookup)", + "Öffentliche IP (Egress, letzte Abfrage)", + "IP publique (sortie, dernière recherche)", + "IP publică (egress, ultima căutare)", + "IP pública (salida, última consulta)", + ), + "devices.public_ip_failed" => ( + "— (lookup failed or blocked)", + "— (Abfrage fehlgeschlagen oder blockiert)", + "— (échec de la recherche ou bloqué)", + "— (căutare eșuată sau blocată)", + "— (consulta fallida o bloqueada)", + ), + "devices.bitlocker" => ( + "BitLocker recovery key (system drive)", + "BitLocker-Wiederherstellungsschlüssel (Systemlaufwerk)", + "Clé de récupération BitLocker (disque système)", + "Cheie de recuperare BitLocker (unitate de sistem)", + "Clave de recuperación BitLocker (unidad del sistema)", + ), + "devices.bitlocker_unavailable" => ( + "— (not reported; non-Pro SKU, not encrypted, or no admin rights)", + "— (nicht gemeldet; keine Pro-Edition, nicht verschlüsselt oder keine Admin-Rechte)", + "— (non signalé ; édition non Pro, non chiffré, ou pas de droits admin)", + "— (neraportat; ediție non-Pro, necriptat sau fără drepturi de admin)", + "— (no reportado; edición no Pro, sin cifrar o sin permisos de administrador)", + ), + "devices.no_device_with_id" => ( + "No device with peer ID", + "Kein Gerät mit Peer-ID", + "Aucun appareil avec l'ID de pair", + "Niciun dispozitiv cu ID-ul peer", + "Ningún dispositivo con el ID de par", + ), + "devices.in_dashboard" => ( + "in the dashboard.", + "im Dashboard.", + "dans le tableau de bord.", + "în panou.", + "en el panel.", + ), + "devices.devices_count" => ( + "{0} device(s).", + "{0} Gerät(e).", + "{0} appareil(s).", + "{0} dispozitiv(e).", + "{0} dispositivo(s).", + ), + + // ---- groups ---- + "groups.heading" => ( + "Device groups", + "Gerätegruppen", + "Groupes d'appareils", + "Grupuri de dispozitive", + "Grupos de dispositivos", + ), + "groups.create_heading" => ( + "Create group", + "Gruppe erstellen", + "Créer un groupe", + "Creează grup", + "Crear grupo", + ), + "groups.group_name" => ( + "group name", + "Gruppenname", + "nom du groupe", + "nume grup", + "nombre del grupo", + ), + "groups.name_required" => ( + "Name required", + "Name erforderlich", + "Nom requis", + "Nume necesar", + "Se requiere el nombre", + ), + "groups.created" => ( + "Group '{0}' created.", + "Gruppe „{0}\" erstellt.", + "Groupe « {0} » créé.", + "Grupul „{0}\" creat.", + "Grupo «{0}» creado.", + ), + "groups.deleted" => ( + "Group deleted.", + "Gruppe gelöscht.", + "Groupe supprimé.", + "Grup șters.", + "Grupo eliminado.", + ), + "groups.no_groups" => ( + "No device groups yet.", + "Noch keine Gerätegruppen.", + "Aucun groupe d'appareils.", + "Nu există încă grupuri.", + "Aún no hay grupos de dispositivos.", + ), + "groups.delete_group" => ( + "Delete group", + "Gruppe löschen", + "Supprimer le groupe", + "Șterge grup", + "Eliminar grupo", + ), + "groups.confirm_delete" => ( + "Delete group {0}? Members aren't deleted; just unassigned.", + "Gruppe {0} löschen? Mitglieder werden nicht gelöscht, nur die Zuordnung wird entfernt.", + "Supprimer le groupe {0} ? Les membres ne sont pas supprimés, seulement désassignés.", + "Ștergeți grupul {0}? Membrii nu sunt șterși, doar dezasignați.", + "¿Eliminar el grupo {0}? Los miembros no se eliminan; solo se desasignan.", + ), + "groups.users" => ( + "Users", + "Benutzer", + "Utilisateurs", + "Utilizatori", + "Usuarios", + ), + "groups.no_user_members" => ( + "No user members yet.", + "Noch keine Benutzer als Mitglieder.", + "Aucun utilisateur membre.", + "Niciun utilizator membru.", + "Aún no hay usuarios miembros.", + ), + "groups.add_user" => ( + "Add user", + "Benutzer hinzufügen", + "Ajouter utilisateur", + "Adaugă utilizator", + "Añadir usuario", + ), + "groups.devices_section" => ( + "Devices", + "Geräte", + "Appareils", + "Dispozitive", + "Dispositivos", + ), + "groups.no_peer_members" => ( + "No devices added directly. (Devices owned by user members above are also visible to them.)", + "Keine Geräte direkt hinzugefügt. (Geräte der oben genannten Benutzer-Mitglieder sind ebenfalls sichtbar.)", + "Aucun appareil ajouté directement. (Les appareils possédés par les membres ci-dessus leur sont aussi visibles.)", + "Nu există dispozitive adăugate direct. (Dispozitivele deținute de membrii de mai sus sunt vizibile pentru aceștia.)", + "No se añadieron dispositivos directamente. (Los dispositivos de los miembros anteriores también son visibles para ellos.)", + ), + "groups.unowned" => ( + "unowned", + "ohne Besitzer", + "sans propriétaire", + "fără proprietar", + "sin propietario", + ), + "groups.owner_label" => ( + "owner: {0}", + "Besitzer: {0}", + "propriétaire : {0}", + "proprietar: {0}", + "propietario: {0}", + ), + "groups.add_device" => ( + "Add device", + "Gerät hinzufügen", + "Ajouter appareil", + "Adaugă dispozitiv", + "Añadir dispositivo", + ), + "groups.peer_id_placeholder" => ( + "Device ID (e.g. 123456789)", + "Geräte-ID (z. B. 123456789)", + "ID d'appareil (ex. 123456789)", + "ID dispozitiv (ex. 123456789)", + "ID de dispositivo (p. ej. 123456789)", + ), + "groups.peer_id_required" => ( + "Device ID required", + "Geräte-ID erforderlich", + "ID d'appareil requis", + "ID dispozitiv necesar", + "Se requiere el ID del dispositivo", + ), + "groups.no_device_yet" => ( + "No device '{0}' has reported in yet.", + "Es hat sich noch kein Gerät „{0}\" gemeldet.", + "Aucun appareil « {0} » ne s'est encore manifesté.", + "Niciun dispozitiv „{0}\" nu s-a raportat încă.", + "Ningún dispositivo «{0}» se ha reportado aún.", + ), + + // ---- strategies ---- + "strategies.heading" => ( + "Strategies", + "Strategien", + "Stratégies", + "Strategii", + "Estrategias", + ), + "strategies.tagline" => ( + "Pushed to clients via heartbeat. Use SQL to assign — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", + "Wird Clients per Heartbeat zugestellt. Zur Zuweisung SQL verwenden — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", + "Distribué aux clients via heartbeat. Utilisez SQL pour assigner — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", + "Trimis clienților prin heartbeat. Folosiți SQL pentru atribuire — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", + "Se envía a los clientes por heartbeat. Usa SQL para asignar — strategy_assignments(strategy_id, user_id|device_group_id|peer_id, priority).", + ), + "strategies.create_heading" => ( + "Create strategy", + "Strategie erstellen", + "Créer une stratégie", + "Creează strategie", + "Crear estrategia", + ), + "strategies.name_unique" => ( + "name (unique)", + "Name (eindeutig)", + "nom (unique)", + "nume (unic)", + "nombre (único)", + ), + "strategies.json_obj_required" => ( + "config_options must be a JSON object", + "config_options muss ein JSON-Objekt sein", + "config_options doit être un objet JSON", + "config_options trebuie să fie un obiect JSON", + "config_options debe ser un objeto JSON", + ), + "strategies.invalid_json" => ( + "invalid JSON: {0}", + "ungültiges JSON: {0}", + "JSON invalide : {0}", + "JSON invalid: {0}", + "JSON inválido: {0}", + ), + "strategies.created" => ( + "Strategy '{0}' created.", + "Strategie „{0}\" erstellt.", + "Stratégie « {0} » créée.", + "Strategie „{0}\" creată.", + "Estrategia «{0}» creada.", + ), + "strategies.updated" => ( + "Strategy updated.", + "Strategie aktualisiert.", + "Stratégie mise à jour.", + "Strategie actualizată.", + "Estrategia actualizada.", + ), + "strategies.deleted" => ( + "Strategy deleted.", + "Strategie gelöscht.", + "Stratégie supprimée.", + "Strategie ștearsă.", + "Estrategia eliminada.", + ), + "strategies.no_strategies" => ( + "No strategies yet.", + "Noch keine Strategien.", + "Aucune stratégie.", + "Nu există încă strategii.", + "Aún no hay estrategias.", + ), + "strategies.config_label" => ( + "config_options (JSON object)", + "config_options (JSON-Objekt)", + "config_options (objet JSON)", + "config_options (obiect JSON)", + "config_options (objeto JSON)", + ), + "strategies.confirm_delete" => ( + "Delete strategy {0}? Assignments will be cleaned up too.", + "Strategie {0} löschen? Zuweisungen werden ebenfalls bereinigt.", + "Supprimer la stratégie {0} ? Les affectations seront également nettoyées.", + "Ștergeți strategia {0}? Atribuirile vor fi curățate de asemenea.", + "¿Eliminar la estrategia {0}? También se limpiarán las asignaciones.", + ), + "strategies.id_modified" => ( + "id={0}, modified_at={1}", + "id={0}, modified_at={1}", + "id={0}, modified_at={1}", + "id={0}, modified_at={1}", + "id={0}, modified_at={1}", + ), + + // ---- address books ---- + "ab.heading" => ( + "Address books", + "Adressbücher", + "Carnets d'adresses", + "Agende", + "Libretas de direcciones", + ), + "ab.tagline" => ( + "Personal books are owned by users and managed from their desktop client. Shared books are server-side: create one here, share it with users / rules, and the client picks it up on its next AB sync.", + "Persönliche Adressbücher gehören Benutzern und werden im Desktop-Client verwaltet. Geteilte Adressbücher sind serverseitig: Erstellen Sie hier eines, teilen Sie es mit Benutzern/Regeln, und der Client übernimmt es bei der nächsten AB-Synchronisierung.", + "Les carnets personnels appartiennent aux utilisateurs et sont gérés depuis leur client de bureau. Les carnets partagés sont côté serveur : créez-en un ici, partagez-le avec des utilisateurs / règles, et le client le récupère à la prochaine synchronisation.", + "Agendele personale aparțin utilizatorilor și sunt gestionate din clientul desktop. Agendele partajate sunt pe server: creați una aici, partajați-o cu utilizatori / reguli, iar clientul o preia la următoarea sincronizare.", + "Las libretas personales son de los usuarios y se gestionan desde su cliente de escritorio. Las libretas compartidas están en el servidor: crea una aquí, compártela con usuarios/reglas, y el cliente la recogerá en su próxima sincronización.", + ), + "ab.new_shared" => ( + "New shared book", + "Neues geteiltes Adressbuch", + "Nouveau carnet partagé", + "Agendă partajată nouă", + "Nueva libreta compartida", + ), + "ab.name_required" => ( + "Name is required.", + "Name ist erforderlich.", + "Le nom est requis.", + "Numele este obligatoriu.", + "El nombre es obligatorio.", + ), + "ab.created" => ( + "Shared address book '{0}' created.", + "Geteiltes Adressbuch „{0}\" erstellt.", + "Carnet partagé « {0} » créé.", + "Agenda partajată „{0}\" creată.", + "Libreta compartida «{0}» creada.", + ), + "ab.exists" => ( + "An address book with that name already exists.", + "Ein Adressbuch mit diesem Namen existiert bereits.", + "Un carnet avec ce nom existe déjà.", + "Există deja o agendă cu acest nume.", + "Ya existe una libreta con ese nombre.", + ), + "ab.create_failed" => ( + "Create failed: {0}", + "Erstellen fehlgeschlagen: {0}", + "Échec de la création : {0}", + "Creare eșuată: {0}", + "Error al crear: {0}", + ), + "ab.deleted" => ("Deleted.", "Gelöscht.", "Supprimé.", "Șters.", "Eliminado."), + "ab.not_found" => ( + "Address book not found.", + "Adressbuch nicht gefunden.", + "Carnet d'adresses introuvable.", + "Agenda nu a fost găsită.", + "Libreta de direcciones no encontrada.", + ), + "ab.no_books" => ( + "No address books exist yet.", + "Es existieren noch keine Adressbücher.", + "Aucun carnet n'existe encore.", + "Nu există încă agende.", + "Aún no existen libretas de direcciones.", + ), + "ab.col_owner" => ( + "Owner", + "Besitzer", + "Propriétaire", + "Proprietar", + "Propietario", + ), + "ab.col_kind" => ("Kind", "Art", "Type", "Tip", "Tipo"), + "ab.col_name" => ("Name", "Name", "Nom", "Nume", "Nombre"), + "ab.col_peers" => ("Peers", "Peers", "Pairs", "Peers", "Pares"), + "ab.col_guid" => ("GUID", "GUID", "GUID", "GUID", "GUID"), + "ab.col_created" => ("Created", "Erstellt", "Créé", "Creat", "Creado"), + "ab.kind_personal" => ( + "personal", + "persönlich", + "personnel", + "personal", + "personal", + ), + "ab.kind_shared" => ("shared", "geteilt", "partagé", "partajat", "compartida"), + "ab.manage_shares" => ( + "Manage shares", + "Freigaben verwalten", + "Gérer les partages", + "Gestionează partajări", + "Gestionar compartidos", + ), + "ab.delete_book" => ( + "Delete book", + "Adressbuch löschen", + "Supprimer le carnet", + "Șterge agenda", + "Eliminar libreta", + ), + "ab.confirm_delete_shared" => ( + "Delete shared book '{0}'? Peers, tags, and shares will be removed.", + "Geteiltes Adressbuch „{0}\" löschen? Peers, Tags und Freigaben werden entfernt.", + "Supprimer le carnet partagé « {0} » ? Les pairs, étiquettes et partages seront retirés.", + "Ștergeți agenda partajată „{0}\"? Peer-urile, etichetele și partajările vor fi eliminate.", + "¿Eliminar la libreta compartida «{0}»? Se eliminarán pares, etiquetas y compartidos.", + ), + "ab.confirm_delete_personal" => ( + "Delete {0}'s personal book? This wipes all peers and tags on the server. If {0}'s desktop client is still signed in, it will recreate an empty personal book on its next sync (~30 s).", + "Persönliches Adressbuch von {0} löschen? Damit werden alle Peers und Tags auf dem Server gelöscht. Wenn der Desktop-Client von {0} noch angemeldet ist, wird beim nächsten Sync (~30 s) ein leeres persönliches Adressbuch erstellt.", + "Supprimer le carnet personnel de {0} ? Cela efface tous les pairs et étiquettes sur le serveur. Si le client de bureau de {0} est encore connecté, il recréera un carnet personnel vide à la prochaine synchronisation (~30 s).", + "Ștergeți agenda personală a lui {0}? Aceasta șterge toți peer-ii și etichetele de pe server. Dacă clientul desktop al lui {0} este încă conectat, va recrea o agendă personală goală la următoarea sincronizare (~30 s).", + "¿Eliminar la libreta personal de {0}? Esto borra todos los pares y etiquetas en el servidor. Si el cliente de escritorio de {0} sigue conectado, recreará una libreta personal vacía en la próxima sincronización (~30 s).", + ), + "ab.manage_heading" => ( + "Manage shares", + "Freigaben verwalten", + "Gérer les partages", + "Gestionează partajări", + "Gestionar compartidos", + ), + "ab.add_or_update" => ( + "Add or update a share", + "Freigabe hinzufügen oder aktualisieren", + "Ajouter ou mettre à jour un partage", + "Adaugă sau actualizează o partajare", + "Añadir o actualizar un compartido", + ), + "ab.user_label" => ( + "User", + "Benutzer", + "Utilisateur", + "Utilizator", + "Usuario", + ), + "ab.no_users" => ( + "No users defined", + "Keine Benutzer definiert", + "Aucun utilisateur défini", + "Niciun utilizator definit", + "Sin usuarios definidos", + ), + "ab.existing_will_update" => ( + " (existing — will update rule)", + " (existiert — Regel wird aktualisiert)", + " (existant — la règle sera mise à jour)", + " (existent — regula va fi actualizată)", + " (existente — se actualizará la regla)", + ), + "ab.rule" => ("Rule", "Regel", "Règle", "Regulă", "Regla"), + "ab.rule_read" => ("Read", "Lesen", "Lecture", "Citire", "Lectura"), + "ab.rule_read_write" => ( + "Read + write", + "Lesen + Schreiben", + "Lecture + écriture", + "Citire + scriere", + "Lectura + escritura", + ), + "ab.rule_full" => ( + "Full", + "Vollständig", + "Complet", + "Complet", + "Completo", + ), + "ab.invalid_rule" => ( + "Invalid rule.", + "Ungültige Regel.", + "Règle invalide.", + "Regulă invalidă.", + "Regla inválida.", + ), + "ab.share_saved" => ( + "Share saved.", + "Freigabe gespeichert.", + "Partage enregistré.", + "Partajare salvată.", + "Compartido guardado.", + ), + "ab.share_removed" => ( + "Share removed.", + "Freigabe entfernt.", + "Partage supprimé.", + "Partajare eliminată.", + "Compartido eliminado.", + ), + "ab.current_shares" => ( + "Current shares ({0})", + "Aktuelle Freigaben ({0})", + "Partages actuels ({0})", + "Partajări curente ({0})", + "Compartidos actuales ({0})", + ), + "ab.no_shares" => ( + "No shares yet. The book is invisible to non-owners until you add at least one user share.", + "Noch keine Freigaben. Das Adressbuch ist für Nicht-Besitzer unsichtbar, bis mindestens eine Benutzer-Freigabe hinzugefügt wird.", + "Aucun partage. Le carnet est invisible pour les non-propriétaires jusqu'à ce qu'au moins un partage utilisateur soit ajouté.", + "Nu există partajări. Agenda este invizibilă pentru cei care nu sunt proprietari până când adăugați cel puțin o partajare către un utilizator.", + "Aún no hay compartidos. La libreta es invisible para los no propietarios hasta que añadas al menos un compartido con un usuario.", + ), + "ab.confirm_remove" => ( + "Remove {0}'s access?", + "Zugriff von {0} entfernen?", + "Retirer l'accès de {0} ?", + "Eliminați accesul lui {0}?", + "¿Quitar el acceso de {0}?", + ), + "ab.personal_managed_at_client" => ( + "Personal address books are managed from the desktop client.", + "Persönliche Adressbücher werden im Desktop-Client verwaltet.", + "Les carnets d'adresses personnels sont gérés depuis le client de bureau.", + "Agendele personale sunt gestionate din clientul desktop.", + "Las libretas personales se gestionan desde el cliente de escritorio.", + ), + + // ---- audit ---- + "audit.heading" => ( + "Audit log", + "Audit-Protokoll", + "Journal d'audit", + "Jurnal de audit", + "Registro de auditoría", + ), + "audit.latest" => ( + "Latest {0} rows.", + "Letzte {0} Einträge.", + "Dernières {0} lignes.", + "Ultimele {0} rânduri.", + "Últimas {0} filas.", + ), + "audit.tab_conn" => ( + "Connections", + "Verbindungen", + "Connexions", + "Conexiuni", + "Conexiones", + ), + "audit.tab_file" => ( + "File transfers", + "Dateiübertragungen", + "Transferts de fichiers", + "Transferuri de fișiere", + "Transferencias de archivos", + ), + "audit.tab_alarm" => ("Alarms", "Alarme", "Alarmes", "Alarme", "Alarmas"), + "audit.col_when" => ("When", "Wann", "Quand", "Când", "Cuándo"), + "audit.col_peer" => ("Peer", "Peer", "Pair", "Peer", "Par"), + "audit.col_conn_session" => ( + "Conn / Session", + "Verb. / Sitzung", + "Conn / Session", + "Conn / Sesiune", + "Conn / Sesión", + ), + "audit.col_ip" => ("IP", "IP", "IP", "IP", "IP"), + "audit.col_action" => ("Action", "Aktion", "Action", "Acțiune", "Acción"), + "audit.col_note" => ("Note", "Notiz", "Note", "Notă", "Nota"), + "audit.col_direction" => ( + "Direction", + "Richtung", + "Direction", + "Direcție", + "Dirección", + ), + "audit.col_path" => ("Path", "Pfad", "Chemin", "Cale", "Ruta"), + "audit.col_remote" => ("Remote", "Remote", "Distant", "Remote", "Remoto"), + "audit.col_type" => ("Type", "Typ", "Type", "Tip", "Tipo"), + "audit.col_info" => ("Info", "Info", "Info", "Info", "Info"), + "audit.no_conn" => ( + "No connection audit rows yet.", + "Noch keine Verbindungs-Audit-Einträge.", + "Aucune ligne d'audit de connexion.", + "Nu există încă rânduri de audit pentru conexiuni.", + "Aún no hay filas de auditoría de conexiones.", + ), + "audit.no_file" => ( + "No file-transfer audit rows yet.", + "Noch keine Dateiübertragungs-Audit-Einträge.", + "Aucune ligne d'audit de transfert de fichiers.", + "Nu există încă rânduri de audit pentru transferuri.", + "Aún no hay filas de auditoría de transferencias de archivos.", + ), + "audit.no_alarm" => ( + "No alarm audit rows yet.", + "Noch keine Alarm-Audit-Einträge.", + "Aucune ligne d'audit d'alarme.", + "Nu există încă rânduri de audit pentru alarme.", + "Aún no hay filas de auditoría de alarmas.", + ), + "audit.dir_to_remote" => ( + "→ remote", + "→ remote", + "→ distant", + "→ remote", + "→ remoto", + ), + "audit.dir_from_remote" => ( + "← remote", + "← remote", + "← distant", + "← remote", + "← remoto", + ), + + // ---- profile ---- + "profile.heading" => ("Profile", "Profil", "Profil", "Profil", "Perfil"), + "profile.signed_in_as" => ( + "Signed in as", + "Angemeldet als", + "Connecté en tant que", + "Conectat ca", + "Conectado como", + ), + "profile.info_heading" => ( + "Profile info", + "Profilinformationen", + "Informations du profil", + "Informații profil", + "Información del perfil", + ), + "profile.display_name" => ( + "Display name", + "Anzeigename", + "Nom affiché", + "Nume afișat", + "Nombre visible", + ), + "profile.email" => ("Email", "E-Mail", "E-mail", "Email", "Correo"), + "profile.profile_updated" => ( + "Profile updated.", + "Profil aktualisiert.", + "Profil mis à jour.", + "Profil actualizat.", + "Perfil actualizado.", + ), + "profile.password_heading" => ( + "Change password", + "Passwort ändern", + "Changer le mot de passe", + "Schimbă parola", + "Cambiar contraseña", + ), + "profile.password_oidc" => ( + "Password", + "Passwort", + "Mot de passe", + "Parolă", + "Contraseña", + ), + "profile.password_oidc_msg" => ( + "Your account signs in via your organisation's identity provider — there's no local password to change here.", + "Ihr Konto meldet sich über den Identitätsanbieter Ihrer Organisation an — hier gibt es kein lokales Passwort zum Ändern.", + "Votre compte se connecte via le fournisseur d'identité de votre organisation — il n'y a pas de mot de passe local à changer ici.", + "Contul dvs. se autentifică prin furnizorul de identitate al organizației dvs. — nu există o parolă locală de schimbat aici.", + "Tu cuenta inicia sesión a través del proveedor de identidad de tu organización — no hay una contraseña local que cambiar aquí.", + ), + "profile.current_password" => ( + "current password", + "aktuelles Passwort", + "mot de passe actuel", + "parola curentă", + "contraseña actual", + ), + "profile.new_password" => ( + "new password", + "neues Passwort", + "nouveau mot de passe", + "parolă nouă", + "nueva contraseña", + ), + "profile.confirm_new" => ( + "confirm new", + "neues bestätigen", + "confirmer", + "confirmă", + "confirmar nueva", + ), + "profile.update_password" => ( + "Update password", + "Passwort aktualisieren", + "Mettre à jour le mot de passe", + "Actualizează parola", + "Actualizar contraseña", + ), + "profile.password_updated" => ( + "Password updated.", + "Passwort aktualisiert.", + "Mot de passe mis à jour.", + "Parolă actualizată.", + "Contraseña actualizada.", + ), + "profile.password_oidc_change" => ( + "Your account signs in via the identity provider — change the password there.", + "Ihr Konto meldet sich über den Identitätsanbieter an — ändern Sie das Passwort dort.", + "Votre compte se connecte via le fournisseur d'identité — changez le mot de passe là-bas.", + "Contul dvs. se autentifică prin furnizorul de identitate — schimbați parola acolo.", + "Tu cuenta inicia sesión a través del proveedor de identidad — cambia la contraseña ahí.", + ), + "profile.password_min" => ( + "New password must be at least 4 characters.", + "Das neue Passwort muss mindestens 4 Zeichen lang sein.", + "Le nouveau mot de passe doit contenir au moins 4 caractères.", + "Parola nouă trebuie să aibă cel puțin 4 caractere.", + "La nueva contraseña debe tener al menos 4 caracteres.", + ), + "profile.password_mismatch" => ( + "New password and confirmation don't match.", + "Neues Passwort und Bestätigung stimmen nicht überein.", + "Le nouveau mot de passe et la confirmation ne correspondent pas.", + "Parola nouă și confirmarea nu se potrivesc.", + "La nueva contraseña y la confirmación no coinciden.", + ), + "profile.current_incorrect" => ( + "Current password is incorrect.", + "Aktuelles Passwort ist falsch.", + "Le mot de passe actuel est incorrect.", + "Parola curentă este incorectă.", + "La contraseña actual es incorrecta.", + ), + "profile.tfa" => ( + "Two-factor authentication", + "Zwei-Faktor-Authentifizierung", + "Authentification à deux facteurs", + "Autentificare în doi pași", + "Autenticación en dos pasos", + ), + "profile.tfa_oidc_msg" => ( + "MFA is managed by your identity provider — local TOTP isn't used for OIDC sign-ins.", + "MFA wird von Ihrem Identitätsanbieter verwaltet — lokales TOTP wird für OIDC-Anmeldungen nicht verwendet.", + "La MFA est gérée par votre fournisseur d'identité — le TOTP local n'est pas utilisé pour les connexions OIDC.", + "MFA este gestionată de furnizorul de identitate — TOTP local nu este folosit pentru autentificările OIDC.", + "La MFA la gestiona tu proveedor de identidad — el TOTP local no se usa en los inicios de sesión OIDC.", + ), + "profile.tfa_enrolled_msg" => ( + "Sign-ins require a 6-digit code from your authenticator.", + "Anmeldungen erfordern einen 6-stelligen Code aus Ihrer Authenticator-App.", + "Les connexions nécessitent un code à 6 chiffres de votre authentificateur.", + "Autentificările necesită un cod din 6 cifre din aplicația de autentificare.", + "Los inicios de sesión requieren un código de 6 dígitos de tu autenticador.", + ), + "profile.tfa_disable" => ( + "Disable TOTP", + "TOTP deaktivieren", + "Désactiver TOTP", + "Dezactivează TOTP", + "Deshabilitar TOTP", + ), + "profile.tfa_confirm_remove" => ( + "Remove TOTP from your account?", + "TOTP aus Ihrem Konto entfernen?", + "Supprimer TOTP de votre compte ?", + "Eliminați TOTP din contul dvs.?", + "¿Quitar TOTP de tu cuenta?", + ), + "profile.tfa_intro" => ( + "Add a TOTP authenticator (1Password, Authy, Google Authenticator, etc.) so sign-ins also require a 6-digit code.", + "Fügen Sie einen TOTP-Authenticator hinzu (1Password, Authy, Google Authenticator usw.), damit Anmeldungen zusätzlich einen 6-stelligen Code erfordern.", + "Ajoutez un authentificateur TOTP (1Password, Authy, Google Authenticator, etc.) pour que les connexions nécessitent aussi un code à 6 chiffres.", + "Adăugați un autentificator TOTP (1Password, Authy, Google Authenticator etc.) pentru ca autentificările să necesite și un cod din 6 cifre.", + "Añade un autenticador TOTP (1Password, Authy, Google Authenticator, etc.) para que los inicios de sesión también requieran un código de 6 dígitos.", + ), + "profile.tfa_enroll" => ( + "Enroll TOTP", + "TOTP registrieren", + "Activer TOTP", + "Activează TOTP", + "Activar TOTP", + ), + "profile.tfa_already" => ( + "You already have TOTP enrolled. Disable it first if you want to re-enroll.", + "Sie haben TOTP bereits registriert. Deaktivieren Sie es zuerst, wenn Sie es erneut registrieren möchten.", + "TOTP est déjà activé. Désactivez-le d'abord pour le réactiver.", + "TOTP este deja activat. Dezactivați-l mai întâi dacă doriți să îl reactivați.", + "Ya tienes TOTP activado. Desactívalo primero si quieres volver a activarlo.", + ), + "profile.tfa_confirm_heading" => ( + "Confirm TOTP enrollment", + "TOTP-Registrierung bestätigen", + "Confirmer l'activation TOTP", + "Confirmă activarea TOTP", + "Confirmar activación TOTP", + ), + "profile.tfa_confirm_intro" => ( + "Scan the QR code with your authenticator app, then enter the 6-digit code it shows to confirm. Nothing is enrolled until you submit a valid code.", + "Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben Sie dann den 6-stelligen Code zur Bestätigung ein. Es wird nichts gespeichert, bis Sie einen gültigen Code übermitteln.", + "Scannez le QR code avec votre application d'authentification, puis saisissez le code à 6 chiffres pour confirmer. Rien n'est activé tant que vous n'avez pas soumis un code valide.", + "Scanați codul QR cu aplicația dvs. de autentificare, apoi introduceți codul din 6 cifre pentru a confirma. Nimic nu este activat până nu trimiteți un cod valid.", + "Escanea el código QR con tu aplicación de autenticación, luego introduce el código de 6 dígitos para confirmar. Nada queda activado hasta que envíes un código válido.", + ), + "profile.tfa_secret_manual" => ( + "Secret (manual entry):", + "Geheimnis (manuelle Eingabe):", + "Secret (saisie manuelle) :", + "Secret (introducere manuală):", + "Secreto (introducción manual):", + ), + "profile.tfa_missing_secret" => ( + "Missing secret in confirm form.", + "Geheimnis im Bestätigungsformular fehlt.", + "Secret manquant dans le formulaire de confirmation.", + "Lipsește secretul din formularul de confirmare.", + "Falta el secreto en el formulario de confirmación.", + ), + "profile.tfa_bad_code" => ( + "Code didn't match. Try again — make sure the time on the authenticator device is in sync.", + "Code stimmt nicht. Versuchen Sie es erneut — stellen Sie sicher, dass die Uhrzeit auf dem Authenticator-Gerät synchronisiert ist.", + "Le code ne correspond pas. Réessayez — assurez-vous que l'heure de l'appareil d'authentification est synchronisée.", + "Codul nu se potrivește. Încercați din nou — asigurați-vă că ora de pe dispozitivul de autentificare este sincronizată.", + "El código no coincide. Inténtalo de nuevo — asegúrate de que la hora del dispositivo de autenticación esté sincronizada.", + ), + "profile.tfa_enrolled" => ( + "TOTP enrolled. Future sign-ins will require a 6-digit code.", + "TOTP registriert. Zukünftige Anmeldungen erfordern einen 6-stelligen Code.", + "TOTP activé. Les connexions futures nécessiteront un code à 6 chiffres.", + "TOTP activat. Autentificările viitoare vor necesita un cod din 6 cifre.", + "TOTP activado. Los próximos inicios de sesión requerirán un código de 6 dígitos.", + ), + "profile.tfa_current_pw_incorrect" => ( + "Current password is incorrect — TOTP not removed.", + "Aktuelles Passwort ist falsch — TOTP wurde nicht entfernt.", + "Le mot de passe actuel est incorrect — TOTP non supprimé.", + "Parola curentă este incorectă — TOTP nu a fost eliminat.", + "La contraseña actual es incorrecta — TOTP no se eliminó.", + ), + "profile.tfa_removed" => ( + "TOTP removed.", + "TOTP entfernt.", + "TOTP supprimé.", + "TOTP eliminat.", + "TOTP eliminado.", + ), + + // ---- deploy ---- + "deploy.heading" => ( + "Deploy", + "Bereitstellen", + "Déployer", + "Implementare", + "Implementación", + ), + "deploy.intro" => ( + "Generate a config blob the stock client accepts via rustdesk --config <blob>, or the equivalent renamed-installer filename. The public key below is read from id_ed25519.pub on the server; override if you bootstrapped a custom keypair.", + "Erzeugen Sie ein Konfig-Blob, das der Standard-Client per rustdesk --config <blob> akzeptiert, oder den entsprechenden umbenannten Installer-Dateinamen. Der öffentliche Schlüssel unten wird aus id_ed25519.pub auf dem Server gelesen; überschreiben Sie ihn bei einem benutzerdefinierten Schlüsselpaar.", + "Générez un blob de configuration que le client standard accepte via rustdesk --config <blob>, ou le nom d'installeur renommé équivalent. La clé publique ci-dessous est lue depuis id_ed25519.pub sur le serveur ; remplacez-la si vous avez initialisé une paire de clés personnalisée.", + "Generați un blob de configurare pe care clientul standard îl acceptă prin rustdesk --config <blob>, sau numele de fișier echivalent al instalatorului redenumit. Cheia publică de mai jos este citită din id_ed25519.pub de pe server; suprascrieți dacă ați inițializat o pereche de chei personalizată.", + "Genera un blob de configuración que el cliente estándar acepta vía rustdesk --config <blob>, o el nombre de archivo equivalente del instalador renombrado. La clave pública de abajo se lee de id_ed25519.pub en el servidor; sobrescríbela si inicializaste un par de claves personalizado.", + ), + "deploy.host_label" => ( + "Rendezvous host (required)", + "Rendezvous-Host (erforderlich)", + "Hôte rendezvous (requis)", + "Gazdă rendezvous (obligatoriu)", + "Host rendezvous (obligatorio)", + ), + "deploy.host_hint" => ( + "The hostname or IP clients reach hbbs at (TCP/UDP 21116).", + "Der Hostname oder die IP, über die Clients hbbs erreichen (TCP/UDP 21116).", + "Le nom d'hôte ou l'IP par laquelle les clients atteignent hbbs (TCP/UDP 21116).", + "Numele de gazdă sau IP-ul prin care clienții ajung la hbbs (TCP/UDP 21116).", + "El nombre de host o la IP por la que los clientes llegan a hbbs (TCP/UDP 21116).", + ), + "deploy.api_label" => ( + "API URL (optional)", + "API-URL (optional)", + "URL API (optionnel)", + "URL API (opțional)", + "URL de la API (opcional)", + ), + "deploy.api_hint" => ( + "Full URL of this admin/login API. Defaults to https://<host>; edit if your API runs on a different scheme/port. Leave blank to disable login on the client.", + "Vollständige URL dieser Admin-/Login-API. Standard ist https://<host>; bearbeiten Sie sie, wenn Ihre API auf einem anderen Schema/Port läuft. Lassen Sie das Feld leer, um die Anmeldung im Client zu deaktivieren.", + "URL complète de cette API admin/login. Par défaut https://<host> ; modifiez si votre API fonctionne sur un autre schéma/port. Laissez vide pour désactiver la connexion sur le client.", + "URL-ul complet al acestei API admin/login. Implicit https://<host>; editați dacă API-ul rulează pe un alt protocol/port. Lăsați gol pentru a dezactiva autentificarea pe client.", + "URL completa de esta API admin/login. Por defecto https://<host>; edítala si tu API funciona en otro esquema/puerto. Déjala en blanco para desactivar el inicio de sesión en el cliente.", + ), + "deploy.relay_label" => ( + "Relay host (optional)", + "Relay-Host (optional)", + "Hôte relais (optionnel)", + "Gazdă relay (opțional)", + "Host relay (opcional)", + ), + "deploy.relay_hint" => ( + "Only set if hbbr runs on a separate host; otherwise leave blank.", + "Nur ausfüllen, wenn hbbr auf einem separaten Host läuft; ansonsten leer lassen.", + "À renseigner uniquement si hbbr fonctionne sur un hôte séparé ; sinon laisser vide.", + "Setați doar dacă hbbr rulează pe o gazdă separată; altfel lăsați gol.", + "Establece esto solo si hbbr se ejecuta en un host distinto; en caso contrario, déjalo en blanco.", + ), + "deploy.key_label" => ( + "Public key", + "Öffentlicher Schlüssel", + "Clé publique", + "Cheie publică", + "Clave pública", + ), + "deploy.key_hint" => ( + "Base64 contents of id_ed25519.pub.", + "Base64-Inhalt von id_ed25519.pub.", + "Contenu base64 de id_ed25519.pub.", + "Conținut base64 al id_ed25519.pub.", + "Contenido en base64 de id_ed25519.pub.", + ), + "deploy.generate" => ( + "Generate", + "Generieren", + "Générer", + "Generează", + "Generar", + ), + "deploy.host_required" => ( + "Host is required.", + "Host ist erforderlich.", + "L'hôte est requis.", + "Gazda este obligatorie.", + "El host es obligatorio.", + ), + "deploy.artifact_heading" => ( + "Deployment artifact", + "Bereitstellungs-Artefakt", + "Artefact de déploiement", + "Artefact de implementare", + "Artefacto de implementación", + ), + "deploy.artifact_intro" => ( + "Pick whichever path fits your rollout. All three produce the same client config.", + "Wählen Sie den Weg, der zu Ihrem Rollout passt. Alle drei erzeugen die gleiche Client-Konfiguration.", + "Choisissez le chemin qui correspond à votre déploiement. Les trois produisent la même configuration client.", + "Alegeți calea care se potrivește implementării. Toate trei produc aceeași configurație de client.", + "Elige la opción que mejor se ajuste a tu despliegue. Las tres producen la misma configuración del cliente.", + ), + "deploy.cmd_a_label" => ( + "A. Post-install command (Windows, Administrator)", + "A. Befehl nach Installation (Windows, Administrator)", + "A. Commande post-installation (Windows, administrateur)", + "A. Comandă post-instalare (Windows, administrator)", + "A. Comando post-instalación (Windows, administrador)", + ), + "deploy.cmd_a_hint" => ( + "Requires the client to be installed and running as admin. Equivalent on macOS/Linux: {0}.", + "Erfordert, dass der Client installiert ist und als Administrator läuft. Entsprechend auf macOS/Linux: {0}.", + "Nécessite que le client soit installé et exécuté en tant qu'admin. Équivalent sur macOS/Linux : {0}.", + "Necesită ca clientul să fie instalat și rulat ca administrator. Echivalent pe macOS/Linux: {0}.", + "Requiere que el cliente esté instalado y ejecutándose como administrador. Equivalente en macOS/Linux: {0}.", + ), + "deploy.cmd_b_label" => ( + "B. Renamed installer (drop-in)", + "B. Umbenannter Installer (Drop-in)", + "B. Installeur renommé (drop-in)", + "B. Instalator redenumit (drop-in)", + "B. Instalador renombrado (drop-in)", + ), + "deploy.cmd_b_hint" => ( + "Rename the official RustDesk installer to this exact name and run it; the client reads its own filename on first launch and writes the config into the registry.", + "Benennen Sie den offiziellen RustDesk-Installer genau in diesen Namen um und führen Sie ihn aus; der Client liest beim ersten Start seinen eigenen Dateinamen und schreibt die Konfiguration in die Registry.", + "Renommez l'installeur officiel RustDesk avec exactement ce nom et lancez-le ; le client lit son propre nom de fichier au premier lancement et écrit la configuration dans le registre.", + "Redenumiți instalatorul oficial RustDesk exact la acest nume și rulați-l; clientul citește propriul nume de fișier la prima lansare și scrie configurația în registru.", + "Renombra el instalador oficial de RustDesk exactamente con este nombre y ejecútalo; el cliente lee su propio nombre de archivo en el primer arranque y escribe la configuración en el registro.", + ), + "deploy.cmd_c_label" => ( + "C. HelloAgent (Windows, MDM one-liner)", + "C. HelloAgent (Windows, MDM-Einzeiler)", + "C. HelloAgent (Windows, ligne unique MDM)", + "C. HelloAgent (Windows, comandă unică MDM)", + "C. HelloAgent (Windows, comando único MDM)", + ), + "deploy.cmd_c_hint" => ( + "Headless agent — registers the Windows service and imports this config in a single command. Run elevated.", + "Headless-Agent — registriert den Windows-Dienst und importiert diese Konfiguration in einem einzigen Befehl. Mit erhöhten Rechten ausführen.", + "Agent sans interface — enregistre le service Windows et importe cette configuration en une seule commande. Exécuter en mode élevé.", + "Agent fără interfață — înregistrează serviciul Windows și importă această configurație într-o singură comandă. Rulați cu privilegii ridicate.", + "Agente sin interfaz — registra el servicio de Windows e importa esta configuración en un solo comando. Ejecútalo con privilegios elevados.", + ), + "deploy.raw_blob" => ( + "Raw blob", + "Rohes Blob", + "Blob brut", + "Blob brut", + "Blob sin procesar", + ), + + // unknown key — return a stable placeholder so callers see something + // they can grep for. We can't return `key` itself since the function + // signature promises a `'static` borrow. + _ => return "", + }; + match lang { + Lang::En => en, + Lang::De => de, + Lang::Fr => fr, + Lang::Ro => ro, + Lang::Es => es, + } +} + +/// One-arg formatted lookup. Replaces `{0}` in the template. +pub fn tf1(lang: Lang, key: &str, a: &str) -> String { + t(lang, key).replace("{0}", a) +} + +/// Two-arg formatted lookup. Replaces `{0}` and `{1}` in the template. +pub fn tf2(lang: Lang, key: &str, a: &str, b: &str) -> String { + t(lang, key).replace("{0}", a).replace("{1}", b) +} diff --git a/src/api/admin/me.rs b/src/api/admin/me.rs index 25b5b87..2893322 100644 --- a/src/api/admin/me.rs +++ b/src/api/admin/me.rs @@ -3,14 +3,16 @@ //! the cookie isn't valid, the AuthedUser extractor 401s and the page-level //! HTMX response handler bounces back to the login form. +use crate::api::admin::i18n::{t, Lang}; use crate::api::error::ApiError; use crate::api::middleware::AuthedUser; use axum::response::Html; -pub async fn me(user: AuthedUser) -> Result, ApiError> { +pub async fn me(user: AuthedUser, lang: Lang) -> Result, ApiError> { Ok(Html(format!( - "Signed in as {}", - html_escape(&user.name) + "{label} {name}", + label = t(lang, "nav.signed_in_as"), + name = html_escape(&user.name), ))) } diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index fd06cfc..0468262 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -16,16 +16,19 @@ //! /admin/pages/* GET fragments (one per page) pub mod auth; +pub mod i18n; pub mod me; pub mod oidc_login; pub mod pages; -use axum::http::{header, HeaderValue, StatusCode}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; use std::sync::Arc; +use crate::api::admin::i18n::{lang_from_headers, t, Lang}; + /// Files embedded into the binary. Paths are relative to this source file /// per `include_str!`. Adding a new HTML asset = one new entry here. const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html"); @@ -218,16 +221,105 @@ pub fn build(state: Arc) -> Option { Some(r) } -async fn serve_index() -> Response { - html_response(INDEX_HTML) +async fn serve_index(headers: HeaderMap) -> Response { + let lang = lang_from_headers(&headers); + html_response_owned(render_index(lang)) } -async fn serve_login() -> Response { - html_response(LOGIN_HTML) +async fn serve_login(headers: HeaderMap) -> Response { + let lang = lang_from_headers(&headers); + html_response_owned(render_login(lang)) } -fn html_response(body: &'static str) -> Response { - // We hand back `Html<&'static str>` so axum sets `text/html` for us. +/// Apply i18n placeholders to the embedded `index.html` template. +fn render_index(lang: Lang) -> String { + let body = INDEX_HTML + .replace("{{LANG_CODE}}", lang.code()) + .replace("{{T_APP_TITLE}}", t(lang, "shell.app_title")) + .replace("{{T_NAV_USERS}}", t(lang, "nav.users")) + .replace("{{T_NAV_DEVICES}}", t(lang, "nav.devices")) + .replace("{{T_NAV_GROUPS}}", t(lang, "nav.groups")) + .replace("{{T_NAV_STRATEGIES}}", t(lang, "nav.strategies")) + .replace("{{T_NAV_AB}}", t(lang, "nav.address_books")) + .replace("{{T_NAV_AUDIT}}", t(lang, "nav.audit")) + .replace("{{T_NAV_DEPLOY}}", t(lang, "nav.deploy")) + .replace("{{T_NAV_PROFILE}}", t(lang, "nav.profile")) + .replace("{{T_NAV_SIGNOUT}}", t(lang, "nav.signout")) + .replace("{{T_LANGUAGE}}", t(lang, "common.language")) + .replace("{{T_LOADING}}", t(lang, "common.loading")); + apply_lang_selected(body, lang) +} + +/// Apply i18n placeholders to the embedded `login.html` template. +fn render_login(lang: Lang) -> String { + let body = LOGIN_HTML + .replace("{{LANG_CODE}}", lang.code()) + .replace("{{T_TITLE}}", t(lang, "login.title")) + .replace("{{T_SUBTITLE}}", t(lang, "login.subtitle")) + .replace("{{T_USERNAME}}", t(lang, "login.username")) + .replace("{{T_PASSWORD}}", t(lang, "login.password")) + .replace("{{T_TOTP_LABEL}}", t(lang, "login.totp_label")) + .replace("{{T_SIGNIN}}", t(lang, "login.signin")) + .replace("{{T_OR}}", t(lang, "login.or")) + .replace("{{T_LANGUAGE}}", t(lang, "common.language")) + .replace( + "{{T_SIGNIN_WITH_JSON}}", + &json_string(t(lang, "login.signin_with")), + ); + apply_lang_selected(body, lang) +} + +/// Inject `selected` into the matching `