diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 120a489..01ee13f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -19,6 +19,7 @@ GnModal, GnNavigationShell, GnSearchField, + GnSelect, GnSkeleton, GnStatusCard, GnSwitch, @@ -34,6 +35,7 @@ import { api } from "./api"; import SecretDetailPanel from "./components/SecretDetailPanel.vue"; +import { setLocale, t } from "./i18n"; import { assertGate, clearStore, @@ -229,24 +231,32 @@ locale: "en" }); +// Auto ("") = follow the auth account; the service stores its own otherwise +const localeOptions = computed(() => [ + { value: "", label: t("profile.languageAuto") }, + { value: "en", label: t("profile.langEn") }, + { value: "uk", label: t("profile.langUk") }, + { value: "ru", label: t("profile.langRu") } +]); + const navItems = computed(() => { const items = [ - { id: "secrets", label: "Secrets", icon: "ph-vault" }, - { id: "history", label: "History", icon: "ph-clock-counter-clockwise" }, - { id: "audit", label: "Audit", icon: "ph-list-checks" }, - { id: "tokens", label: "Tokens", icon: "ph-key" }, - { id: "extension", label: "Extension", icon: "ph-puzzle-piece" }, - { id: "settings", label: "Settings", icon: "ph-gear" } + { id: "secrets", label: t("nav.secrets"), icon: "ph-vault" }, + { id: "history", label: t("nav.history"), icon: "ph-clock-counter-clockwise" }, + { id: "audit", label: t("nav.audit"), icon: "ph-list-checks" }, + { id: "tokens", label: t("nav.tokens"), icon: "ph-key" }, + { id: "extension", label: t("nav.extension"), icon: "ph-puzzle-piece" }, + { id: "settings", label: t("nav.settings"), icon: "ph-gear" } ]; if (me.value?.role === "admin") { - items.push({ id: "admin", label: "Admin", icon: "ph-user-gear" }); + items.push({ id: "admin", label: t("nav.admin"), icon: "ph-user-gear" }); } return items.map((item) => ({ ...item, active: activeTab.value === item.id })); }); const currentNavLabel = computed(() => { const item = navItems.value.find((i) => i.id === activeTab.value); - return item?.label || "Secrets"; + return item?.label || t("nav.secrets"); }); const pageTitle = computed(() => { @@ -303,29 +313,29 @@ } const canCreateSecret = computed(() => Boolean(form.title.trim() && parseCreateFields().length)); const sortItems = computed(() => [ - { label: "Updated (newest)", value: { by: "updated_at", dir: "desc" } }, - { label: "Updated (oldest)", value: { by: "updated_at", dir: "asc" } }, - { label: "Title (A–Z)", value: { by: "title", dir: "asc" } }, - { label: "Title (Z–A)", value: { by: "title", dir: "desc" } }, - { label: "Status", value: { by: "status", dir: "asc" } } + { label: t("secrets.sortUpdatedNewest"), value: { by: "updated_at", dir: "desc" } }, + { label: t("secrets.sortUpdatedOldest"), value: { by: "updated_at", dir: "asc" } }, + { label: t("secrets.sortTitleAz"), value: { by: "title", dir: "asc" } }, + { label: t("secrets.sortTitleZa"), value: { by: "title", dir: "desc" } }, + { label: t("secrets.sortStatus"), value: { by: "status", dir: "asc" } } ]); const secretRows = computed(() => secrets.value.map((secret) => ({ id: secret.id, title: secret.title, - purpose: secret.purpose || "Not set", - category: secret.category || "uncategorized", + purpose: secret.purpose || t("common.notSet"), + category: secret.category || t("common.uncategorized"), status: secret.status, raw: secret })) ); -const secretColumns = [ - { key: "title", label: "Title" }, - { key: "purpose", label: "Purpose" }, - { key: "category", label: "Category" }, - { key: "status", label: "Status" }, +const secretColumns = computed(() => [ + { key: "title", label: t("col.title") }, + { key: "purpose", label: t("col.purpose") }, + { key: "category", label: t("col.category") }, + { key: "status", label: t("col.status") }, { key: "actions", label: "" } -]; +]); // created_at values arrive as UTC ISO strings; render the server timestamp // as local "YYYY-MM-DD HH:mm" — the kit timeline draws it in a narrow // .timeline-time, where a compact fixed format reads better than Locale @@ -338,9 +348,9 @@ const versionItems = computed(() => versions.value.map((version) => ({ key: String(version.id), - title: `Version ${version.version_number}`, + title: t("history.version", { n: version.version_number }), time: formatTimestamp(version.created_at), - text: `${version.fields.length} ${version.fields.length === 1 ? "field" : "fields"}`, + text: t("history.fields", { n: version.fields.length }), icon: "ph-clock-counter-clockwise", raw: version })) @@ -366,28 +376,34 @@ raw: token })) ); -const tokenColumns = [ - { key: "name", label: "Name" }, - { key: "scopes", label: "Scopes" }, +const tokenColumns = computed(() => [ + { key: "name", label: t("col.name") }, + { key: "scopes", label: t("col.scopes") }, { key: "actions", label: "" } -]; +]); const diagnosticsItems = computed(() => [ { label: "/health", key: "health", value: diagnostics.health }, { label: "/ready", key: "ready", value: diagnostics.ready } ]); -const adminColumns = [ - { key: "user", label: "User" }, - { key: "status", label: "Status" }, - { key: "role", label: "Role" }, - { key: "last_seen_at", label: "Last seen" } -]; -const auditColumns = [ - { key: "time", label: "Time" }, - { key: "action", label: "Action" }, - { key: "channel", label: "Channel" }, - { key: "ip", label: "IP" }, - { key: "metadata", label: "Metadata" } -]; +const adminColumns = computed(() => [ + { key: "user", label: t("col.user") }, + { key: "status", label: t("col.status") }, + { key: "role", label: t("col.role") }, + { key: "last_seen_at", label: t("col.lastSeen") } +]); +const auditColumns = computed(() => [ + { key: "time", label: t("col.time") }, + { key: "action", label: t("col.action") }, + { key: "channel", label: t("col.channel") }, + { key: "ip", label: t("col.ip") }, + { key: "metadata", label: t("col.metadata") } +]); +const backupColumns = computed(() => [ + { key: "filename", label: t("col.file") }, + { key: "size", label: t("col.size") }, + { key: "created_at", label: t("col.created") }, + { key: "actions", label: "" } +]); function formatAuditMetadata(meta) { if (!meta || typeof meta !== "object") return []; @@ -672,7 +688,7 @@ revealed.value = null; } catch (err) { error.value = err.message; - toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 }); + toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); } finally { loading.value = false; } @@ -804,20 +820,20 @@ if (result.ok) return true; if (result.code === "cancelled") { toastProvider.value?.info({ - title: "Canceled", - text: "Biometric verification canceled — nothing revealed.", + title: t("bio.canceled"), + text: t("bio.canceledText"), lifetime: 2500 }); } else if (result.code === "no-credential") { toastProvider.value?.error({ - title: "Biometric gate", - text: "Passkey not found on this device — re-enroll the gate in Settings.", + title: t("bio.gateTitle"), + text: t("bio.noCredential"), lifetime: 4000 }); } else if (result.code !== "no-gate") { toastProvider.value?.error({ - title: "Biometric gate", - text: "Verification failed — the secret was not revealed.", + title: t("bio.gateTitle"), + text: t("bio.failedText"), lifetime: 3500 }); } @@ -832,8 +848,8 @@ const proof = await assertGate(bioGateUserLabel()); if (!proof.ok) { toastProvider.value?.error({ - title: "Biometric gate", - text: proof.code === "cancelled" ? "Canceled — the gate stays on." : "Verification failed — the gate stays on.", + title: t("bio.gateTitle"), + text: proof.code === "cancelled" ? t("bio.stayOnCanceled") : t("bio.stayOnFailed"), lifetime: 3000 }); return; @@ -841,8 +857,8 @@ clearStore(); await refreshGateState(); toastProvider.value?.success({ - title: "Saved", - text: "Biometric gate disabled — reveals no longer require verification.", + title: t("bio.saved"), + text: t("bio.disabled"), lifetime: 2500 }); return; @@ -850,11 +866,11 @@ const result = await enrollGate(bioGateUserLabel()); if (!result.ok) { toastProvider.value?.info({ - title: "Biometric gate", + title: t("bio.gateTitle"), text: result.code === "cancelled" - ? "Enrollment canceled — the gate stays off." - : result.message || "Enrollment failed.", + ? t("bio.enrollCanceled") + : result.message || t("bio.enrollFailed"), lifetime: 3500 }); await refreshGateState(); @@ -862,8 +878,8 @@ } await refreshGateState(); toastProvider.value?.success({ - title: "Saved", - text: "Biometric gate enabled — every reveal will ask for the sensor.", + title: t("bio.saved"), + text: t("bio.enabled"), lifetime: 3000 }); } @@ -884,7 +900,7 @@ auditEvents.value = payload.items; auditTotal.value = payload.total; } catch (err) { - toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 }); + toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); } finally { auditLoading.value = false; } @@ -895,7 +911,7 @@ try { tokens.value = await api.tokens(); } catch (err) { - toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 }); + toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); } finally { tokensLoading.value = false; } @@ -917,21 +933,24 @@ function openProfileModal() { profileForm.display_name = me.value?.display_name || ""; - profileForm.locale = me.value?.locale || "en"; + profileForm.locale = me.value?.locale || ""; showProfileModal.value = true; } async function saveProfile() { try { + // "auto" clears the service-level override; the next /me's + // locale_effective decides the language again (profile -> auth account) const updated = await api.updateMe({ display_name: profileForm.display_name || null, - locale: profileForm.locale || null + locale: profileForm.locale || "auto" }); me.value = updated; + setLocale(updated.locale_effective); showProfileModal.value = false; - toastProvider.value?.success({ title: "Saved", text: "Profile updated.", lifetime: 2000 }); + toastProvider.value?.success({ title: t("bio.saved"), text: t("profile.saved"), lifetime: 2000 }); } catch (err) { - toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 }); + toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); } } @@ -969,7 +988,7 @@ const text = await file.text(); const payload = JSON.parse(text); if (payload.format !== "gnexus-creds-export" || payload.version !== 1) { - throw new Error("Unsupported import file"); + throw new Error(t("import.unsupportedFile")); } importPreview.value = payload; } catch (err) { @@ -991,9 +1010,9 @@ await api.deleteAccountData(); showDeleteConfirm.value = false; await loadSecrets(); - toastProvider.value?.success({ title: "Deleted", text: "All data has been removed.", lifetime: 2000 }); + toastProvider.value?.success({ title: t("toast.deleted"), text: t("toast.allDataRemoved"), lifetime: 2000 }); } catch (err) { - toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 }); + toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); } } @@ -1007,7 +1026,7 @@ adminUsers.value = payload.items; adminUsersTotal.value = payload.total; } catch (err) { - toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 }); + toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); } } @@ -1016,7 +1035,7 @@ try { backups.value = await api.listBackups(); } catch (err) { - toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 }); + toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); } finally { backupsLoading.value = false; } @@ -1025,10 +1044,10 @@ async function createBackup() { try { await api.createBackup(); - toastProvider.value?.success({ title: "Backup", text: "Database backup created.", lifetime: 2000 }); + toastProvider.value?.success({ title: t("toast.backup"), text: t("toast.backupCreated"), lifetime: 2000 }); await loadBackups(); } catch (err) { - toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 }); + toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); } } @@ -1047,9 +1066,9 @@ await api.restoreBackup(pendingRestoreFile.value); showRestoreConfirm.value = false; pendingRestoreFile.value = null; - toastProvider.value?.success({ title: "Restored", text: "Database restored. Please reload the page.", lifetime: 5000 }); + toastProvider.value?.success({ title: t("toast.restored"), text: t("toast.databaseRestored"), lifetime: 5000 }); } catch (err) { - toastProvider.value?.error({ title: "Error", text: err.message, lifetime: 3000 }); + toastProvider.value?.error({ title: t("common.error"), text: err.message, lifetime: 3000 }); } } @@ -1074,7 +1093,7 @@ try { extensionMeta.value = await api.extension(); } catch (err) { - extensionError.value = err.message || "Failed to load extension info."; + extensionError.value = err.message || t("extension.loadFailed"); } finally { extensionLoading.value = false; } @@ -1098,6 +1117,9 @@ onMounted(async () => { try { me.value = await api.me(); + // the backend decides the language: settings override -> auth account. + // The profile modal's switcher can override it for this service only. + setLocale(me.value.locale_effective); await refreshGateState(); await loadSecrets(); await syncTabFromRoute(true); @@ -1188,7 +1210,7 @@ class="profile-identity" > @@ -1215,11 +1237,11 @@
{{ error }} - + @@ -1230,11 +1252,11 @@ @select="(item) => { secretsSort = item.value; loadSecrets(); }" > - Create + {{ t("secrets.create") }} @@ -1243,7 +1265,7 @@ :columns="secretColumns" :rows="secretRows" class="secrets-table" - empty-text="No secrets yet" + :empty-text="t('secrets.empty')" >
@@ -1296,26 +1318,26 @@ @navigate="onPanelNavigate" />
- +
- Save new version + {{ t("create.saveNewVersion") }} - Cancel + {{ t("common.cancel") }}
@@ -1323,7 +1345,7 @@
- + @@ -1366,8 +1388,8 @@
@@ -1376,8 +1398,8 @@
@@ -1457,14 +1479,10 @@
-

- Autofill saved credentials, capture new logins, and browse secrets from the toolbar. - Download the build for your browser, load it unpacked, and point it at this server with - an API token. -

+

{{ t("extension.description") }}

{{ extensionMeta.notes }}

@@ -1487,33 +1505,33 @@ icon="ph ph-download-simple" @click="downloadExtension(build.filename)" > - Download {{ build.browser }} build + {{ t("extension.download", { browser: build.browser }) }}
- Create an API token + {{ t("extension.createToken") }}
-
- - - +
+ + +
- + - Import selected file + {{ t("settings.importAction") }} {{ importError }}
- {{ importPreview.secrets?.length || 0 }} secrets ready + {{ t("settings.importReady", { n: importPreview.secrets?.length || 0 }) }} {{ importFile?.name }}
- -

- Export all secrets as decrypted JSON. Store the file securely — it contains plaintext values. -

+ +

{{ t("settings.exportDescription") }}

- Export decrypted JSON + {{ t("settings.exportAction") }}
- -

- Require a fingerprint (Android), Touch ID or Windows Hello before every secret reveal. - Verification runs entirely on this browser — the server never sees biometric data. - Disable needs the same proof, so the gate cannot be switched off from an unattended session. -

+ +

{{ t("settings.gateDescription") }}

- {{ gateState.detail || "Biometric authentication is unavailable on this device." }} + {{ gateState.detail || t("settings.gateUnavailable") }} - The enrolled passkey is no longer on this device. Re-enroll the gate to restore protection. + {{ t("settings.gateBroken") }}
- {{ gateState.status === 'enrolled' ? "Disable biometric gate" : "Enable biometric gate" }} + {{ gateState.status === 'enrolled' ? t("settings.gateDisable") : t("settings.gateEnable") }}
- -

- Deleting all data removes every secret and version. Audit records and API tokens are preserved. -

+ +

{{ t("settings.dangerDescription") }}

- Delete all data + {{ t("settings.deleteAll") }}
@@ -1587,9 +1597,9 @@
- + @@ -1604,7 +1614,7 @@ - Check diagnostics + {{ t("admin.checkDiagnostics") }}
- +
- +
- +
- +

- API tokens let external clients and MCP tools authenticate with gnexus-creds without a browser session. Pick a descriptive name and select the scopes this token should have. + {{ t("tokens.intro") }}

- +
- - - - - + + + + +
- +

- Copy this token now. It will not be shown again. + {{ t("tokens.copyNotice") }}

- +
- - + + +

{{ t("profile.languageAuto") }}

diff --git a/frontend/src/components/SecretDetailPanel.vue b/frontend/src/components/SecretDetailPanel.vue index fecd92f..8351f6e 100644 --- a/frontend/src/components/SecretDetailPanel.vue +++ b/frontend/src/components/SecretDetailPanel.vue @@ -12,6 +12,8 @@ GnTooltip } from "gnexus-ui-kit/vue"; +import { t } from "../i18n"; + const props = defineProps({ secret: { type: Object, required: true }, fields: { type: Array, default: null }, @@ -34,15 +36,16 @@ ]); const details = computed(() => [ - { label: "Purpose", value: props.secret.purpose || "Not set" }, - { label: "Category", value: props.secret.category || "Not set" }, - { label: "Tags", value: props.secret.tags?.join(", ") || "No tags" }, + { label: t("detail.purpose"), value: props.secret.purpose || t("common.notSet") }, + { label: t("detail.category"), value: props.secret.category || t("common.notSet") }, + { label: t("detail.tags"), value: props.secret.tags?.join(", ") || t("detail.noTags") }, { - label: "Access", - value: - `UI ${props.secret.allow_ui ? "on" : "off"} · REST ` + - `${props.secret.allow_rest_api ? "on" : "off"} · MCP ` + - `${props.secret.allow_mcp ? "on" : "off"}` + label: t("detail.access"), + value: t("detail.accessState", { + ui: props.secret.allow_ui ? t("detail.on") : t("detail.off"), + rest: props.secret.allow_rest_api ? t("detail.on") : t("detail.off"), + mcp: props.secret.allow_mcp ? t("detail.on") : t("detail.off") + }) } ]); @@ -50,36 +53,36 @@ (props.fields || []).map((field, index) => ({ key: `${field.name}:${index}`, title: field.name, - subtitle: field.encrypted ? "encrypted" : "plain", + subtitle: field.encrypted ? t("detail.encrypted") : t("detail.plain"), raw: field })) ); const actionItems = computed(() => [ { - label: "Versions", + label: t("detail.versions"), icon: "ph ph-clock-counter-clockwise", onSelect: () => emit("navigate", "history") }, { - label: "Audit", + label: t("nav.audit"), icon: "ph ph-list-checks", onSelect: () => emit("navigate", "audit") }, { - label: "Edit fields", + label: t("detail.editFields"), icon: "ph ph-list-plus", onSelect: () => emit("navigate", "edit-fields") }, { - label: `Mark ${props.secret.status === "actual" ? "outdated" : "actual"}`, + label: props.secret.status === "actual" ? t("detail.markOutdated") : t("detail.markActual"), icon: "ph ph-arrows-clockwise", onSelect: () => emit("status-change", props.secret.status === "actual" ? "outdated" : "actual") }, - { label: "Archive", icon: "ph ph-archive", onSelect: () => emit("archive") }, + { label: t("detail.archive"), icon: "ph ph-archive", onSelect: () => emit("archive") }, { - label: "Delete", + label: t("detail.deleteAction"), icon: "ph ph-trash", danger: true, onSelect: () => emit("delete") @@ -105,7 +108,7 @@ @@ -114,17 +117,17 @@ {{ secret.status }} - + - + @@ -132,7 +135,7 @@ @@ -144,16 +147,16 @@
- +