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

View File

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

View File

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

View File

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