mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
c30e117552
commit
9ee79638da
@ -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 <key></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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <key></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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user