diff --git a/Phone App/README.md b/Phone App/README.md index 9fcb9dd..4dd4512 100644 --- a/Phone App/README.md +++ b/Phone App/README.md @@ -89,7 +89,11 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an asks first, with the number of cars the file actually holds. Auth relays PocketBase's own stateless tokens, so there is no per-device session list to show or revoke. - **Users (admin)** — user management tab (list / create / role / reset password - / delete), shown only for the admin role. + / delete), shown only for the admin role. A blocked control says why rather + than only greying out: long-press a locked role picker, and the overflow menu + carries the reason under the action. Superadmins also choose which + organization a new account lands in (or none at all); an admin gets no picker, + because the server puts their members in their own organization regardless. Sharing/ownership: `Car.access` drives `isOwner` / `canWrite` / `isReadOnly` getters that gate the UI, mirroring the server's access checks. @@ -115,9 +119,9 @@ tag the phone cannot render can still arrive from the web. `format.dart` resolve through a supported-language check and falls back to `en-US` instead of throwing; `test/models_format_test.dart` covers it. -Every screen reads its text from the language files. The one gap left is the -Integrations and charger-control strings, which the web app shares — so it -belongs to both apps at once. See [TRANSLATIONS.md](../TRANSLATIONS.md). +Every screen reads its text from the language files, and `flutter test` fails if +a key exists in English but not in Polish or Danish. See +[TRANSLATIONS.md](../TRANSLATIONS.md). ## Biometric / face sign-in & app lock diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 7b89384..395f45e 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -113,6 +113,24 @@ "full": "Optaget", "offPeak": "uden for spidsbelastning", "homeCharger": "Hjemmelader" + }, + "control": { + "title": "Laderstyring", + "connected": "Forbundet", + "disconnected": "Offline", + "serialPlaceholder": "Laderens serienummer (f.eks. A5191-XXXXXXXX)", + "refresh": "Opdater", + "status": "Stik", + "meter": "Energi", + "start": "Start opladning", + "stop": "Stop opladning", + "limit": "Strømgrænse", + "applyLimit": "Anvend grænse", + "clearLimit": "Ryd grænse", + "reset": "Genstart laderen", + "resetConfirm": "Genstart denne lader nu? En igangværende opladning bliver afbrudt. Indtast din adgangskode igen for at bekræfte.", + "resetPassword": "Din kontoadgangskode", + "connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer)." } }, "settings": { @@ -244,6 +262,62 @@ "exported": "Gemte {file}", "open": "Åbn", "openFailed": "Filen kunne ikke åbnes." + }, + "integrations": { + "title": "Integrationer", + "subtitle": "Forbind eksterne tjenester til din konto.", + "toyotaDesc": "Log ind med din MyToyota-konto for at hente køretøjsdata fra Toyota Connected Europe. Kun Europa.", + "enable": "Aktivér for min konto", + "enableOrg": "Aktivér for organisationen", + "unavailable": "Denne integration er slået fra af administratoren.", + "orgDisabled": "Denne integration er slået fra for din organisation.", + "scopeMy": "Mine indstillinger", + "scopeOrg": "Organisation", + "scopeHint": "Organisationens indstillinger gælder for alle i din organisation, der lader et felt stå tomt.", + "readOnly": "Denne integrations fælles indstillinger administreres i API-serverens panel; her er de skrivebeskyttede.", + "email": "MyToyota-e-mail", + "password": "MyToyota-adgangskode", + "brand": "Mærke", + "inherited": "Sat på et højere niveau", + "inheritedFrom": "Nedarvet fra {source}", + "sourceGlobal": "administratoren", + "sourceOrg": "din organisation", + "sourceUser": "dig", + "passwordKeep": "Lad feltet stå tomt for at beholde den gemte adgangskode.", + "test": "Test forbindelsen", + "testing": "Tester…", + "save": "Gem", + "saved": "Gemt ✓", + "connected": "Forbundet", + "notConnected": "Ikke forbundet", + "ankerSolixDesc": "Log ind med din Anker-konto for at hente laderdata fra Anker Solix-skyen.", + "ankerEmail": "E-mail til Anker-konto", + "ankerPassword": "Adgangskode til Anker-konto", + "country": "Land", + "countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).", + "controlMode": "Styringstilstand", + "controlModeHint": "Hvordan DriverVault styrer laderen over OCPP.", + "controlOff": "Fra (kun overvågning)", + "controlOwn": "Eget CSMS (fuld styring)", + "controlProxy": "Proxy-CSMS (videresendelse + styring)", + "controlTitle": "Laderstyring (OCPP)", + "controlOwnHint": "Laderen forbinder direkte til DriverVault som sit centralsystem. Peg laderens OCPP-backend på endepunktet nedenfor.", + "controlProxyHint": "DriverVault videresender til Ankers sky og kan indsætte kommandoer. Peg laderens OCPP-backend på endepunktet nedenfor.", + "controlProvisionSteps": "I Anker-appen (eller laderens OCPP-indstillinger) sættes OCPP-backend-URL'en til endepunktet og autorisationsnøglen til tokenet ovenfor.", + "chargerSerial": "Laderens serienummer", + "controlCheck": "Tjek status", + "controlGenerate": "Generér token", + "controlEndpoint": "OCPP-endepunkt", + "controlToken": "Autorisationstoken", + "controlTokenOnce": "Kopiér dette token nu — det vises kun én gang og kan ikke hentes frem igen.", + "controlRevoke": "Tilbagekald token", + "controlRevokeConfirm": "Tilbagekald denne laders styringstoken? Den bliver afbrudt og kan ikke forbinde igen, før du genererer et nyt.", + "controlConnected": "Forbundet til styrings-backend", + "controlDisconnected": "Ikke forbundet", + "toyota": "Toyota Connected (MyToyota)", + "brandToyota": "Toyota", + "brandLexus": "Lexus", + "ankerSolix": "Anker Solix (V1 Smart EV Charger)" } }, "status": { diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 0a44b3a..781a4a3 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -117,6 +117,24 @@ "full": "Zajęte", "offPeak": "poza szczytem", "homeCharger": "Ładowarka domowa" + }, + "control": { + "title": "Sterowanie ładowarką", + "connected": "Połączono", + "disconnected": "Offline", + "serialPlaceholder": "Numer seryjny ładowarki (np. A5191-XXXXXXXX)", + "refresh": "Odśwież", + "status": "Złącze", + "meter": "Energia", + "start": "Rozpocznij ładowanie", + "stop": "Zatrzymaj ładowanie", + "limit": "Limit prądu", + "applyLimit": "Zastosuj limit", + "clearLimit": "Usuń limit", + "reset": "Zrestartuj ładowarkę", + "resetConfirm": "Zrestartować teraz tę ładowarkę? Trwająca sesja ładowania zostanie przerwana. Wpisz ponownie hasło, aby potwierdzić.", + "resetPassword": "Hasło do Twojego konta", + "connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje)." } }, "settings": { @@ -248,6 +266,62 @@ "exported": "Zapisano {file}", "open": "Otwórz", "openFailed": "Nie udało się otworzyć pliku." + }, + "integrations": { + "title": "Integracje", + "subtitle": "Połącz zewnętrzne usługi ze swoim kontem.", + "toyotaDesc": "Zaloguj się kontem MyToyota, aby pobierać dane pojazdu z Toyota Connected Europe. Tylko Europa.", + "enable": "Włącz dla mojego konta", + "enableOrg": "Włącz dla organizacji", + "unavailable": "Ta integracja została wyłączona przez administratora.", + "orgDisabled": "Ta integracja jest wyłączona dla Twojej organizacji.", + "scopeMy": "Moje ustawienia", + "scopeOrg": "Organizacja", + "scopeHint": "Ustawienia organizacji obowiązują każdego w Twojej organizacji, kto pozostawi pole puste.", + "readOnly": "Wspólnymi ustawieniami tej integracji zarządzasz w panelu API Servera; tutaj są tylko do odczytu.", + "email": "E-mail MyToyota", + "password": "Hasło MyToyota", + "brand": "Marka", + "inherited": "Ustawione na wyższym poziomie", + "inheritedFrom": "Odziedziczone od: {source}", + "sourceGlobal": "administratora", + "sourceOrg": "Twojej organizacji", + "sourceUser": "Ciebie", + "passwordKeep": "Pozostaw puste, aby zachować zapisane hasło.", + "test": "Testuj połączenie", + "testing": "Testowanie…", + "save": "Zapisz", + "saved": "Zapisano ✓", + "connected": "Połączono", + "notConnected": "Nie połączono", + "ankerSolixDesc": "Zaloguj się kontem Anker, aby pobierać dane ładowarki EV z chmury Anker Solix.", + "ankerEmail": "E-mail konta Anker", + "ankerPassword": "Hasło konta Anker", + "country": "Kraj", + "countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).", + "controlMode": "Tryb sterowania", + "controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką przez OCPP.", + "controlOff": "Wyłączone (tylko monitorowanie)", + "controlOwn": "Własny CSMS (pełne sterowanie)", + "controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)", + "controlTitle": "Sterowanie ładowarką (OCPP)", + "controlOwnHint": "Ładowarka łączy się bezpośrednio z DriverVault jako swoim systemem centralnym. Ustaw backend OCPP ładowarki na poniższy adres.", + "controlProxyHint": "DriverVault przekazuje ruch do chmury Anker i może wysyłać własne polecenia. Ustaw backend OCPP ładowarki na poniższy adres.", + "controlProvisionSteps": "W aplikacji Anker (lub w ustawieniach OCPP ładowarki) ustaw adres backendu OCPP na powyższy endpoint, a klucz autoryzacji na powyższy token.", + "chargerSerial": "Numer seryjny ładowarki", + "controlCheck": "Sprawdź status", + "controlGenerate": "Wygeneruj token", + "controlEndpoint": "Endpoint OCPP", + "controlToken": "Token autoryzacji", + "controlTokenOnce": "Skopiuj ten token teraz — jest pokazywany tylko raz i nie da się go później odzyskać.", + "controlRevoke": "Unieważnij token", + "controlRevokeConfirm": "Unieważnić token sterowania tej ładowarki? Rozłączy się i nie połączy ponownie, dopóki nie wygenerujesz nowego.", + "controlConnected": "Połączono z backendem sterowania", + "controlDisconnected": "Nie połączono", + "toyota": "Toyota Connected (MyToyota)", + "brandToyota": "Toyota", + "brandLexus": "Lexus", + "ankerSolix": "Anker Solix (V1 Smart EV Charger)" } }, "status": { diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index 91c146c..c36ee47 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -180,10 +180,24 @@ class ApiClient { return items.map((e) => AdminUser.fromJson(Map.from(e))).toList(); } - Future createUser( - {required String email, required String password, String? name, String role = "user"}) async { - final data = await _send("POST", "/users", - body: {"email": email, "password": password, "name": name ?? "", "role": role}); + /// Creates a user. [organization] is the superadmin's choice of tenant — an + /// empty string deliberately means "no organization". Omit it entirely for an + /// admin: the server forces its own org on their members, so sending anything + /// would be noise the server ignores. + Future createUser({ + required String email, + required String password, + String? name, + String role = "user", + String? organization, + }) async { + final data = await _send("POST", "/users", body: { + "email": email, + "password": password, + "name": name ?? "", + "role": role, + if (organization != null) "organization": organization, + }); return AdminUser.fromJson(Map.from(data["user"])); } diff --git a/Phone App/lib/screens/admin_users_screen.dart b/Phone App/lib/screens/admin_users_screen.dart index 999841e..2966ebf 100644 --- a/Phone App/lib/screens/admin_users_screen.dart +++ b/Phone App/lib/screens/admin_users_screen.dart @@ -35,6 +35,22 @@ class _AdminUsersScreenState extends State { List get _assignableRoles => _iamSuperadmin ? const ["user", "admin", "superadmin"] : const ["user", "admin"]; + /// Why a row's destructive and role controls are locked, or "" when they are + /// not. These mirror the server's guards, so the UI never offers an action + /// that would come back a 403 — and, unlike simply greying the control out, + /// they say which guard it is. Same wording as the web app's tooltips. + String _deleteBlockedReason(AdminUser u) { + if (u.id == _myId) return t("admin.cantDeleteSelf"); + if (u.isSuperadmin && !_iamSuperadmin) return t("admin.onlySuperadminDeletes"); + return ""; + } + + String _roleLockReason(AdminUser u) { + if (u.id == _myId) return t("admin.cantChangeOwnRole"); + if (u.isSuperadmin && !_iamSuperadmin) return t("admin.onlySuperadminEdits"); + return ""; + } + void _snack(String msg) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); @@ -139,8 +155,12 @@ class _AdminUsersScreenState extends State { separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, i) { final u = users[i]; + // An admin may not edit or delete a superadmin, and nobody may + // touch their own role or account. Both guards carry the sentence + // that explains them. + final roleLock = _roleLockReason(u); + final deleteLock = _deleteBlockedReason(u); final isSelf = u.id == _myId; - // An admin may not edit or delete a superadmin; only a superadmin may. final locked = u.isSuperadmin && !_iamSuperadmin; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 4), @@ -164,22 +184,30 @@ class _AdminUsersScreenState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - DropdownButton( - value: u.role, - underline: const SizedBox.shrink(), - // Nobody may change their own role; only a superadmin may - // touch a superadmin. - onChanged: - (isSelf || locked) ? null : (v) => v == null ? null : _changeRole(u, v), - items: [ - for (final r in _assignableRoles) - DropdownMenuItem(value: r, child: Text(t("admin.roles.$r"))), - // Keep the current role selectable even when this viewer - // can't assign it, so the dropdown has a valid value. - if (!_assignableRoles.contains(u.role)) - DropdownMenuItem( - value: u.role, child: Text(t("admin.roles.${u.role}"))), - ], + // A long-press on the greyed-out picker says why it is + // greyed out — the touch equivalent of the web's tooltip. + Tooltip( + message: roleLock, + triggerMode: roleLock.isEmpty + ? TooltipTriggerMode.manual + : TooltipTriggerMode.longPress, + child: DropdownButton( + value: u.role, + underline: const SizedBox.shrink(), + onChanged: roleLock.isNotEmpty + ? null + : (v) => v == null ? null : _changeRole(u, v), + items: [ + for (final r in _assignableRoles) + DropdownMenuItem(value: r, child: Text(t("admin.roles.$r"))), + // Keep the current role selectable even when this + // viewer can't assign it, so the dropdown has a + // valid value. + if (!_assignableRoles.contains(u.role)) + DropdownMenuItem( + value: u.role, child: Text(t("admin.roles.${u.role}"))), + ], + ), ), PopupMenuButton( onSelected: (choice) { @@ -190,14 +218,19 @@ class _AdminUsersScreenState extends State { PopupMenuItem( value: "password", enabled: !locked, - child: Text(t("admin.resetPassword")), + child: _MenuEntry( + label: t("admin.resetPassword"), + reason: locked ? t("admin.onlySuperadminEdits") : "", + ), ), PopupMenuItem( value: "delete", - // Can't delete yourself, or a superadmin you don't outrank. - enabled: !isSelf && !locked, - child: Text(t("common.delete"), - style: const TextStyle(color: DriverVault.danger)), + enabled: deleteLock.isEmpty, + child: _MenuEntry( + label: t("common.delete"), + reason: deleteLock, + color: DriverVault.danger, + ), ), ], ), @@ -226,12 +259,40 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { bool _saving = false; String? _error; + /// Organizations a superadmin can drop the new account into. An admin gets no + /// picker: the server puts their members in their own org regardless, so + /// offering a choice would be a lie. Empty until loaded, and left empty if the + /// listing fails — a picker that cannot be filled must not block the form. + List _orgs = const []; + + /// The chosen tenant; "" is a real choice, meaning an account belonging to no + /// organization at all. + String _organization = ""; + + bool get _iamSuperadmin => authService.user?.isSuperadmin == true; + /// Only a superadmin can create another one. An admin's new users are placed /// in the admin's own organization by the server. List get _assignableRoles => authService.user?.isSuperadmin == true ? const ["user", "admin", "superadmin"] : const ["user", "admin"]; + @override + void initState() { + super.initState(); + if (_iamSuperadmin) _loadOrgs(); + } + + Future _loadOrgs() async { + try { + final orgs = await apiClient.listOrgs(); + if (mounted) setState(() => _orgs = orgs); + } catch (_) { + // Listing is manager-only and can fail; the picker then offers only "no + // organization", which is still a valid account to create. + } + } + @override void dispose() { _email.dispose(); @@ -251,6 +312,9 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { password: _password.text, name: _name.text.trim(), role: _role, + // Only a superadmin picks; for an admin the server forces its own org, + // so sending anything here would be noise. + organization: _iamSuperadmin ? _organization : null, ); if (mounted) Navigator.pop(context, true); } catch (e) { @@ -316,6 +380,24 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { ), ], ), + if (_iamSuperadmin) ...[ + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: _organization, + isExpanded: true, + decoration: InputDecoration( + labelText: t("admin.colOrganization"), + helperText: t("admin.organizationHint"), + helperMaxLines: 3, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: "", child: Text(t("admin.noOrganization"))), + for (final o in _orgs) DropdownMenuItem(value: o.id, child: Text(o.name)), + ], + onChanged: (v) => setState(() => _organization = v ?? ""), + ), + ], const SizedBox(height: 12), SizedBox( width: double.infinity, @@ -329,3 +411,29 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { ); } } + +/// One entry of a row's overflow menu. A blocked action keeps its label and +/// gains the sentence saying why it is blocked: the item is disabled, so it +/// cannot be long-pressed for a tooltip the way the role picker can, and a +/// silently greyed-out action is the thing this is meant to avoid. +class _MenuEntry extends StatelessWidget { + final String label; + final String reason; + final Color? color; + const _MenuEntry({required this.label, required this.reason, this.color}); + + @override + Widget build(BuildContext context) { + if (reason.isEmpty) return Text(label, style: TextStyle(color: color)); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: TextStyle(color: color)), + Text(reason, + style: TextStyle(fontSize: 11, color: DriverVault.muted(context)), + softWrap: true), + ], + ); + } +} diff --git a/Phone App/test/models_format_test.dart b/Phone App/test/models_format_test.dart index 86fd5d4..673b54a 100644 --- a/Phone App/test/models_format_test.dart +++ b/Phone App/test/models_format_test.dart @@ -6,6 +6,9 @@ // plausible-looking zero, the badge wording has to match the web app's, and an // unsupported locale (settable from the web, whose lists are wider than the // phone's) must not throw out of every date on screen. +import "dart:convert"; +import "dart:io"; + import "package:flutter_test/flutter_test.dart"; import "package:intl/date_symbol_data_local.dart"; import "package:drivervault_phone/format.dart"; @@ -372,6 +375,40 @@ void main() { }); }); + test("no key is left behind in a translation", () { + // Every key English has, the other languages have too. t() falls back to + // English for a missing one, so a gap is invisible until someone reads a + // half-translated screen — this is what makes it a test failure instead. + final en = json.decode( + File("assets/i18n/en.json").readAsStringSync()) as Map; + + List flatten(Map tree, [String prefix = ""]) => [ + for (final e in tree.entries) + if (e.value is Map) + ...flatten(e.value as Map, "$prefix${e.key}.") + else + "$prefix${e.key}", + ]; + + dynamic lookup(Map tree, String key) { + dynamic node = tree; + for (final part in key.split(".")) { + if (node is! Map || !node.containsKey(part)) return null; + node = node[part]; + } + return node; + } + + final keys = flatten(en); + expect(keys, isNotEmpty); + for (final lang in translatedLanguages.where((l) => l != "en")) { + final other = json.decode( + File("assets/i18n/$lang.json").readAsStringSync()) as Map; + final missing = keys.where((k) => lookup(other, k) == null).toList(); + expect(missing, isEmpty, reason: "$lang.json is missing ${missing.length} key(s)"); + } + }); + test("a day count reads as prose in each language's own plural forms", () { appSettings.locale = "en-GB"; expect(t("car.info.daysValue", n: 1), "1 day"); diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index 8faab19..aff2c1b 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -81,10 +81,13 @@ No screen code changes are needed to add a language. ## Coverage -- **Web App** — fully translated (every view, component, form, and the status - labels in `lib/format.js`), except the Integrations settings and the OCPP - charger-control card: `settings.integrations.*` and `charging.control.*` exist - in `en.json` only, so both fall back to English in Polish and Danish. +- **Web App** — fully translated: every view, component, form, and the status + labels in `lib/format.js`. +Both apps are complete in all three languages. The one place the wording is +deliberately not translated is proper nouns: protocol and product names (OCPP, +CSMS, Toyota Connected, MyToyota, Anker Solix, Lexus) read the same in every +file, as do the units. + - **API Server panel** — UI chrome, cards, login, status, and the API section titles are translated. The individual REST endpoint **descriptions** in the API reference table are intentionally left in English as developer reference @@ -102,12 +105,11 @@ No screen code changes are needed to add a language. data out as table columns), client-side validation (the web leans on the browser's `required`), and the snackbars. - Still English: the same `settings.integrations.*` / `charging.control.*` gap - the web app has, which is worth closing in both at once rather than letting the - phone run ahead. - - `test/models_format_test.dart` guards the lookups the analyzer cannot see — - the ones built from a key at render time (`car.tabs.$key`, - `enums.fuelType.$v`, the delete dialog's plural counts, the connected - service's readings). A catalogue entry with no label fails the test rather - than reaching a screen as a raw key path. + `test/models_format_test.dart` guards two things the analyzer cannot see. The + lookups built from a key at render time (`car.tabs.$key`, `enums.fuelType.$v`, + `admin.roles.$r`, the delete dialog's plural counts, the connected service's + readings) must resolve to a real label in every language — a catalogue entry + with no translation fails the test rather than reaching a screen as a raw key + path. And every key `en.json` carries must exist in `pl.json` and `da.json`, + so a phrase added in English alone is caught at the point it is added rather + than by whoever next reads a half-translated screen. diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 0a91227..9a59d4b 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -66,6 +66,25 @@ "full": "Optaget", "offPeak": "uden for spidsbelastning", "homeCharger": "Hjemmelader" + }, + + "control": { + "title": "Laderstyring", + "connected": "Forbundet", + "disconnected": "Offline", + "serialPlaceholder": "Laderens serienummer (f.eks. A5191-XXXXXXXX)", + "refresh": "Opdater", + "status": "Stik", + "meter": "Energi", + "start": "Start opladning", + "stop": "Stop opladning", + "limit": "Strømgrænse", + "applyLimit": "Anvend grænse", + "clearLimit": "Ryd grænse", + "reset": "Genstart laderen", + "resetConfirm": "Genstart denne lader nu? En igangværende opladning bliver afbrudt. Indtast din adgangskode igen for at bekræfte.", + "resetPassword": "Din kontoadgangskode", + "connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer)." } }, @@ -222,6 +241,63 @@ "saveBio": "Gem beskrivelse" }, + "integrations": { + "title": "Integrationer", + "subtitle": "Forbind eksterne tjenester til din konto.", + "toyota": "Toyota Connected (MyToyota)", + "toyotaDesc": "Log ind med din MyToyota-konto for at hente køretøjsdata fra Toyota Connected Europe. Kun Europa.", + "enable": "Aktivér for min konto", + "enableOrg": "Aktivér for organisationen", + "unavailable": "Denne integration er slået fra af administratoren.", + "orgDisabled": "Denne integration er slået fra for din organisation.", + "scopeMy": "Mine indstillinger", + "scopeOrg": "Organisation", + "scopeHint": "Organisationens indstillinger gælder for alle i din organisation, der lader et felt stå tomt.", + "readOnly": "Denne integrations fælles indstillinger administreres i API-serverens panel; her er de skrivebeskyttede.", + "email": "MyToyota-e-mail", + "password": "MyToyota-adgangskode", + "brand": "Mærke", + "brandToyota": "Toyota", + "brandLexus": "Lexus", + "inherited": "Sat på et højere niveau", + "inheritedFrom": "Nedarvet fra {source}", + "sourceGlobal": "administratoren", + "sourceOrg": "din organisation", + "sourceUser": "dig", + "passwordKeep": "Lad feltet stå tomt for at beholde den gemte adgangskode.", + "test": "Test forbindelsen", + "testing": "Tester…", + "save": "Gem", + "saved": "Gemt ✓", + "connected": "Forbundet", + "notConnected": "Ikke forbundet", + "ankerSolix": "Anker Solix (V1 Smart EV Charger)", + "ankerSolixDesc": "Log ind med din Anker-konto for at hente laderdata fra Anker Solix-skyen.", + "ankerEmail": "E-mail til Anker-konto", + "ankerPassword": "Adgangskode til Anker-konto", + "country": "Land", + "countryHint": "Landekode på to bogstaver for din Anker-konto (f.eks. DE, GB, US).", + "controlMode": "Styringstilstand", + "controlModeHint": "Hvordan DriverVault styrer laderen over OCPP.", + "controlOff": "Fra (kun overvågning)", + "controlOwn": "Eget CSMS (fuld styring)", + "controlProxy": "Proxy-CSMS (videresendelse + styring)", + "controlTitle": "Laderstyring (OCPP)", + "controlOwnHint": "Laderen forbinder direkte til DriverVault som sit centralsystem. Peg laderens OCPP-backend på endepunktet nedenfor.", + "controlProxyHint": "DriverVault videresender til Ankers sky og kan indsætte kommandoer. Peg laderens OCPP-backend på endepunktet nedenfor.", + "controlProvisionSteps": "I Anker-appen (eller laderens OCPP-indstillinger) sættes OCPP-backend-URL'en til endepunktet og autorisationsnøglen til tokenet ovenfor.", + "chargerSerial": "Laderens serienummer", + "controlCheck": "Tjek status", + "controlGenerate": "Generér token", + "controlEndpoint": "OCPP-endepunkt", + "controlToken": "Autorisationstoken", + "controlTokenOnce": "Kopiér dette token nu — det vises kun én gang og kan ikke hentes frem igen.", + "controlRevoke": "Tilbagekald token", + "controlRevokeConfirm": "Tilbagekald denne laders styringstoken? Den bliver afbrudt og kan ikke forbinde igen, før du genererer et nyt.", + "controlConnected": "Forbundet til styrings-backend", + "controlDisconnected": "Ikke forbundet" + }, + "org": { "title": "Organisation", "titleAll": "Organisationer", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 319ab05..493cd09 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -68,6 +68,25 @@ "full": "Zajęte", "offPeak": "poza szczytem", "homeCharger": "Ładowarka domowa" + }, + + "control": { + "title": "Sterowanie ładowarką", + "connected": "Połączono", + "disconnected": "Offline", + "serialPlaceholder": "Numer seryjny ładowarki (np. A5191-XXXXXXXX)", + "refresh": "Odśwież", + "status": "Złącze", + "meter": "Energia", + "start": "Rozpocznij ładowanie", + "stop": "Zatrzymaj ładowanie", + "limit": "Limit prądu", + "applyLimit": "Zastosuj limit", + "clearLimit": "Usuń limit", + "reset": "Zrestartuj ładowarkę", + "resetConfirm": "Zrestartować teraz tę ładowarkę? Trwająca sesja ładowania zostanie przerwana. Wpisz ponownie hasło, aby potwierdzić.", + "resetPassword": "Hasło do Twojego konta", + "connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje)." } }, @@ -226,6 +245,63 @@ "saveBio": "Zapisz opis" }, + "integrations": { + "title": "Integracje", + "subtitle": "Połącz zewnętrzne usługi ze swoim kontem.", + "toyota": "Toyota Connected (MyToyota)", + "toyotaDesc": "Zaloguj się kontem MyToyota, aby pobierać dane pojazdu z Toyota Connected Europe. Tylko Europa.", + "enable": "Włącz dla mojego konta", + "enableOrg": "Włącz dla organizacji", + "unavailable": "Ta integracja została wyłączona przez administratora.", + "orgDisabled": "Ta integracja jest wyłączona dla Twojej organizacji.", + "scopeMy": "Moje ustawienia", + "scopeOrg": "Organizacja", + "scopeHint": "Ustawienia organizacji obowiązują każdego w Twojej organizacji, kto pozostawi pole puste.", + "readOnly": "Wspólnymi ustawieniami tej integracji zarządzasz w panelu API Servera; tutaj są tylko do odczytu.", + "email": "E-mail MyToyota", + "password": "Hasło MyToyota", + "brand": "Marka", + "brandToyota": "Toyota", + "brandLexus": "Lexus", + "inherited": "Ustawione na wyższym poziomie", + "inheritedFrom": "Odziedziczone od: {source}", + "sourceGlobal": "administratora", + "sourceOrg": "Twojej organizacji", + "sourceUser": "Ciebie", + "passwordKeep": "Pozostaw puste, aby zachować zapisane hasło.", + "test": "Testuj połączenie", + "testing": "Testowanie…", + "save": "Zapisz", + "saved": "Zapisano ✓", + "connected": "Połączono", + "notConnected": "Nie połączono", + "ankerSolix": "Anker Solix (V1 Smart EV Charger)", + "ankerSolixDesc": "Zaloguj się kontem Anker, aby pobierać dane ładowarki EV z chmury Anker Solix.", + "ankerEmail": "E-mail konta Anker", + "ankerPassword": "Hasło konta Anker", + "country": "Kraj", + "countryHint": "Dwuliterowy kod kraju Twojego konta Anker (np. DE, GB, US).", + "controlMode": "Tryb sterowania", + "controlModeHint": "Sposób, w jaki DriverVault steruje ładowarką przez OCPP.", + "controlOff": "Wyłączone (tylko monitorowanie)", + "controlOwn": "Własny CSMS (pełne sterowanie)", + "controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)", + "controlTitle": "Sterowanie ładowarką (OCPP)", + "controlOwnHint": "Ładowarka łączy się bezpośrednio z DriverVault jako swoim systemem centralnym. Ustaw backend OCPP ładowarki na poniższy adres.", + "controlProxyHint": "DriverVault przekazuje ruch do chmury Anker i może wysyłać własne polecenia. Ustaw backend OCPP ładowarki na poniższy adres.", + "controlProvisionSteps": "W aplikacji Anker (lub w ustawieniach OCPP ładowarki) ustaw adres backendu OCPP na powyższy endpoint, a klucz autoryzacji na powyższy token.", + "chargerSerial": "Numer seryjny ładowarki", + "controlCheck": "Sprawdź status", + "controlGenerate": "Wygeneruj token", + "controlEndpoint": "Endpoint OCPP", + "controlToken": "Token autoryzacji", + "controlTokenOnce": "Skopiuj ten token teraz — jest pokazywany tylko raz i nie da się go później odzyskać.", + "controlRevoke": "Unieważnij token", + "controlRevokeConfirm": "Unieważnić token sterowania tej ładowarki? Rozłączy się i nie połączy ponownie, dopóki nie wygenerujesz nowego.", + "controlConnected": "Połączono z backendem sterowania", + "controlDisconnected": "Nie połączono" + }, + "org": { "title": "Organizacja", "titleAll": "Organizacje",