Add a language-switch system with per-language files

Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.

- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
  reactive to the signed-in profile locale. Every view, component, form and
  the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
  language (the panel has no user profile) and a header language picker.
  Chrome, cards, login and API section titles translated; endpoint reference
  descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
  driven by AppSettings.locale. Nav, login, lock, dashboard, the full
  Settings panel (incl. language picker) and format.dart status labels
  translated; remaining detail screens fall back to English.

Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.

Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-17 20:07:48 +02:00
co-authored by Claude Opus 4.8
parent ee28b522c7
commit b6bb6b1df0
54 changed files with 4191 additions and 979 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-OENR1RVj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i1JZk1ZM.css">
<script type="module" crossorigin src="/assets/index-CL6eq5qV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3MeNcl7.css">
</head>
<body>
<div id="app"></div>
+45 -21
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { theme, toggleTheme } from "./theme";
import { t, lang, setLang, TRANSLATED_LANGUAGES } from "./i18n";
import { me, token, restore, logout, isManager, isSuperadmin } from "./api";
import LoginView from "./components/LoginView.vue";
import StatusCard from "./components/StatusCard.vue";
@@ -23,21 +24,35 @@ onMounted(async () => {
// plugins need a superadmin. The server enforces the same rules — this only
// hides what the caller could not use anyway.
const sections = computed(() => {
const out = [{ id: "overview", label: "Overview" }];
const out = [{ id: "overview", label: t("sections.overview") }];
if (isManager.value) {
out.push({ id: "users", label: "Users" }, { id: "orgs", label: "Organizations" });
out.push({ id: "users", label: t("sections.users") }, { id: "orgs", label: t("sections.orgs") });
}
if (isSuperadmin.value) {
out.push(
{ id: "pocketbase", label: "PocketBase" },
{ id: "webapp", label: "Web App" },
{ id: "plugins", label: "Plugins" },
{ id: "pocketbase", label: t("sections.pocketbase") },
{ id: "webapp", label: t("sections.webapp") },
{ id: "plugins", label: t("sections.plugins") },
);
}
out.push({ id: "api", label: "API" });
out.push({ id: "api", label: t("sections.api") });
return out;
});
// The language picker offers only the languages that have a real file. English
// names ("Polski", "Dansk") come from Intl so each reads in its own language.
const languageOptions = computed(() =>
TRANSLATED_LANGUAGES.map((code) => {
let label = code;
try {
label = new Intl.DisplayNames([code], { type: "language" }).of(code) || code;
} catch {
/* runtime without DisplayNames — the code is still selectable */
}
return { code, label: label.charAt(0).toUpperCase() + label.slice(1) };
}),
);
function signOut() {
logout();
section.value = "overview";
@@ -151,7 +166,7 @@ const superadminApi = [
<span class="text-strong">Driver</span><span class="text-brandtext">Vault</span>
</span>
</span>
<span class="eyebrow mt-1.5">API server</span>
<span class="eyebrow mt-1.5">{{ t("app.apiServer") }}</span>
<div class="flex-1"></div>
<span v-if="me" class="data hidden text-xs text-muted sm:inline">
@@ -159,9 +174,18 @@ const superadminApi = [
</span>
<span v-if="me" class="dh-pill bg-info-soft text-info">{{ me.role }}</span>
<select
class="dh-select !w-auto !py-1 !text-xs"
:value="lang"
:aria-label="t('sections.overview')"
@change="setLang($event.target.value)"
>
<option v-for="l in languageOptions" :key="l.code" :value="l.code">{{ l.label }}</option>
</select>
<button
class="dh-btn-ghost"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
:title="theme === 'dark' ? t('app.switchToLight') : t('app.switchToDark')"
@click="toggleTheme"
>
<svg
@@ -187,12 +211,12 @@ const superadminApi = [
>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</svg>
Theme
{{ t("app.theme") }}
</button>
<button v-if="token" class="dh-btn-ghost" @click="signOut">Sign out</button>
<button v-if="token" class="dh-btn-ghost" @click="signOut">{{ t("app.signOut") }}</button>
</div>
<p v-if="booting" class="eyebrow py-16 text-center">Loading</p>
<p v-if="booting" class="eyebrow py-16 text-center">{{ t("app.loading") }}</p>
<!-- Unauthenticated: the login gate is the whole console. -->
<LoginView v-else-if="!token" />
@@ -219,21 +243,21 @@ const superadminApi = [
<PluginsCard v-else-if="section === 'plugins'" />
<template v-else-if="section === 'api'">
<EndpointTable title="Public" auth="No auth" :endpoints="publicApi" />
<EndpointTable title="Identity" auth="Bearer token" :endpoints="identityApi" />
<EndpointTable title="Cars" auth="Bearer token" :endpoints="carsApi" />
<EndpointTable title="Service records" auth="Bearer token" :endpoints="serviceApi" />
<EndpointTable title="Parts" auth="Bearer token" :endpoints="partsApi" />
<EndpointTable title="Account" auth="Bearer token" :endpoints="accountApi" />
<EndpointTable title="Management" auth="Admin / superadmin" :endpoints="managementApi" />
<EndpointTable title="Superadmin" auth="Superadmin" :endpoints="superadminApi" />
<EndpointTable :title="t('api.groupPublic')" :auth="t('api.authNone')" :endpoints="publicApi" />
<EndpointTable :title="t('api.groupIdentity')" :auth="t('api.authBearer')" :endpoints="identityApi" />
<EndpointTable :title="t('api.groupCars')" :auth="t('api.authBearer')" :endpoints="carsApi" />
<EndpointTable :title="t('api.groupService')" :auth="t('api.authBearer')" :endpoints="serviceApi" />
<EndpointTable :title="t('api.groupParts')" :auth="t('api.authBearer')" :endpoints="partsApi" />
<EndpointTable :title="t('api.groupAccount')" :auth="t('api.authBearer')" :endpoints="accountApi" />
<EndpointTable :title="t('api.groupManagement')" :auth="t('api.authManager')" :endpoints="managementApi" />
<EndpointTable :title="t('api.groupSuperadmin')" :auth="t('api.authSuperadmin')" :endpoints="superadminApi" />
</template>
<p v-if="!isManager && section === 'overview'" class="eyebrow text-center">
Signed in as a standard user management sections need an admin role
{{ t("app.standardUserNote") }}
</p>
</template>
<p class="eyebrow text-center">DriverVault car maintenance &amp; service tracker.</p>
<p class="eyebrow text-center">{{ t("app.footer") }}</p>
</div>
</template>
@@ -1,4 +1,6 @@
<script setup>
import { t } from "../i18n";
defineProps({
title: String,
auth: String,
@@ -22,8 +24,8 @@ const methodClass = {
<table class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Endpoint</th>
<th>Description</th>
<th>{{ t("api.colEndpoint") }}</th>
<th>{{ t("api.colDescription") }}</th>
</tr>
</thead>
<tbody>
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed } from "vue";
import { login } from "../api";
import { t } from "../i18n";
const emit = defineEmits(["authenticated"]);
@@ -24,8 +25,8 @@ async function submit() {
// 400/404 from PocketBase both mean "bad credentials" — don't leak which.
error.value =
e.status === 400 || e.status === 404
? "Invalid email or password."
: e.message || "Could not sign in.";
? t("login.invalid")
: e.message || t("login.failed");
password.value = "";
} finally {
busy.value = false;
@@ -36,14 +37,14 @@ async function submit() {
<template>
<div class="mx-auto flex w-full max-w-sm flex-col gap-5 pt-24">
<div class="dh-card p-6">
<h1 class="text-lg font-bold tracking-[-0.02em] text-strong">Sign in</h1>
<h1 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("login.title") }}</h1>
<p class="mt-1 mb-5 text-sm text-body">
Superadmin console for the DriverVault API Server.
{{ t("login.subtitle") }}
</p>
<form class="flex flex-col gap-4" @submit.prevent="submit">
<div>
<label class="dh-label" for="login-email">Email</label>
<label class="dh-label" for="login-email">{{ t("login.email") }}</label>
<input
id="login-email"
v-model="email"
@@ -55,7 +56,7 @@ async function submit() {
/>
</div>
<div>
<label class="dh-label" for="login-password">Password</label>
<label class="dh-label" for="login-password">{{ t("login.password") }}</label>
<input
id="login-password"
v-model="password"
@@ -71,13 +72,13 @@ async function submit() {
</p>
<button class="dh-btn w-full" type="submit" :disabled="!canSubmit">
{{ busy ? "Signing in" : "Sign in" }}
{{ busy ? t("login.submitting") : t("login.submit") }}
</button>
</form>
</div>
<p class="eyebrow text-center">
Authenticates against PocketBase through this server
{{ t("login.authNote") }}
</p>
</div>
</template>
+14 -13
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { isSuperadmin, request } from "../api";
import { t } from "../i18n";
// Listing is manager-scoped (an admin sees only their own org); creating,
// renaming and deleting are superadmin-only, matching the server's gates.
@@ -58,7 +59,7 @@ async function save() {
}
async function remove(o) {
if (!confirm(`Delete the organization "${o.name}"?`)) return;
if (!confirm(t("orgs.confirmDelete", { name: o.name }))) return;
busy.value = true;
error.value = "";
try {
@@ -77,34 +78,34 @@ async function remove(o) {
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">Organizations</div>
<p class="mt-0.5 text-xs text-muted">Tenants users belong to</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("orgs.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("orgs.subtitle") }}</p>
</div>
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">New organization</button>
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">{{ t("orgs.newOrg") }}</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
<label class="dh-label">Name</label>
<input v-model="draftName" class="dh-input" placeholder="Acme Fleet" @keyup.enter="save" />
<label class="dh-label">{{ t("common.name") }}</label>
<input v-model="draftName" class="dh-input" :placeholder="t('orgs.namePlaceholder')" @keyup.enter="save" />
<div class="mt-3 flex items-center gap-2">
<button class="dh-btn" :disabled="busy || !draftName.trim()" @click="save">
{{ editing === "new" ? "Create" : "Save" }}
{{ editing === "new" ? t("common.create") : t("common.save") }}
</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">Cancel</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
</div>
</div>
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
No organizations yet.
{{ t("orgs.empty") }}
</p>
<table v-else class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Name</th>
<th>ID</th>
<th>{{ t("common.name") }}</th>
<th>{{ t("orgs.colId") }}</th>
<th></th>
</tr>
</thead>
@@ -114,9 +115,9 @@ async function remove(o) {
<td class="data px-5 py-2.5 text-xs text-muted">{{ o.id }}</td>
<td class="px-5 py-2.5 text-right whitespace-nowrap">
<template v-if="isSuperadmin">
<button class="dh-btn-ghost" @click="startEdit(o)">Rename</button>
<button class="dh-btn-ghost" @click="startEdit(o)">{{ t("common.rename") }}</button>
<button class="dh-btn-danger ml-1.5" :disabled="busy" @click="remove(o)">
Delete
{{ t("common.delete") }}
</button>
</template>
</td>
+22 -21
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, reactive } from "vue";
import { request } from "../api";
import { t } from "../i18n";
// Superadmin-only. Each plugin advertises its own config fields (Descriptor.
// ConfigFields), so the form below is generated rather than hard-coded — that's
@@ -47,7 +48,7 @@ async function save(p, enabled) {
});
// A save can succeed while Init fails (e.g. bad credentials) — the server
// returns the saved plugin plus a warning.
rowNotice[p.name] = out.warning || "Saved.";
rowNotice[p.name] = out.warning || t("plugins.saved");
await load();
} catch (e) {
rowNotice[p.name] = e.message;
@@ -58,7 +59,7 @@ async function save(p, enabled) {
async function health(p) {
busy.value = true;
rowNotice[p.name] = "Checking";
rowNotice[p.name] = t("plugins.checking");
try {
const out = await request(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
method: "POST",
@@ -73,7 +74,7 @@ async function health(p) {
}
async function remove(p) {
if (!confirm(`Remove the external plugin "${p.name}"? Its saved config is deleted.`)) return;
if (!confirm(t("plugins.confirmRemove", { name: p.name }))) return;
busy.value = true;
try {
await request(`/api/admin/plugins/${encodeURIComponent(p.name)}`, { method: "DELETE" });
@@ -113,11 +114,11 @@ const healthClass = (s) =>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">Plugins</div>
<p class="mt-0.5 text-xs text-muted">Third-party service integrations</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("plugins.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("plugins.subtitle") }}</p>
</div>
<button class="dh-btn-ghost" @click="showRegister = !showRegister">
{{ showRegister ? "Cancel" : "Register external" }}
{{ showRegister ? t("plugins.cancelRegister") : t("plugins.registerExternal") }}
</button>
</div>
@@ -125,15 +126,15 @@ const healthClass = (s) =>
<div v-if="showRegister" class="border-b border-subtle bg-sunken px-5 py-4">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label class="dh-label">Name</label>
<label class="dh-label">{{ t("plugins.name") }}</label>
<input v-model="reg.name" class="dh-input" placeholder="acme-parts" />
</div>
<div>
<label class="dh-label">Base URL</label>
<label class="dh-label">{{ t("plugins.baseUrl") }}</label>
<input v-model="reg.baseURL" class="dh-input" placeholder="http://127.0.0.1:9100" />
</div>
<div>
<label class="dh-label">Provider</label>
<label class="dh-label">{{ t("plugins.provider") }}</label>
<input v-model="reg.provider" class="dh-input" placeholder="ACME Corp" />
</div>
</div>
@@ -142,14 +143,14 @@ const healthClass = (s) =>
:disabled="busy || !reg.name || !reg.baseURL"
@click="registerExternal"
>
Register
{{ t("plugins.register") }}
</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
<p v-if="!plugins.length" class="px-5 py-6 text-center text-sm text-muted">
No plugins yet. Register an external one above, or compile a built-in connector.
{{ t("plugins.empty") }}
</p>
<div v-for="p in plugins" :key="p.name" class="border-t border-subtle first:border-t-0">
@@ -157,22 +158,22 @@ const healthClass = (s) =>
<div class="flex items-center gap-3 px-5 py-3">
<button class="flex flex-1 items-center gap-3 text-left" @click="expand(p)">
<span class="font-semibold text-strong">{{ p.name }}</span>
<span class="dh-pill bg-sunken text-muted">{{ p.kind || "builtin" }}</span>
<span class="dh-pill bg-sunken text-muted">{{ p.kind || t("plugins.builtin") }}</span>
<span v-if="p.provider" class="text-xs text-muted">{{ p.provider }}</span>
<span v-if="p.health" class="dh-pill" :class="healthClass(p.health.status)">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ p.health.status }}
</span>
</button>
<span class="dh-pill" :class="p.enabled ? 'bg-success-soft text-success' : 'bg-sunken text-muted'">
{{ p.enabled ? "enabled" : "disabled" }}
{{ p.enabled ? t("plugins.enabled") : t("plugins.disabled") }}
</span>
<button class="dh-btn-ghost" :disabled="busy" @click="health(p)">Health</button>
<button class="dh-btn-ghost" :disabled="busy" @click="health(p)">{{ t("plugins.health") }}</button>
<button
class="dh-btn-ghost"
:disabled="busy"
@click="expand(p)"
>
{{ open === p.name ? "Close" : "Configure" }}
{{ open === p.name ? t("plugins.close") : t("plugins.configure") }}
</button>
</div>
@@ -201,10 +202,10 @@ const healthClass = (s) =>
<p v-if="f.help" class="mt-1 text-xs text-muted">{{ f.help }}</p>
</div>
</div>
<p v-else class="text-xs text-muted">This plugin takes no configuration.</p>
<p v-else class="text-xs text-muted">{{ t("plugins.noConfig") }}</p>
<div v-if="(p.capabilities || []).length" class="mt-4">
<div class="eyebrow mb-1.5">Capabilities</div>
<div class="eyebrow mb-1.5">{{ t("plugins.capabilities") }}</div>
<ul class="data flex flex-col gap-1 text-xs text-muted">
<li v-for="c in p.capabilities" :key="c.id">
<span class="text-strong">{{ c.id }}</span>
@@ -218,10 +219,10 @@ const healthClass = (s) =>
<div class="mt-4 flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save(p, true)">
{{ p.enabled ? "Save" : "Save &amp; enable" }}
{{ p.enabled ? t("plugins.save") : t("plugins.saveEnable") }}
</button>
<button v-if="p.enabled" class="dh-btn-ghost" :disabled="busy" @click="save(p, false)">
Disable
{{ t("plugins.disable") }}
</button>
<span class="flex-1"></span>
<button
@@ -230,11 +231,11 @@ const healthClass = (s) =>
:disabled="busy"
@click="remove(p)"
>
Remove
{{ t("plugins.remove") }}
</button>
</div>
<p v-if="p.kind !== 'external'" class="eyebrow mt-2">
Built-in plugins can be disabled but not removed
{{ t("plugins.builtinNote") }}
</p>
</div>
</div>
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { request } from "../api";
import { t } from "../i18n";
// Superadmin-only: retarget the PocketBase this server talks to. The change is
// applied at runtime AND persisted to the server's .env, so it survives a
@@ -37,10 +38,10 @@ async function test() {
body: form.value,
});
notice.value = probe.value.superuser
? "Connection OK — superuser authenticated."
? t("pocketbase.testOk")
: probe.value.reachable
? "PocketBase is reachable, but the service account did not authenticate."
: "PocketBase is not reachable at that address.";
? t("pocketbase.testReachableNoAuth")
: t("pocketbase.testUnreachable");
} catch (e) {
error.value = e.message;
} finally {
@@ -57,7 +58,7 @@ async function save() {
cfg.value = out.config;
probe.value = out.config.probe;
form.value.adminPassword = "";
notice.value = out.warning || "Saved. The server is now using this PocketBase.";
notice.value = out.warning || t("pocketbase.savedNotice");
} catch (e) {
error.value = e.message;
} finally {
@@ -70,8 +71,8 @@ async function save() {
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">PocketBase</div>
<p class="mt-0.5 text-xs text-muted">Database connection used by every endpoint</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("pocketbase.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("pocketbase.subtitle") }}</p>
</div>
<span
v-if="probe"
@@ -83,30 +84,30 @@ async function save() {
: 'bg-danger-soft text-danger'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ probe.superuser ? "connected" : probe.reachable ? "no superuser" : "unreachable" }}
{{ probe.superuser ? t("pocketbase.connected") : probe.reachable ? t("pocketbase.noSuperuser") : t("pocketbase.unreachable") }}
</span>
</div>
<div class="flex flex-col gap-4 px-5 py-4">
<div>
<label class="dh-label" for="pb-url">Base URL</label>
<label class="dh-label" for="pb-url">{{ t("pocketbase.baseUrl") }}</label>
<input id="pb-url" v-model="form.url" class="dh-input" placeholder="http://10.2.1.10:8027" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="dh-label" for="pb-email">Superuser email</label>
<label class="dh-label" for="pb-email">{{ t("pocketbase.superuserEmail") }}</label>
<input id="pb-email" v-model="form.adminEmail" class="dh-input" autocomplete="off" />
</div>
<div>
<label class="dh-label" for="pb-password">Superuser password</label>
<label class="dh-label" for="pb-password">{{ t("pocketbase.superuserPassword") }}</label>
<input
id="pb-password"
v-model="form.adminPassword"
class="dh-input"
type="password"
autocomplete="new-password"
:placeholder="cfg?.adminConfigured ? 'unchanged' : 'not set'"
:placeholder="cfg?.adminConfigured ? t('pocketbase.passwordUnchanged') : t('pocketbase.passwordNotSet')"
/>
</div>
</div>
@@ -116,10 +117,10 @@ async function save() {
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-xs text-danger">{{ error }}</p>
<div class="flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save">Save &amp; apply</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">Test connection</button>
<button class="dh-btn" :disabled="busy" @click="save">{{ t("pocketbase.saveApply") }}</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">{{ t("pocketbase.testConnection") }}</button>
<span class="flex-1"></span>
<span class="eyebrow">persisted to .env</span>
<span class="eyebrow">{{ t("pocketbase.persistedEnv") }}</span>
</div>
</div>
</div>
+16 -12
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { ref, onMounted, onUnmounted, computed } from "vue";
import { request } from "../api";
import { t } from "../i18n";
// /api/status probes PocketBase and the Web App server-side, so the browser
// never has to reach either directly.
@@ -14,10 +15,17 @@ async function check() {
error.value = "";
} catch (e) {
status.value = null;
error.value = e.message || "unreachable";
error.value = e.message || t("status.unreachable");
}
}
// Computed so the row labels re-evaluate when the language changes.
const rows = computed(() => [
{ key: "apiServer", label: t("status.apiServer"), h: status.value?.apiServer },
{ key: "pocketBase", label: t("status.pocketBase"), h: status.value?.pocketBase },
{ key: "webApp", label: t("status.webApp"), h: status.value?.webApp },
]);
onMounted(() => {
check();
timer = setInterval(check, 10000);
@@ -31,9 +39,9 @@ const pillFor = (s) =>
<template>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-bold tracking-[-0.02em] text-strong">Status</div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("status.title") }}</div>
<span v-if="error" class="dh-pill bg-danger-soft text-danger">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>unreachable
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ t("status.unreachable") }}
</span>
</div>
@@ -41,15 +49,11 @@ const pillFor = (s) =>
<table v-else-if="status" class="w-full text-left text-sm">
<tbody>
<tr v-for="row in [
{ key: 'apiServer', label: 'API Server', h: status.apiServer },
{ key: 'pocketBase', label: 'PocketBase', h: status.pocketBase },
{ key: 'webApp', label: 'Web App', h: status.webApp },
]" :key="row.key" class="border-t border-subtle first:border-t-0">
<tr v-for="row in rows" :key="row.key" class="border-t border-subtle first:border-t-0">
<td class="px-5 py-3 font-medium text-strong">{{ row.label }}</td>
<td class="data px-5 py-3 text-xs text-muted">{{ row.h.url || "this process" }}</td>
<td class="data px-5 py-3 text-xs text-muted">{{ row.h.url || t("status.thisProcess") }}</td>
<td class="data px-5 py-3 text-right text-xs text-muted">
{{ row.h.latencyMs != null ? row.h.latencyMs + "ms" : "—" }}
{{ row.h.latencyMs != null ? row.h.latencyMs + "ms" : t("common.empty") }}
</td>
<td class="px-5 py-3 text-right">
<span class="dh-pill" :class="pillFor(row.h.status)">
@@ -60,6 +64,6 @@ const pillFor = (s) =>
</tbody>
</table>
<div v-else class="px-5 py-4 text-sm text-muted">Checking</div>
<div v-else class="px-5 py-4 text-sm text-muted">{{ t("status.checking") }}</div>
</div>
</template>
+24 -23
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, computed } from "vue";
import { request, me, isSuperadmin } from "../api";
import { t } from "../i18n";
// Manager-only. A superadmin sees and edits everyone; an admin is scoped by the
// server to their own organization. The UI mirrors those limits, but the server
@@ -77,7 +78,7 @@ async function save() {
}
async function remove(u) {
if (!confirm(`Delete ${u.email}? This cannot be undone.`)) return;
if (!confirm(t("users.confirmDelete", { email: u.email }))) return;
busy.value = true;
error.value = "";
try {
@@ -102,12 +103,12 @@ const roleClass = (r) =>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">Users</div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("users.title") }}</div>
<p class="mt-0.5 text-xs text-muted">
{{ isSuperadmin ? "All organizations" : "Your organization" }}
{{ isSuperadmin ? t("users.allOrgs") : t("users.yourOrg") }}
</p>
</div>
<button class="dh-btn" @click="startNew">New user</button>
<button class="dh-btn" @click="startNew">{{ t("users.newUser") }}</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
@@ -116,56 +117,56 @@ const roleClass = (r) =>
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="dh-label">Email</label>
<label class="dh-label">{{ t("common.email") }}</label>
<input v-model="draft.email" class="dh-input" type="email" autocomplete="off" />
</div>
<div>
<label class="dh-label">Name</label>
<label class="dh-label">{{ t("common.name") }}</label>
<input v-model="draft.name" class="dh-input" autocomplete="off" />
</div>
<div>
<label class="dh-label">
Password{{ editing === "new" ? "" : " (blank = unchanged)" }}
{{ editing === "new" ? t("users.password") : t("users.passwordUnchanged") }}
</label>
<input
v-model="draft.password"
class="dh-input"
type="password"
autocomplete="new-password"
placeholder="min 8 characters"
:placeholder="t('users.passwordPlaceholder')"
/>
</div>
<div>
<label class="dh-label">Role</label>
<label class="dh-label">{{ t("common.role") }}</label>
<select v-model="draft.role" class="dh-select">
<option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
</select>
</div>
<div v-if="isSuperadmin">
<label class="dh-label">Organization</label>
<label class="dh-label">{{ t("common.organization") }}</label>
<select v-model="draft.organization" class="dh-select">
<option value=""> none </option>
<option value="">{{ t("users.orgNone") }}</option>
<option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save">
{{ editing === "new" ? "Create" : "Save" }}
{{ editing === "new" ? t("common.create") : t("common.save") }}
</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">Cancel</button>
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
</div>
</div>
<p v-if="!users.length" class="px-5 py-6 text-center text-sm text-muted">No users.</p>
<p v-if="!users.length" class="px-5 py-6 text-center text-sm text-muted">{{ t("users.empty") }}</p>
<table v-else class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Email</th>
<th>Name</th>
<th>Organization</th>
<th>Role</th>
<th>{{ t("common.email") }}</th>
<th>{{ t("common.name") }}</th>
<th>{{ t("common.organization") }}</th>
<th>{{ t("common.role") }}</th>
<th></th>
</tr>
</thead>
@@ -173,22 +174,22 @@ const roleClass = (r) =>
<tr v-for="u in users" :key="u.id" class="border-t border-subtle transition-colors hover:bg-sunken">
<td class="data px-5 py-2.5 text-xs text-strong">
{{ u.email }}
<span v-if="u.id === me?.id" class="eyebrow ml-1">you</span>
<span v-if="u.id === me?.id" class="eyebrow ml-1">{{ t("users.you") }}</span>
</td>
<td class="px-5 py-2.5 text-body">{{ u.name || "—" }}</td>
<td class="px-5 py-2.5 text-body">{{ u.organizationName || "—" }}</td>
<td class="px-5 py-2.5 text-body">{{ u.name || t("common.empty") }}</td>
<td class="px-5 py-2.5 text-body">{{ u.organizationName || t("common.empty") }}</td>
<td class="px-5 py-2.5">
<span class="dh-pill" :class="roleClass(u.role)">{{ u.role }}</span>
</td>
<td class="px-5 py-2.5 text-right whitespace-nowrap">
<button class="dh-btn-ghost" @click="startEdit(u)">Edit</button>
<button class="dh-btn-ghost" @click="startEdit(u)">{{ t("common.edit") }}</button>
<button
v-if="u.id !== me?.id"
class="dh-btn-danger ml-1.5"
:disabled="busy"
@click="remove(u)"
>
Delete
{{ t("common.delete") }}
</button>
</td>
</tr>
+14 -12
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { request } from "../api";
import { t, tSplit } from "../i18n";
// Superadmin-only: where the Web App lives (the address /api/status probes) and
// which browser origins CORS admits. Like the PocketBase card, the change is
@@ -42,8 +43,8 @@ async function test() {
});
notice.value =
probe.value.status === "ok"
? "Web App is reachable."
: "Web App did not answer its health check at that address.";
? t("webapp.testOk")
: t("webapp.testFailed");
} catch (e) {
error.value = e.message;
} finally {
@@ -61,7 +62,7 @@ async function save() {
body: { url: form.value.url, allowOrigins: splitOrigins(form.value.origins) },
});
apply(out.config);
notice.value = out.warning || "Saved. New origins apply to the next request.";
notice.value = out.warning || t("webapp.savedNotice");
} catch (e) {
error.value = e.message;
} finally {
@@ -74,8 +75,8 @@ async function save() {
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">Web App</div>
<p class="mt-0.5 text-xs text-muted">Address probed by the status check, and who may call this API from a browser</p>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("webapp.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("webapp.subtitle") }}</p>
</div>
<span
v-if="probe"
@@ -83,18 +84,18 @@ async function save() {
:class="probe.status === 'ok' ? 'bg-success-soft text-success' : 'bg-danger-soft text-danger'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ probe.status === "ok" ? "reachable" : "unreachable" }}
{{ probe.status === "ok" ? t("webapp.reachable") : t("webapp.unreachable") }}
</span>
</div>
<div class="flex flex-col gap-4 px-5 py-4">
<div>
<label class="dh-label" for="web-url">Base URL</label>
<label class="dh-label" for="web-url">{{ t("webapp.baseUrl") }}</label>
<input id="web-url" v-model="form.url" class="dh-input" placeholder="http://localhost:5173" />
</div>
<div>
<label class="dh-label" for="web-origins">Allowed origins</label>
<label class="dh-label" for="web-origins">{{ t("webapp.allowedOrigins") }}</label>
<input
id="web-origins"
v-model="form.origins"
@@ -102,7 +103,8 @@ async function save() {
placeholder="http://localhost:5173, https://app.example.com"
/>
<p class="mt-1 text-xs text-muted">
Comma separated, or <span class="data">*</span> for any. Native mobile apps are not subject to CORS.
{{ tSplit("webapp.originsHint", "star").before
}}<span class="data">*</span>{{ tSplit("webapp.originsHint", "star").after }}
</p>
</div>
@@ -111,10 +113,10 @@ async function save() {
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-xs text-danger">{{ error }}</p>
<div class="flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save">Save &amp; apply</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">Test connection</button>
<button class="dh-btn" :disabled="busy" @click="save">{{ t("webapp.saveApply") }}</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">{{ t("webapp.testConnection") }}</button>
<span class="flex-1"></span>
<span class="eyebrow">persisted to .env</span>
<span class="eyebrow">{{ t("webapp.persistedEnv") }}</span>
</div>
</div>
</div>
+163
View File
@@ -0,0 +1,163 @@
{
"app": {
"apiServer": "API-server",
"switchToLight": "Skift til lyst tema",
"switchToDark": "Skift til mørkt tema",
"theme": "Tema",
"signOut": "Log ud",
"loading": "Indlæser…",
"standardUserNote": "Logget ind som almindelig bruger — administrationssektioner kræver en administratorrolle",
"footer": "DriverVault — bilservice- og vedligeholdelsesregister."
},
"sections": {
"overview": "Oversigt",
"users": "Brugere",
"orgs": "Organisationer",
"pocketbase": "PocketBase",
"webapp": "Webapp",
"plugins": "Plugins",
"api": "API"
},
"login": {
"title": "Log ind",
"subtitle": "Superadmin-konsol til DriverVault API-serveren.",
"email": "E-mail",
"password": "Adgangskode",
"submit": "Log ind",
"submitting": "Logger ind…",
"invalid": "Ugyldig e-mail eller adgangskode.",
"failed": "Kunne ikke logge ind.",
"authNote": "Godkender mod PocketBase gennem denne server"
},
"status": {
"title": "Status",
"unreachable": "utilgængelig",
"checking": "Tjekker…",
"apiServer": "API-server",
"pocketBase": "PocketBase",
"webApp": "Webapp",
"thisProcess": "denne proces"
},
"common": {
"create": "Opret",
"save": "Gem",
"cancel": "Annuller",
"edit": "Rediger",
"delete": "Slet",
"rename": "Omdøb",
"empty": "—",
"name": "Navn",
"email": "E-mail",
"role": "Rolle",
"organization": "Organisation"
},
"users": {
"title": "Brugere",
"allOrgs": "Alle organisationer",
"yourOrg": "Din organisation",
"newUser": "Ny bruger",
"password": "Adgangskode",
"passwordUnchanged": "Adgangskode (tom = uændret)",
"passwordPlaceholder": "mindst 8 tegn",
"orgNone": "— ingen —",
"you": "dig",
"empty": "Ingen brugere.",
"confirmDelete": "Slet {email}? Dette kan ikke fortrydes."
},
"orgs": {
"title": "Organisationer",
"subtitle": "Enheder, som brugere tilhører",
"newOrg": "Ny organisation",
"namePlaceholder": "Acme Fleet",
"empty": "Ingen organisationer endnu.",
"colId": "ID",
"confirmDelete": "Slet organisationen \"{name}\"?"
},
"pocketbase": {
"title": "PocketBase",
"subtitle": "Databaseforbindelse brugt af hvert endpoint",
"connected": "forbundet",
"noSuperuser": "ingen superbruger",
"unreachable": "utilgængelig",
"baseUrl": "Basis-URL",
"superuserEmail": "Superbrugerens e-mail",
"superuserPassword": "Superbrugerens adgangskode",
"passwordUnchanged": "uændret",
"passwordNotSet": "ikke angivet",
"saveApply": "Gem og anvend",
"testConnection": "Test forbindelse",
"persistedEnv": "gemt i .env",
"testOk": "Forbindelse OK — superbruger godkendt.",
"testReachableNoAuth": "PocketBase er tilgængelig, men servicekontoen blev ikke godkendt.",
"testUnreachable": "PocketBase er ikke tilgængelig på den adresse.",
"savedNotice": "Gemt. Serveren bruger nu denne PocketBase."
},
"webapp": {
"title": "Webapp",
"subtitle": "Adressen, der tjekkes ved statuskontrol, og hvem der må kalde dette API fra en browser",
"reachable": "tilgængelig",
"unreachable": "utilgængelig",
"baseUrl": "Basis-URL",
"allowedOrigins": "Tilladte oprindelser",
"originsHint": "Kommasepareret, eller {star} for enhver. Native mobilapps er ikke underlagt CORS.",
"saveApply": "Gem og anvend",
"testConnection": "Test forbindelse",
"persistedEnv": "gemt i .env",
"testOk": "Webappen er tilgængelig.",
"testFailed": "Webappen svarede ikke på sit helbredstjek på den adresse.",
"savedNotice": "Gemt. Nye oprindelser gælder fra næste anmodning."
},
"plugins": {
"title": "Plugins",
"subtitle": "Integrationer med tredjepartstjenester",
"registerExternal": "Registrér ekstern",
"cancelRegister": "Annuller",
"name": "Navn",
"baseUrl": "Basis-URL",
"provider": "Udbyder",
"register": "Registrér",
"empty": "Ingen plugins endnu. Registrér et eksternt ovenfor, eller kompilér et indbygget stik.",
"builtin": "indbygget",
"enabled": "aktiveret",
"disabled": "deaktiveret",
"health": "Helbred",
"configure": "Konfigurer",
"close": "Luk",
"noConfig": "Dette plugin kræver ingen konfiguration.",
"capabilities": "Funktioner",
"saved": "Gemt.",
"checking": "Tjekker…",
"save": "Gem",
"saveEnable": "Gem og aktivér",
"disable": "Deaktiver",
"remove": "Fjern",
"builtinNote": "Indbyggede plugins kan deaktiveres, men ikke fjernes",
"confirmRemove": "Fjern det eksterne plugin \"{name}\"? Dets gemte konfiguration slettes."
},
"api": {
"colEndpoint": "Endpoint",
"colDescription": "Beskrivelse",
"authNone": "Ingen godkendelse",
"authBearer": "Bearer-token",
"authManager": "Admin / superadmin",
"authSuperadmin": "Superadmin",
"groupPublic": "Offentlig",
"groupIdentity": "Identitet",
"groupCars": "Biler",
"groupService": "Serviceposter",
"groupParts": "Reservedele",
"groupAccount": "Konto",
"groupManagement": "Administration",
"groupSuperadmin": "Superadmin"
}
}
+163
View File
@@ -0,0 +1,163 @@
{
"app": {
"apiServer": "API server",
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme",
"theme": "Theme",
"signOut": "Sign out",
"loading": "Loading…",
"standardUserNote": "Signed in as a standard user — management sections need an admin role",
"footer": "DriverVault — car maintenance & service tracker."
},
"sections": {
"overview": "Overview",
"users": "Users",
"orgs": "Organizations",
"pocketbase": "PocketBase",
"webapp": "Web App",
"plugins": "Plugins",
"api": "API"
},
"login": {
"title": "Sign in",
"subtitle": "Superadmin console for the DriverVault API Server.",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in…",
"invalid": "Invalid email or password.",
"failed": "Could not sign in.",
"authNote": "Authenticates against PocketBase through this server"
},
"status": {
"title": "Status",
"unreachable": "unreachable",
"checking": "Checking…",
"apiServer": "API Server",
"pocketBase": "PocketBase",
"webApp": "Web App",
"thisProcess": "this process"
},
"common": {
"create": "Create",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"rename": "Rename",
"empty": "—",
"name": "Name",
"email": "Email",
"role": "Role",
"organization": "Organization"
},
"users": {
"title": "Users",
"allOrgs": "All organizations",
"yourOrg": "Your organization",
"newUser": "New user",
"password": "Password",
"passwordUnchanged": "Password (blank = unchanged)",
"passwordPlaceholder": "min 8 characters",
"orgNone": "— none —",
"you": "you",
"empty": "No users.",
"confirmDelete": "Delete {email}? This cannot be undone."
},
"orgs": {
"title": "Organizations",
"subtitle": "Tenants users belong to",
"newOrg": "New organization",
"namePlaceholder": "Acme Fleet",
"empty": "No organizations yet.",
"colId": "ID",
"confirmDelete": "Delete the organization \"{name}\"?"
},
"pocketbase": {
"title": "PocketBase",
"subtitle": "Database connection used by every endpoint",
"connected": "connected",
"noSuperuser": "no superuser",
"unreachable": "unreachable",
"baseUrl": "Base URL",
"superuserEmail": "Superuser email",
"superuserPassword": "Superuser password",
"passwordUnchanged": "unchanged",
"passwordNotSet": "not set",
"saveApply": "Save & apply",
"testConnection": "Test connection",
"persistedEnv": "persisted to .env",
"testOk": "Connection OK — superuser authenticated.",
"testReachableNoAuth": "PocketBase is reachable, but the service account did not authenticate.",
"testUnreachable": "PocketBase is not reachable at that address.",
"savedNotice": "Saved. The server is now using this PocketBase."
},
"webapp": {
"title": "Web App",
"subtitle": "Address probed by the status check, and who may call this API from a browser",
"reachable": "reachable",
"unreachable": "unreachable",
"baseUrl": "Base URL",
"allowedOrigins": "Allowed origins",
"originsHint": "Comma separated, or {star} for any. Native mobile apps are not subject to CORS.",
"saveApply": "Save & apply",
"testConnection": "Test connection",
"persistedEnv": "persisted to .env",
"testOk": "Web App is reachable.",
"testFailed": "Web App did not answer its health check at that address.",
"savedNotice": "Saved. New origins apply to the next request."
},
"plugins": {
"title": "Plugins",
"subtitle": "Third-party service integrations",
"registerExternal": "Register external",
"cancelRegister": "Cancel",
"name": "Name",
"baseUrl": "Base URL",
"provider": "Provider",
"register": "Register",
"empty": "No plugins yet. Register an external one above, or compile a built-in connector.",
"builtin": "builtin",
"enabled": "enabled",
"disabled": "disabled",
"health": "Health",
"configure": "Configure",
"close": "Close",
"noConfig": "This plugin takes no configuration.",
"capabilities": "Capabilities",
"saved": "Saved.",
"checking": "Checking…",
"save": "Save",
"saveEnable": "Save & enable",
"disable": "Disable",
"remove": "Remove",
"builtinNote": "Built-in plugins can be disabled but not removed",
"confirmRemove": "Remove the external plugin \"{name}\"? Its saved config is deleted."
},
"api": {
"colEndpoint": "Endpoint",
"colDescription": "Description",
"authNone": "No auth",
"authBearer": "Bearer token",
"authManager": "Admin / superadmin",
"authSuperadmin": "Superadmin",
"groupPublic": "Public",
"groupIdentity": "Identity",
"groupCars": "Cars",
"groupService": "Service records",
"groupParts": "Parts",
"groupAccount": "Account",
"groupManagement": "Management",
"groupSuperadmin": "Superadmin"
}
}
Binary file not shown.
+163
View File
@@ -0,0 +1,163 @@
{
"app": {
"apiServer": "Serwer API",
"switchToLight": "Przełącz na motyw jasny",
"switchToDark": "Przełącz na motyw ciemny",
"theme": "Motyw",
"signOut": "Wyloguj się",
"loading": "Ładowanie…",
"standardUserNote": "Zalogowano jako zwykły użytkownik — sekcje zarządzania wymagają roli administratora",
"footer": "DriverVault — rejestr serwisu i konserwacji samochodu."
},
"sections": {
"overview": "Przegląd",
"users": "Użytkownicy",
"orgs": "Organizacje",
"pocketbase": "PocketBase",
"webapp": "Aplikacja webowa",
"plugins": "Wtyczki",
"api": "API"
},
"login": {
"title": "Zaloguj się",
"subtitle": "Konsola superadministratora serwera API DriverVault.",
"email": "E-mail",
"password": "Hasło",
"submit": "Zaloguj się",
"submitting": "Logowanie…",
"invalid": "Nieprawidłowy e-mail lub hasło.",
"failed": "Nie udało się zalogować.",
"authNote": "Uwierzytelnia w PocketBase za pośrednictwem tego serwera"
},
"status": {
"title": "Status",
"unreachable": "niedostępny",
"checking": "Sprawdzanie…",
"apiServer": "Serwer API",
"pocketBase": "PocketBase",
"webApp": "Aplikacja webowa",
"thisProcess": "ten proces"
},
"common": {
"create": "Utwórz",
"save": "Zapisz",
"cancel": "Anuluj",
"edit": "Edytuj",
"delete": "Usuń",
"rename": "Zmień nazwę",
"empty": "—",
"name": "Nazwa",
"email": "E-mail",
"role": "Rola",
"organization": "Organizacja"
},
"users": {
"title": "Użytkownicy",
"allOrgs": "Wszystkie organizacje",
"yourOrg": "Twoja organizacja",
"newUser": "Nowy użytkownik",
"password": "Hasło",
"passwordUnchanged": "Hasło (puste = bez zmian)",
"passwordPlaceholder": "min. 8 znaków",
"orgNone": "— brak —",
"you": "Ty",
"empty": "Brak użytkowników.",
"confirmDelete": "Usunąć {email}? Tej operacji nie można cofnąć."
},
"orgs": {
"title": "Organizacje",
"subtitle": "Podmioty, do których należą użytkownicy",
"newOrg": "Nowa organizacja",
"namePlaceholder": "Acme Fleet",
"empty": "Brak organizacji.",
"colId": "ID",
"confirmDelete": "Usunąć organizację „{name}”?"
},
"pocketbase": {
"title": "PocketBase",
"subtitle": "Połączenie z bazą danych używane przez każdy punkt końcowy",
"connected": "połączono",
"noSuperuser": "brak superużytkownika",
"unreachable": "niedostępny",
"baseUrl": "Adres bazowy",
"superuserEmail": "E-mail superużytkownika",
"superuserPassword": "Hasło superużytkownika",
"passwordUnchanged": "bez zmian",
"passwordNotSet": "nie ustawiono",
"saveApply": "Zapisz i zastosuj",
"testConnection": "Testuj połączenie",
"persistedEnv": "zapisano w .env",
"testOk": "Połączenie OK — superużytkownik uwierzytelniony.",
"testReachableNoAuth": "PocketBase jest dostępny, ale konto usługowe nie zostało uwierzytelnione.",
"testUnreachable": "PocketBase jest niedostępny pod tym adresem.",
"savedNotice": "Zapisano. Serwer korzysta teraz z tego PocketBase."
},
"webapp": {
"title": "Aplikacja webowa",
"subtitle": "Adres sprawdzany podczas kontroli statusu oraz kto może wywoływać to API z przeglądarki",
"reachable": "dostępna",
"unreachable": "niedostępna",
"baseUrl": "Adres bazowy",
"allowedOrigins": "Dozwolone źródła",
"originsHint": "Oddzielone przecinkami lub {star} dla dowolnego. Natywne aplikacje mobilne nie podlegają CORS.",
"saveApply": "Zapisz i zastosuj",
"testConnection": "Testuj połączenie",
"persistedEnv": "zapisano w .env",
"testOk": "Aplikacja webowa jest dostępna.",
"testFailed": "Aplikacja webowa nie odpowiedziała na kontrolę stanu pod tym adresem.",
"savedNotice": "Zapisano. Nowe źródła obowiązują od następnego żądania."
},
"plugins": {
"title": "Wtyczki",
"subtitle": "Integracje z usługami zewnętrznymi",
"registerExternal": "Zarejestruj zewnętrzną",
"cancelRegister": "Anuluj",
"name": "Nazwa",
"baseUrl": "Adres bazowy",
"provider": "Dostawca",
"register": "Zarejestruj",
"empty": "Brak wtyczek. Zarejestruj zewnętrzną powyżej lub skompiluj wbudowany łącznik.",
"builtin": "wbudowana",
"enabled": "włączona",
"disabled": "wyłączona",
"health": "Stan",
"configure": "Konfiguruj",
"close": "Zamknij",
"noConfig": "Ta wtyczka nie wymaga konfiguracji.",
"capabilities": "Możliwości",
"saved": "Zapisano.",
"checking": "Sprawdzanie…",
"save": "Zapisz",
"saveEnable": "Zapisz i włącz",
"disable": "Wyłącz",
"remove": "Usuń",
"builtinNote": "Wtyczki wbudowane można wyłączyć, ale nie usunąć",
"confirmRemove": "Usunąć zewnętrzną wtyczkę „{name}”? Jej zapisana konfiguracja zostanie usunięta."
},
"api": {
"colEndpoint": "Punkt końcowy",
"colDescription": "Opis",
"authNone": "Bez uwierzytelniania",
"authBearer": "Token Bearer",
"authManager": "Administrator / superadministrator",
"authSuperadmin": "Superadministrator",
"groupPublic": "Publiczne",
"groupIdentity": "Tożsamość",
"groupCars": "Samochody",
"groupService": "Wpisy serwisowe",
"groupParts": "Części",
"groupAccount": "Konto",
"groupManagement": "Zarządzanie",
"groupSuperadmin": "Superadministrator"
}
}