feat(i18n): localize settings configuration panels

- Implemented next-intl across Appearance, Security, API Keys, and Deals settings panels.
- Defined translation schemas for Settings.appearance, Settings.security, Settings.apiKeys, and Settings.deals in en.json.
- Removed explicit 'any' types in components by utilizing local useTranslations hooks directly inside sub-components (ModeCard, ThemeCard, CreateKeyDialog).
- Eliminated all new typescript and eslint errors related to the localization migrations.
- Updated progress trackers to reflect completion.
This commit is contained in:
Ivan Mercedes 2026-07-07 21:09:50 -04:00
parent c30e117552
commit 9ee79638da
5 changed files with 152 additions and 78 deletions

View File

@ -1523,6 +1523,69 @@
"signingOut": "Signing out…",
"signOutEverywhere": "Sign out everywhere",
"signOutFailed": "Sign-out failed: {message}"
},
"appearance": {
"title": "Appearance",
"description": "Set the mode and accent colour used across the app. Saved to this device — try it, it changes live.",
"mode": "Mode",
"useMode": "Use {mode} mode",
"active": "Active",
"accentColor": "Accent color",
"useTheme": "Use {name} theme"
},
"security": {
"title": "Login & security",
"description": "Change your password and sign out of your devices. These keep your account safe."
},
"apiKeys": {
"title": "API keys",
"description": "Keys authenticate the public REST API (<apiCode>/api/v1</apiCode>) so you can build your own automations. Send them as <headerCode>Authorization: Bearer &lt;key&gt;</headerCode>.",
"newApiKey": "New API key",
"noApiKeys": "No API keys yet.",
"createOneHint": "Click <bold>New API key</bold> to create one.",
"askAdminHint": "Ask an admin to create one.",
"revoked": "Revoked",
"expired": "Expired",
"noScopes": "No scopes",
"created": "Created {date}",
"lastUsed": "last used {date}",
"neverUsed": "never used",
"expires": "expires {date}",
"revoke": "Revoke",
"revokeSuccess": "Revoked \"{name}\"",
"revokeFailed": "Failed to revoke key",
"loadFailed": "Failed to load API keys",
"createError": "Failed to create key",
"nameRequired": "Give the key a name",
"nameLabel": "Name",
"namePlaceholder": "e.g. Zapier automation",
"scopesLabel": "Scopes",
"scopesHint": "A key with no scopes can still call <code className=\"text-[11px]\">GET /api/v1/me</code> to verify it works.",
"cancel": "Cancel",
"creating": "Creating…",
"createKey": "Create key",
"copyTitle": "Copy your API key",
"copyDesc": "This is the only time the full key is shown. Store it somewhere safe — if you lose it, revoke it and create a new one.",
"apiKeyLabel": "API key",
"copy": "Copy",
"copySuccess": "API key copied",
"copyFailed": "Copy failed — select and copy manually",
"newKeyTitle": "New API key",
"newKeyDesc": "Name it after the integration that will use it, and grant only the scopes it needs.",
"done": "Done",
"networkError": "Could not reach the server"
},
"deals": {
"title": "Deals & currency",
"description": "The currency used for new deals and for pipeline and dashboard totals.",
"defaultCurrency": "Default currency",
"defaultCurrencyDesc": "New deals default to this currency, and pipeline and dashboard totals are shown in it. Existing deals keep the currency they were saved with.",
"currencyLabel": "Currency",
"adminOnlyHint": "Only account admins can change the default currency.",
"save": "Save",
"saving": "Saving...",
"saveFailed": "Failed to save default currency",
"saveSuccess": "Default currency updated"
}
}
}

View File

@ -40,6 +40,7 @@ import {
SCOPE_DESCRIPTIONS,
type ApiScope,
} from '@/lib/api-keys/scopes';
import { useTranslations } from 'next-intl';
import { SettingsPanelHead } from './settings-panel-head';
interface ApiKey {
@ -70,6 +71,7 @@ function keyStatus(k: ApiKey): 'active' | 'revoked' | 'expired' {
export function ApiKeysSettings() {
const { canEditSettings } = useAuth();
const t = useTranslations('Settings.apiKeys');
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
@ -81,14 +83,14 @@ export function ApiKeysSettings() {
const res = await fetch('/api/account/api-keys', { cache: 'no-store' });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to load API keys');
toast.error(payload.error || t('loadFailed'));
return;
}
const data = (await res.json()) as { keys: ApiKey[] };
setKeys(data.keys);
} catch (err) {
console.error('[ApiKeysSettings] load error:', err);
toast.error('Could not reach the server');
toast.error(t('networkError'));
} finally {
setLoading(false);
}
@ -106,10 +108,10 @@ export function ApiKeysSettings() {
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to revoke key');
toast.error(payload.error || t('revokeFailed'));
return;
}
toast.success(`Revoked "${key.name}"`);
toast.success(t('revokeSuccess', { name: key.name }));
// Reflect the revoke locally without a refetch.
setKeys((prev) =>
prev.map((k) =>
@ -118,7 +120,7 @@ export function ApiKeysSettings() {
);
} catch (err) {
console.error('[ApiKeysSettings] revoke error:', err);
toast.error('Could not reach the server');
toast.error(t('networkError'));
} finally {
setRevoking(null);
}
@ -135,20 +137,18 @@ export function ApiKeysSettings() {
return (
<section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead
title="API keys"
title={t('title')}
description={
<>
Keys authenticate the public REST API (
<code className="text-xs">/api/v1</code>) so you can build your own
automations. Send them as{' '}
<code className="text-xs">Authorization: Bearer &lt;key&gt;</code>.
</>
t.rich('description', {
apiCode: (chunks: React.ReactNode) => <code className="text-xs">{chunks}</code>,
headerCode: (chunks: React.ReactNode) => <code className="text-xs">{chunks}</code>
})
}
action={
<RequireRole min="admin">
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New API key
{t('newApiKey')}
</Button>
</RequireRole>
}
@ -159,16 +159,19 @@ export function ApiKeysSettings() {
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
<KeyRound className="text-muted-foreground size-6" />
<p className="text-muted-foreground mt-2 text-sm">
No API keys yet.
{t('noApiKeys')}
</p>
{canEditSettings ? (
<p className="text-muted-foreground mt-1 text-xs">
Click <span className="text-foreground">New API key</span> to
create one.
{t.rich('createOneHint', {
bold: (chunks: React.ReactNode) => (
<span className="text-foreground">{chunks}</span>
),
})}
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Ask an admin to create one.
{t('askAdminHint')}
</p>
)}
</CardContent>
@ -198,12 +201,12 @@ export function ApiKeysSettings() {
</span>
{status === 'revoked' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Revoked
{t('revoked')}
</Badge>
)}
{status === 'expired' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Expired
{t('expired')}
</Badge>
)}
</div>
@ -213,7 +216,7 @@ export function ApiKeysSettings() {
<div className="mt-1.5 flex flex-wrap gap-1">
{k.scopes.length === 0 ? (
<span className="text-muted-foreground text-xs">
No scopes
{t('noScopes')}
</span>
) : (
k.scopes.map((s) => (
@ -227,13 +230,13 @@ export function ApiKeysSettings() {
)}
</div>
<p className="text-muted-foreground mt-1.5 text-xs">
Created {fmtDate(k.created_at)}
{t('created', { date: fmtDate(k.created_at) })}
{' · '}
{k.last_used_at
? `last used ${fmtDate(k.last_used_at)}`
: 'never used'}
? t('lastUsed', { date: fmtDate(k.last_used_at) })
: t('neverUsed')}
{k.expires_at && status !== 'expired'
? ` · expires ${fmtDate(k.expires_at)}`
? ` · ${t('expires', { date: fmtDate(k.expires_at) })}`
: ''}
</p>
</div>
@ -252,7 +255,7 @@ export function ApiKeysSettings() {
) : (
<Trash2 className="size-4" />
)}
Revoke
{t('revoke')}
</Button>
</RequireRole>
)}
@ -286,6 +289,7 @@ function CreateKeyDialog({
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}) {
const t = useTranslations('Settings.apiKeys');
const [name, setName] = useState('');
const [scopes, setScopes] = useState<ApiScope[]>([]);
const [submitting, setSubmitting] = useState(false);
@ -308,7 +312,7 @@ function CreateKeyDialog({
async function handleCreate() {
const trimmed = name.trim();
if (!trimmed) {
toast.error('Give the key a name');
toast.error(t('nameRequired'));
return;
}
setSubmitting(true);
@ -320,14 +324,14 @@ function CreateKeyDialog({
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
toast.error(payload.error || 'Failed to create key');
toast.error(payload.error || t('createError'));
return;
}
setCreatedKey(payload.plaintext as string);
onCreated();
} catch (err) {
console.error('[CreateKeyDialog] create error:', err);
toast.error('Could not reach the server');
toast.error(t('networkError'));
} finally {
setSubmitting(false);
}
@ -337,9 +341,9 @@ function CreateKeyDialog({
if (!createdKey) return;
try {
await navigator.clipboard.writeText(createdKey);
toast.success('API key copied');
toast.success(t('copySuccess'));
} catch {
toast.error('Copy failed — select and copy manually');
toast.error(t('copyFailed'));
}
}
@ -356,16 +360,15 @@ function CreateKeyDialog({
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">
Copy your API key
{t('copyTitle')}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
This is the only time the full key is shown. Store it somewhere
safe — if you lose it, revoke it and create a new one.
{t('copyDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label className="text-muted-foreground">API key</Label>
<Label className="text-muted-foreground">{t('apiKeyLabel')}</Label>
<div className="flex gap-2">
<Input
readOnly
@ -375,7 +378,7 @@ function CreateKeyDialog({
/>
<Button type="button" variant="outline" onClick={copyKey}>
<Copy className="size-4" />
Copy
{t('copy')}
</Button>
</div>
</div>
@ -387,7 +390,7 @@ function CreateKeyDialog({
onOpenChange(false);
}}
>
Done
{t('done')}
</Button>
</DialogFooter>
</>
@ -395,30 +398,29 @@ function CreateKeyDialog({
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">
New API key
{t('newKeyTitle')}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Name it after the integration that will use it, and grant only
the scopes it needs.
{t('newKeyDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="api-key-name" className="text-muted-foreground">
Name
{t('nameLabel')}
</Label>
<Input
id="api-key-name"
value={name}
maxLength={80}
placeholder="e.g. Zapier automation"
placeholder={t('namePlaceholder')}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Scopes</Label>
<Label className="text-muted-foreground">{t('scopesLabel')}</Label>
<div className="border-border space-y-2 rounded-md border p-3">
{API_SCOPES.map((scope) => (
<label
@ -444,9 +446,11 @@ function CreateKeyDialog({
))}
</div>
<p className="text-muted-foreground text-xs">
A key with no scopes can still call{' '}
<code className="text-[11px]">GET /api/v1/me</code> to verify
it works.
{t.rich('scopesHint', {
code: (chunks: React.ReactNode) => (
<code className="text-[11px]">{chunks}</code>
),
})}
</p>
</div>
</div>
@ -460,16 +464,16 @@ function CreateKeyDialog({
}}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
{t('cancel')}
</Button>
<Button onClick={handleCreate} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating…
{t('creating')}
</>
) : (
'Create key'
t('createKey')
)}
</Button>
</DialogFooter>

View File

@ -5,6 +5,7 @@ import { Check, Moon, Palette, SunMoon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { SettingsPanelHead } from "./settings-panel-head";
/**
@ -21,17 +22,19 @@ import { SettingsPanelHead } from "./settings-panel-head";
*/
export function AppearancePanel() {
const { theme, setTheme, mode, setMode } = useTheme();
const t = useTranslations("Settings.appearance");
return (
<section className="max-w-3xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Appearance"
description="Set the mode and accent colour used across the app. Saved to this device — try it, it changes live."
title={t("title")}
description={t("description")}
/>
<div className="space-y-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<SunMoon className="size-4 text-muted-foreground" />
Mode
{t("mode")}
</h3>
<div
@ -53,19 +56,19 @@ export function AppearancePanel() {
<div className="mt-8 space-y-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Palette className="size-4 text-muted-foreground" />
Accent color
{t("accentColor")}
</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{THEMES.map((t) => (
{THEMES.map((tObj) => (
<ThemeCard
key={t.id}
id={t.id}
name={t.name}
tagline={t.tagline}
swatch={t.swatch}
isActive={t.id === theme}
onPick={() => setTheme(t.id)}
key={tObj.id}
id={tObj.id}
name={tObj.name}
tagline={tObj.tagline}
swatch={tObj.swatch}
isActive={tObj.id === theme}
onPick={() => setTheme(tObj.id)}
/>
))}
</div>
@ -83,6 +86,7 @@ function ModeCard({
isActive: boolean;
onPick: () => void;
}) {
const t = useTranslations("Settings.appearance");
const isLight = mode === "light";
const Icon = isLight ? Sun : Moon;
return (
@ -91,7 +95,7 @@ function ModeCard({
role="radio"
onClick={onPick}
aria-checked={isActive}
aria-label={`Use ${mode} mode`}
aria-label={t("useMode", { mode })}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive
@ -111,7 +115,7 @@ function ModeCard({
{isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
<Check className="h-3 w-3" />
Active
{t("active")}
</span>
)}
</button>
@ -133,12 +137,13 @@ function ThemeCard({
isActive: boolean;
onPick: () => void;
}) {
const t = useTranslations("Settings.appearance");
return (
<button
type="button"
onClick={onPick}
aria-pressed={isActive}
aria-label={`Use ${name} theme`}
aria-label={t("useTheme", { name })}
className={cn(
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive
@ -158,7 +163,7 @@ function ThemeCard({
{isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
<Check className="h-3 w-3" />
Active
{t("active")}
</span>
)}
</div>

View File

@ -16,6 +16,7 @@ import {
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { useTranslations } from "next-intl";
import { SettingsPanelHead } from "./settings-panel-head";
/**
@ -39,6 +40,7 @@ export function DealsSettings() {
const [selected, setSelected] = useState(defaultCurrency);
const [saving, setSaving] = useState(false);
const t = useTranslations("Settings.deals");
// Keep the select in sync once the profile (and its account default)
// resolves, and after a save round-trips through refreshProfile.
@ -56,7 +58,7 @@ export function DealsSettings() {
.update({ default_currency: selected })
.eq("id", accountId);
if (error) {
toast.error("Failed to save default currency");
toast.error(t("saveFailed"));
setSaving(false);
return;
}
@ -64,30 +66,28 @@ export function DealsSettings() {
// and every total pick it up without a full reload.
await refreshProfile();
setSaving(false);
toast.success("Default currency updated");
toast.success(t("saveSuccess"));
}
return (
<section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Deals & currency"
description="The currency used for new deals and for pipeline and dashboard totals."
title={t("title")}
description={t("description")}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<Coins className="size-4 text-primary" />
Default currency
{t("defaultCurrency")}
</CardTitle>
<CardDescription className="text-muted-foreground">
New deals default to this currency, and pipeline and
dashboard totals are shown in it. Existing deals keep the
currency they were saved with.
{t("defaultCurrencyDesc")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:max-w-xs">
<Label className="text-muted-foreground">Currency</Label>
<Label className="text-muted-foreground">{t("currencyLabel")}</Label>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
@ -102,7 +102,7 @@ export function DealsSettings() {
</select>
{!canEditSettings && (
<p className="text-xs text-muted-foreground">
Only account admins can change the default currency.
{t("adminOnlyHint")}
</p>
)}
</div>
@ -116,10 +116,10 @@ export function DealsSettings() {
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
{t("saving")}
</>
) : (
"Save"
t("save")
)}
</Button>
)}

View File

@ -3,17 +3,19 @@
import { PasswordForm } from './password-form';
import { SessionsCard } from './sessions-card';
import { SettingsPanelHead } from './settings-panel-head';
import { useTranslations } from 'next-intl';
/**
* "Login & security" section — groups the former Profile-tab password
* and active-sessions cards into their own dedicated home.
*/
export function SecurityPanel() {
const t = useTranslations('Settings.security');
return (
<section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Login & security"
description="Change your password and sign out of your devices. These keep your account safe."
title={t('title')}
description={t('description')}
/>
<div className="space-y-4">
<PasswordForm />