feat(i18n): localize Settings → General → Secrets (#2144)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-02 00:20:32 +02:00
committed by GitHub
parent 83c3c1ccf1
commit c40f592ee9
6 changed files with 132 additions and 23 deletions
@@ -0,0 +1,61 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { activateLocale } from "@/lib/i18n";
import type { SecretListItem } from "@/lib/types/http-secrets";
import { DeleteSecretDialog } from "./secrets-settings";
/**
* A secret's name is user data. It reaches the dialog as an interpolated value
* and must never become a catalog key — the pseudo locale is the oracle here:
* the sentence around the name changes, the name itself must not.
*/
const secret: SecretListItem = {
id: "secret-1",
name: "OpenAI Production Key",
has_value: true,
created_at: "",
updated_at: "",
};
/**
* The name renders inside a `<span>`, so testing-library's text matcher — which
* joins only an element's direct text-node children — never sees the sentence
* whole. Read the description element's own `textContent` instead.
*/
function descriptionText(): string {
const description = screen.getByText(secret.name).closest("p");
// Fail on the broken query rather than on an empty string comparison, which
// would point every assertion below at the wrong cause.
if (!description) throw new Error("secret name is not inside the <p> DialogDescription");
return description.textContent ?? "";
}
afterEach(async () => {
cleanup();
await activateLocale("en");
});
describe("DeleteSecretDialog", () => {
it("renders the confirmation copy around the secret name", () => {
render(
<DeleteSecretDialog target={secret} onClose={vi.fn()} onConfirm={vi.fn()} isBusy={false} />,
);
expect(descriptionText()).toBe(
`This will permanently remove ${secret.name}. This action cannot be undone.`,
);
});
it("keeps the secret name verbatim when the locale changes", async () => {
await activateLocale("pseudo");
render(
<DeleteSecretDialog target={secret} onClose={vi.fn()} onConfirm={vi.fn()} isBusy={false} />,
);
// The surrounding sentence is catalog copy, so pseudo accents it.
expect(descriptionText()).toMatch(/[À-ɏ]/);
expect(descriptionText()).not.toContain("This will permanently remove");
// The name is user data — byte-identical under any locale.
expect(descriptionText()).toContain(secret.name);
});
});
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { IconEdit, IconTrash, IconEye, IconEyeOff, IconKey } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
@@ -64,6 +65,7 @@ function SecretForm({
showSubmit = true,
baselineState,
}: SecretFormProps) {
const { t } = useTranslation();
const nameIsDirty = Boolean(baselineState) && formState.name.trim() !== baselineState?.name;
const valueIsDirty = Boolean(baselineState) && formState.value !== baselineState?.value;
return (
@@ -76,14 +78,14 @@ function SecretForm({
<Input
value={formState.name}
onChange={(e) => onFormChange({ name: e.target.value })}
placeholder="Name (e.g. OpenAI Production Key)"
placeholder={t("settings:nameEGOpenaiProductionKey")}
disabled={isBusy}
data-settings-dirty={nameIsDirty}
/>
<Textarea
value={formState.value}
onChange={(e) => onFormChange({ value: e.target.value })}
placeholder="Secret value"
placeholder={t("settings:secretValue")}
rows={2}
className="resize-y font-mono text-sm"
disabled={isBusy}
@@ -97,7 +99,7 @@ function SecretForm({
</Button>
)}
<Button variant="ghost" onClick={onCancel} disabled={isBusy} className="cursor-pointer">
Cancel
{t("settings:cancel")}
</Button>
</div>
</div>
@@ -204,7 +206,16 @@ type DeleteSecretDialogProps = {
isBusy: boolean;
};
function DeleteSecretDialog({ target, onClose, onConfirm, isBusy }: DeleteSecretDialogProps) {
export function DeleteSecretDialog({
target,
onClose,
onConfirm,
isBusy,
}: DeleteSecretDialogProps) {
const { t } = useTranslation();
// The secret's own name is user data: it is interpolated as a value and never
// routed through a catalog key.
const name = target?.name ?? t("settings:thisSecret");
return (
<Dialog
open={Boolean(target)}
@@ -214,16 +225,18 @@ function DeleteSecretDialog({ target, onClose, onConfirm, isBusy }: DeleteSecret
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete secret</DialogTitle>
<DialogTitle>{t("settings:deleteSecret")}</DialogTitle>
<DialogDescription>
This will permanently remove{" "}
<span className="font-medium text-foreground">{target?.name ?? "this secret"}</span>.
This action cannot be undone.
<Trans i18nKey="settings:thisWillPermanentlyRemoveSecret" values={{ name }}>
This will permanently remove{" "}
<span className="font-medium text-foreground">{name}</span>. This action cannot be
undone.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} className="cursor-pointer">
Cancel
{t("settings:cancel")}
</Button>
<Button
type="button"
@@ -232,7 +245,7 @@ function DeleteSecretDialog({ target, onClose, onConfirm, isBusy }: DeleteSecret
disabled={isBusy}
className="cursor-pointer"
>
Delete secret
{t("settings:deleteSecret")}
</Button>
</DialogFooter>
</DialogContent>
@@ -393,6 +406,7 @@ export function getSecretDraftMeta(
}
export function SecretsSettings() {
const { t } = useTranslation();
const state = useSecretsState();
const { loaded, editingId, showCreate, formState, setFormState, deleteTarget, items } = state;
const actions = useSecretsActions(state);
@@ -401,8 +415,8 @@ export function SecretsSettings() {
let invalidReason: string | undefined;
if (isDirty && !isValid) {
invalidReason = showCreate
? "A secret name and value are required."
: "A secret name is required.";
? t("settings:aSecretNameAndValueAreRequired")
: t("settings:aSecretNameIsRequired");
}
const onFormChange = (patch: Partial<SecretFormState>) =>
@@ -410,8 +424,8 @@ export function SecretsSettings() {
return (
<SettingsPageTemplate
title="Secrets"
description="Manage API keys and credentials. Secrets are encrypted at rest and injected into agent environments via executor profile env vars."
title={t("settings:secrets")}
description={t("settings:manageApiKeysAndCredentialsSecrets")}
isDirty={isDirty}
saveStatus="idle"
saveId="secrets-item-draft"
@@ -423,26 +437,26 @@ export function SecretsSettings() {
>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-foreground">Secrets</div>
<div className="text-sm font-medium text-foreground">{t("settings:secrets")}</div>
<Button
onClick={actions.startCreate}
disabled={isBusy || Boolean(editingId) || showCreate}
className="cursor-pointer"
>
Add secret
{t("settings:addSecret")}
</Button>
</div>
{showCreate && (
<SecretForm
title="New secret"
title={t("settings:newSecret")}
formState={formState}
onFormChange={onFormChange}
onSubmit={actions.handleCreate}
onCancel={actions.resetForm}
isValid={isValid}
isBusy={isBusy}
submitLabel="Add secret"
submitLabel={t("settings:addSecret")}
showSubmit={false}
baselineState={defaultFormState}
/>
@@ -450,14 +464,14 @@ export function SecretsSettings() {
{editingId && (
<SecretForm
title="Edit secret"
title={t("settings:editSecret")}
formState={formState}
onFormChange={onFormChange}
onSubmit={actions.handleUpdate}
onCancel={actions.resetForm}
isValid={isValid}
isBusy={isBusy}
submitLabel="Save changes"
submitLabel={t("settings:saveChanges")}
showSubmit={false}
baselineState={edit.baseline}
/>
@@ -466,12 +480,12 @@ export function SecretsSettings() {
<div className="space-y-3">
{!loaded && (
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
Loading secrets...
{t("settings:loadingSecrets")}
</div>
)}
{loaded && items.length === 0 && !showCreate && (
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
No secrets yet. Add your first secret to get started.
{t("settings:noSecretsYetAddYourFirst")}
</div>
)}
{items.map((secret) => (
@@ -26,7 +26,10 @@ import { test, expect } from "../../fixtures/test-base";
const COVERAGE_ENABLED = process.env.KANDEV_I18N_COVERAGE === "1";
/** Migrated screens whose visible text is overwhelmingly UI chrome, not user data. */
const SCREENS = [{ name: "settings — appearance", url: "/settings/general/appearance" }];
const SCREENS = [
{ name: "settings — appearance", url: "/settings/general/appearance" },
{ name: "settings — secrets", url: "/settings/general/secrets" },
];
/**
* Text that is legitimately un-accented under pseudo: brand/proper nouns, code
+3
View File
@@ -172,10 +172,13 @@ export const i18nGuardFiles = [
// Settings → General → Appearance, migrated end-to-end as the worked example:
// the page, the two sections it owns, and the settings chrome around them.
"app/settings/general/appearance/**/*.{ts,tsx}",
// Settings → General → Secrets.
"app/settings/general/secrets/**/*.{ts,tsx}",
"components/app-sidebar/sections/settings/general-group.tsx",
"components/settings/general-settings.tsx",
"components/settings/general-nav.ts",
"components/settings/language-settings.tsx",
"components/settings/secrets-settings.tsx",
"components/settings/settings-floating-save.tsx",
"components/settings/settings-layout-client.tsx",
"components/settings/startup-page-settings-card.tsx",
+14
View File
@@ -1,6 +1,10 @@
{
"addSecret": "Add secret",
"agent": "Agent",
"appearance": "Appearance",
"aSecretNameAndValueAreRequired": "A secret name and value are required.",
"aSecretNameIsRequired": "A secret name is required.",
"cancel": "Cancel",
"changesPanel": "Changes Panel",
"changesPanelLayout": "Changes Panel Layout",
"changesTheLanguageOfTheKandev": "Changes the language of the Kandev interface. Applies immediately and is remembered on this device. Task titles, chat, and other content you create are not translated.",
@@ -26,6 +30,7 @@
"customizeHowTheApplicationLooks": "Customize how the application looks",
"customizeKeyboardShortcutsForTheCommand": "Customize keyboard shortcuts for the command panel",
"dark": "Dark",
"deleteSecret": "Delete secret",
"discardAndLeave": "Discard and leave",
"discarding": "Discarding...",
"disk": "Disk %",
@@ -34,6 +39,7 @@
"displayLanguage": "Display language",
"editorIntegrationsAndDefaults": "Editor integrations and defaults",
"editors": "Editors",
"editSecret": "Edit secret",
"enterToSend": "Enter to send",
"fileListView": "File list view",
"flatList": "Flat list",
@@ -45,12 +51,17 @@
"lastVisitedTask": "Last visited task",
"layouts": "Layouts",
"light": "Light",
"loadingSecrets": "Loading secrets...",
"makesAgentctlValuesAvailableToPlugins": "Makes agentctl values available to plugins and other consumers. The built-in status bar remains host-only.",
"manageApiKeysAndCredentialsSecrets": "Manage API keys and credentials. Secrets are encrypted at rest and injected into agent environments via executor profile env vars.",
"mcpTaskDefaultsAndArchiveSafeguards": "MCP task defaults, archive safeguards, and transcript preferences",
"memory": "Memory %",
"messageSubmitKey": "Message Submit Key",
"metrics": "Metrics",
"nameEGOpenaiProductionKey": "Name (e.g. OpenAI Production Key)",
"new": "New",
"newSecret": "New secret",
"noSecretsYetAddYourFirst": "No secrets yet. Add your first secret to get started.",
"notifications": "Notifications",
"openKandevTo": "Open Kandev to",
"openSettingsMenu": "Open settings menu",
@@ -69,6 +80,7 @@
"saving": "Saving...",
"savingChanges": "Saving changes",
"secrets": "Secrets",
"secretValue": "Secret value",
"settings": "Settings",
"shellTerminalFontsAndLinkBehavior": "Shell, terminal fonts, and link behavior",
"showHostMetricsInStatusBar": "Show host metrics in status bar",
@@ -87,6 +99,8 @@
"themeMetricsAndChangesPanelPreferences": "Theme, startup page, metrics, and changes panel preferences",
"thisAppliesWhenKandevStarts": "This applies when Kandev starts or you open its bare home page. Home, Back, and workflow navigation always open the task overview.",
"thisPageHasUnsavedChangesSave": "This page has unsaved changes. Save them, discard them, or continue editing.",
"thisSecret": "this secret",
"thisWillPermanentlyRemoveSecret": "This will permanently remove <2>{{name}}</2>. This action cannot be undone.",
"transcriptNavigation": "Transcript Navigation",
"tree": "Tree",
"usefulWhenKandevIsSelfHosted": "Useful when Kandev is self-hosted on a remote server and you want a lightweight view of the machine resources from the global status bar or phone Status drawer.",
+14
View File
@@ -1,6 +1,10 @@
{
"addSecret": "Àďď śēćŕēţ",
"agent": "Àĝēńţ",
"appearance": "Àƥƥēàŕàńćē",
"aSecretNameAndValueAreRequired": "À śēćŕēţ ńàḿē àńď vàĺũē àŕē ŕēqũĩŕēď.",
"aSecretNameIsRequired": "À śēćŕēţ ńàḿē ĩś ŕēqũĩŕēď.",
"cancel": "Ćàńćēĺ",
"changesPanel": "Ćĥàńĝēś Ƥàńēĺ",
"changesPanelLayout": "Ćĥàńĝēś Ƥàńēĺ Ĺàŷōũţ",
"changesTheLanguageOfTheKandev": "Ćĥàńĝēś ţĥē ĺàńĝũàĝē ōƒ ţĥē Ķàńďēv ĩńţēŕƒàćē. Àƥƥĺĩēś ĩḿḿēďĩàţēĺŷ àńď ĩś ŕēḿēḿƀēŕēď ōń ţĥĩś ďēvĩćē. Ţàśķ ţĩţĺēś, ćĥàţ, àńď ōţĥēŕ ćōńţēńţ ŷōũ ćŕēàţē àŕē ńōţ ţŕàńśĺàţēď.",
@@ -26,6 +30,7 @@
"customizeHowTheApplicationLooks": "Ćũśţōḿĩźē ĥōŵ ţĥē àƥƥĺĩćàţĩōń ĺōōķś",
"customizeKeyboardShortcutsForTheCommand": "Ćũśţōḿĩźē ķēŷƀōàŕď śĥōŕţćũţś ƒōŕ ţĥē ćōḿḿàńď ƥàńēĺ",
"dark": "Ďàŕķ",
"deleteSecret": "Ďēĺēţē śēćŕēţ",
"discardAndLeave": "Ďĩśćàŕď àńď ĺēàvē",
"discarding": "Ďĩśćàŕďĩńĝ...",
"disk": "Ďĩśķ %",
@@ -34,6 +39,7 @@
"displayLanguage": "Ďĩśƥĺàŷ ĺàńĝũàĝē",
"editorIntegrationsAndDefaults": "Ēďĩţōŕ ĩńţēĝŕàţĩōńś àńď ďēƒàũĺţś",
"editors": "Ēďĩţōŕś",
"editSecret": "Ēďĩţ śēćŕēţ",
"enterToSend": "Ēńţēŕ ţō śēńď",
"fileListView": "Ƒĩĺē ĺĩśţ vĩēŵ",
"flatList": "Ƒĺàţ ĺĩśţ",
@@ -45,12 +51,17 @@
"lastVisitedTask": "Ĺàśţ vĩśĩţēď ţàśķ",
"layouts": "Ĺàŷōũţś",
"light": "Ĺĩĝĥţ",
"loadingSecrets": "Ĺōàďĩńĝ śēćŕēţś...",
"makesAgentctlValuesAvailableToPlugins": "Ḿàķēś àĝēńţćţĺ vàĺũēś àvàĩĺàƀĺē ţō ƥĺũĝĩńś àńď ōţĥēŕ ćōńśũḿēŕś. Ţĥē ƀũĩĺţ-ĩń śţàţũś ƀàŕ ŕēḿàĩńś ĥōśţ-ōńĺŷ.",
"manageApiKeysAndCredentialsSecrets": "Ḿàńàĝē ÀƤĨ ķēŷś àńď ćŕēďēńţĩàĺś. Śēćŕēţś àŕē ēńćŕŷƥţēď àţ ŕēśţ àńď ĩńĵēćţēď ĩńţō àĝēńţ ēńvĩŕōńḿēńţś vĩà ēxēćũţōŕ ƥŕōƒĩĺē ēńv vàŕś.",
"mcpTaskDefaultsAndArchiveSafeguards": "ḾĆƤ ţàśķ ďēƒàũĺţś, àŕćĥĩvē śàƒēĝũàŕďś, àńď ţŕàńśćŕĩƥţ ƥŕēƒēŕēńćēś",
"memory": "Ḿēḿōŕŷ %",
"messageSubmitKey": "Ḿēśśàĝē Śũƀḿĩţ Ķēŷ",
"metrics": "Ḿēţŕĩćś",
"nameEGOpenaiProductionKey": "Ńàḿē (ē.ĝ. ŌƥēńÀĨ Ƥŕōďũćţĩōń Ķēŷ)",
"new": "Ńēŵ",
"newSecret": "Ńēŵ śēćŕēţ",
"noSecretsYetAddYourFirst": "Ńō śēćŕēţś ŷēţ. Àďď ŷōũŕ ƒĩŕśţ śēćŕēţ ţō ĝēţ śţàŕţēď.",
"notifications": "Ńōţĩƒĩćàţĩōńś",
"openKandevTo": "Ōƥēń Ķàńďēv ţō",
"openSettingsMenu": "Ōƥēń śēţţĩńĝś ḿēńũ",
@@ -69,6 +80,7 @@
"saving": "Śàvĩńĝ...",
"savingChanges": "Śàvĩńĝ ćĥàńĝēś",
"secrets": "Śēćŕēţś",
"secretValue": "Śēćŕēţ vàĺũē",
"settings": "Śēţţĩńĝś",
"shellTerminalFontsAndLinkBehavior": "Śĥēĺĺ, ţēŕḿĩńàĺ ƒōńţś, àńď ĺĩńķ ƀēĥàvĩōŕ",
"showHostMetricsInStatusBar": "Śĥōŵ ĥōśţ ḿēţŕĩćś ĩń śţàţũś ƀàŕ",
@@ -87,6 +99,8 @@
"themeMetricsAndChangesPanelPreferences": "Ţĥēḿē, śţàŕţũƥ ƥàĝē, ḿēţŕĩćś, àńď ćĥàńĝēś ƥàńēĺ ƥŕēƒēŕēńćēś",
"thisAppliesWhenKandevStarts": "Ţĥĩś àƥƥĺĩēś ŵĥēń Ķàńďēv śţàŕţś ōŕ ŷōũ ōƥēń ĩţś ƀàŕē ĥōḿē ƥàĝē. Ĥōḿē, Ɓàćķ, àńď ŵōŕķƒĺōŵ ńàvĩĝàţĩōń àĺŵàŷś ōƥēń ţĥē ţàśķ ōvēŕvĩēŵ.",
"thisPageHasUnsavedChangesSave": "Ţĥĩś ƥàĝē ĥàś ũńśàvēď ćĥàńĝēś. Śàvē ţĥēḿ, ďĩśćàŕď ţĥēḿ, ōŕ ćōńţĩńũē ēďĩţĩńĝ.",
"thisSecret": "ţĥĩś śēćŕēţ",
"thisWillPermanentlyRemoveSecret": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē <2>{{name}}</2>. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"transcriptNavigation": "Ţŕàńśćŕĩƥţ Ńàvĩĝàţĩōń",
"tree": "Ţŕēē",
"usefulWhenKandevIsSelfHosted": "Ũśēƒũĺ ŵĥēń Ķàńďēv ĩś śēĺƒ-ĥōśţēď ōń à ŕēḿōţē śēŕvēŕ àńď ŷōũ ŵàńţ à ĺĩĝĥţŵēĩĝĥţ vĩēŵ ōƒ ţĥē ḿàćĥĩńē ŕēśōũŕćēś ƒŕōḿ ţĥē ĝĺōƀàĺ śţàţũś ƀàŕ ōŕ ƥĥōńē Śţàţũś ďŕàŵēŕ.",