Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/profile-card-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@objectstack/console': patch
'@object-ui/i18n': patch
'@object-ui/app-shell': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-tree': patch
---

i18n: translate the Profile page, honor inline i18n label objects under bare
base-language codes, and localize managed-by badges / record quick actions.

- `pickLocalized` now upgrades a bare base language (`zh`) to any
region-qualified key sharing the base (`zh-CN`) — runtime language is
normalized to the base code while metadata authors write full BCP-47 tags,
so inline `{ en, 'zh-CN', ... }` label objects previously fell back to
English.
- ProfilePage (`account:profile_card` / `/system/profile`): every hardcoded
string — page title/subtitle, avatar Upload/Replace/Remove, Personal
Information card, Change/Set Password card — now goes through
`useObjectTranslation()` with `profile.*` keys (new namespace in all ten
locale bundles); the lazy-load fallback reuses `common.loading`.
- `ManagedByBadge` chips/tooltips (Config/System/Append-only/Identity) now
resolve through new `managedByBadge.*` keys with `{{provider}}`
interpolation.
- `record:quick_actions` resolves action labels via the
`objects.{object}._actions.{action}.label` convention plus `pickLocalized`,
so object action buttons (Change Password, Enable 2FA, …) localize.
- `record:details` / `record:related_list` / `record:alert` / `ObjectTree`
pass inline label objects through `pickLocalized`.
- Locale bundles: added `managedByBadge` namespace to all ten locales and
backfilled `list.inlineEditShort` / `inlineEditLabel` /
`recordEditingTitle` for ja/es/ko/de/fr/pt/ru/ar.
86 changes: 62 additions & 24 deletions apps/console/src/pages/system/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ import {
AlertDescription,
} from '@object-ui/components';
import { useUpload } from '@object-ui/providers';
import { useObjectTranslation } from '@object-ui/i18n';
import { CheckCircle2, AlertCircle, User, Lock, Upload, Loader2, X } from 'lucide-react';

export function ProfilePage() {
const { t } = useObjectTranslation();
const { user, updateUser, isLoading, changePassword, setInitialPassword, hasLocalPassword } = useAuth();
const { upload } = useUpload();
const [name, setName] = useState(user?.name ?? '');
Expand Down Expand Up @@ -91,8 +93,12 @@ export function ProfilePage() {
return (
<div className="flex flex-col gap-4 sm:gap-6 p-4 sm:p-6 max-w-2xl">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">Profile</h1>
<p className="text-sm text-muted-foreground mt-1">Manage your account settings</p>
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">
{t('profile.title', { defaultValue: 'Profile' })}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('profile.subtitle', { defaultValue: 'Manage your account settings' })}
</p>
</div>

{/* Avatar & Identity */}
Expand Down Expand Up @@ -132,7 +138,9 @@ export function ProfilePage() {
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{user.image ? 'Replace' : 'Upload'}
{user.image
? t('profile.avatar.replace', { defaultValue: 'Replace' })
: t('profile.avatar.upload', { defaultValue: 'Upload' })}
</Button>
{user.image && (
<Button
Expand All @@ -144,7 +152,7 @@ export function ProfilePage() {
data-testid="profile-avatar-remove-btn"
>
<X className="mr-2 h-4 w-4" />
Remove
{t('profile.avatar.remove', { defaultValue: 'Remove' })}
</Button>
)}
</div>
Expand All @@ -163,9 +171,13 @@ export function ProfilePage() {
<CardHeader>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-base sm:text-lg">Personal Information</CardTitle>
<CardTitle className="text-base sm:text-lg">
{t('profile.info.title', { defaultValue: 'Personal Information' })}
</CardTitle>
</div>
<CardDescription>Update your name and view account details</CardDescription>
<CardDescription>
{t('profile.info.description', { defaultValue: 'Update your name and view account details' })}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSave} className="space-y-4">
Expand All @@ -179,13 +191,13 @@ export function ProfilePage() {
<Alert>
<CheckCircle2 className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800 dark:text-green-400">
Profile updated successfully.
{t('profile.info.saved', { defaultValue: 'Profile updated successfully.' })}
</AlertDescription>
</Alert>
)}

<div className="space-y-2">
<Label htmlFor="profile-name">Name</Label>
<Label htmlFor="profile-name">{t('profile.info.name', { defaultValue: 'Name' })}</Label>
<Input
id="profile-name"
type="text"
Expand All @@ -198,19 +210,21 @@ export function ProfilePage() {
</div>

<div className="space-y-2">
<Label htmlFor="profile-email">Email</Label>
<Label htmlFor="profile-email">{t('profile.info.email', { defaultValue: 'Email' })}</Label>
<Input
id="profile-email"
type="email"
value={user.email}
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground">Email cannot be changed.</p>
<p className="text-xs text-muted-foreground">
{t('profile.info.emailImmutable', { defaultValue: 'Email cannot be changed.' })}
</p>
</div>

<div className="space-y-2">
<Label>Role</Label>
<Label>{t('profile.info.role', { defaultValue: 'Role' })}</Label>
<Input
type="text"
value={user.role ?? 'member'}
Expand All @@ -220,7 +234,9 @@ export function ProfilePage() {
</div>

<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
{isLoading ? 'Saving…' : 'Save Changes'}
{isLoading
? t('profile.saving', { defaultValue: 'Saving…' })
: t('profile.info.save', { defaultValue: 'Save Changes' })}
</Button>
</form>
</CardContent>
Expand All @@ -245,6 +261,7 @@ interface PasswordCardProps {
}

function PasswordCard({ changePassword, setInitialPassword, hasLocalPassword, highlight, onPasswordSet }: PasswordCardProps) {
const { t } = useObjectTranslation();
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
Expand Down Expand Up @@ -273,27 +290,29 @@ function PasswordCard({ changePassword, setInitialPassword, hasLocalPassword, hi
setSuccess(null);

if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
setError(t('profile.password.tooShort', { defaultValue: 'Password must be at least 8 characters' }));
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
setError(t('profile.password.mismatch', { defaultValue: 'Passwords do not match' }));
return;
}

setSubmitting(true);
try {
if (hasPassword) {
if (!currentPassword) {
setError('Enter your current password');
setError(t('profile.password.enterCurrent', { defaultValue: 'Enter your current password' }));
setSubmitting(false);
return;
}
await changePassword(currentPassword, newPassword);
setSuccess('Password changed.');
setSuccess(t('profile.password.changed', { defaultValue: 'Password changed.' }));
} else {
await setInitialPassword(newPassword);
setSuccess('Local password set. You can now sign in with email and password on this environment.');
setSuccess(t('profile.password.localSet', {
defaultValue: 'Local password set. You can now sign in with email and password on this environment.',
}));
setHasPassword(true);
onPasswordSet?.();
}
Expand All @@ -314,20 +333,29 @@ function PasswordCard({ changePassword, setInitialPassword, hasLocalPassword, hi
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-base sm:text-lg">
{hasPassword ? 'Change Password' : 'Set Local Password'}
{hasPassword
? t('profile.password.changeTitle', { defaultValue: 'Change Password' })
: t('profile.password.setTitle', { defaultValue: 'Set Local Password' })}
</CardTitle>
</div>
<CardDescription>
{hasPassword
? 'Update the password you use to sign in to this environment.'
: 'You signed in via single sign-on. Set a local password to also sign in with email and password on this environment.'}
? t('profile.password.changeDescription', {
defaultValue: 'Update the password you use to sign in to this environment.',
})
: t('profile.password.setDescription', {
defaultValue:
'You signed in via single sign-on. Set a local password to also sign in with email and password on this environment.',
})}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4 max-w-md">
{hasPassword && (
<div className="space-y-2">
<Label htmlFor="current-password">Current password</Label>
<Label htmlFor="current-password">
{t('profile.password.current', { defaultValue: 'Current password' })}
</Label>
<Input
id="current-password"
type="password"
Expand All @@ -340,7 +368,11 @@ function PasswordCard({ changePassword, setInitialPassword, hasLocalPassword, hi
</div>
)}
<div className="space-y-2">
<Label htmlFor="new-password">{hasPassword ? 'New password' : 'Password'}</Label>
<Label htmlFor="new-password">
{hasPassword
? t('profile.password.new', { defaultValue: 'New password' })
: t('profile.password.password', { defaultValue: 'Password' })}
</Label>
<Input
id="new-password"
type="password"
Expand All @@ -353,7 +385,9 @@ function PasswordCard({ changePassword, setInitialPassword, hasLocalPassword, hi
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm password</Label>
<Label htmlFor="confirm-password">
{t('profile.password.confirm', { defaultValue: 'Confirm password' })}
</Label>
<Input
id="confirm-password"
type="password"
Expand All @@ -380,7 +414,11 @@ function PasswordCard({ changePassword, setInitialPassword, hasLocalPassword, hi
)}

<Button type="submit" disabled={submitting || initializing} className="w-full sm:w-auto">
{submitting ? 'Saving…' : hasPassword ? 'Change password' : 'Set password'}
{submitting
? t('profile.saving', { defaultValue: 'Saving…' })
: hasPassword
? t('profile.password.changeAction', { defaultValue: 'Change password' })
: t('profile.password.setAction', { defaultValue: 'Set password' })}
</Button>
</form>
</CardContent>
Expand Down
12 changes: 9 additions & 3 deletions apps/console/src/registerAccountComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,27 @@

import { lazy, Suspense } from 'react';
import { registerAppComponent } from '@object-ui/app-shell';
import { useObjectTranslation } from '@object-ui/i18n';

const ProfilePage = lazy(() =>
import('./pages/system/ProfilePage').then((m) => ({ default: m.ProfilePage })),
);

function AccountFallback({ label }: { label: string }) {
return <div className="p-6 text-sm text-muted-foreground">Loading {label}…</div>;
function AccountFallback() {
const { t } = useObjectTranslation();
return (
<div className="p-6 text-sm text-muted-foreground">
{t('common.loading', { defaultValue: 'Loading...' })}
</div>
);
}

registerAppComponent({
ref: 'account:profile_card',
label: 'My Profile',
source: '@objectstack/console',
component: (props: any) => (
<Suspense fallback={<AccountFallback label="profile" />}>
<Suspense fallback={<AccountFallback />}>
<ProfilePage {...props} />
</Suspense>
),
Expand Down
24 changes: 18 additions & 6 deletions packages/app-shell/src/components/ManagedByBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
TooltipProvider,
cn,
} from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';

/**
* ManagedByBadge — replaces the verbose, full-width `ManagedByBanner` with
Expand Down Expand Up @@ -52,11 +53,11 @@ export interface ManagedByBadgeProps {

interface Variant {
icon: typeof ShieldAlert;
/** Short label shown inside the badge itself. */
/** Key under the `managedByBadge.*` locale namespace. */
i18nKey: string;
/** English fallbacks when the locale bundle misses a key. */
short: string;
/** Longer one-line title rendered in the tooltip heading. */
title: string;
/** Tooltip body. Receives the human-readable identity label (used only by better-auth). */
body: (display: string) => string;
/** Tailwind classes for the badge surface. */
tone: string;
Expand All @@ -65,6 +66,7 @@ interface Variant {
const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
config: {
icon: Settings2,
i18nKey: 'config',
short: 'Admin config',
title: 'Administrator configuration',
body: () =>
Expand All @@ -73,6 +75,7 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
},
system: {
icon: Lock,
i18nKey: 'system',
short: 'System-managed',
title: 'Managed by the platform',
body: () =>
Expand All @@ -81,6 +84,7 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
},
'append-only': {
icon: Archive,
i18nKey: 'appendOnly',
short: 'Read-only · Audit log',
title: 'Read-only historical record',
body: () =>
Expand All @@ -89,6 +93,7 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
},
'better-auth': {
icon: ShieldAlert,
i18nKey: 'betterAuth',
short: 'Identity',
title: 'Managed by the identity provider',
body: (display) =>
Expand All @@ -98,11 +103,18 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
};

export function ManagedByBadge({ managedBy, label, className }: ManagedByBadgeProps) {
const { t } = useObjectTranslation();
if (!managedBy || managedBy === 'platform') return null;
const variant = VARIANTS[managedBy as Exclude<Bucket, 'platform'>];
if (!variant) return null;
const Icon = variant.icon;
const display = label ?? 'better-auth';
const short = t(`managedByBadge.${variant.i18nKey}.short`, { defaultValue: variant.short });
const title = t(`managedByBadge.${variant.i18nKey}.title`, { defaultValue: variant.title });
const body = t(`managedByBadge.${variant.i18nKey}.body`, {
defaultValue: variant.body(display),
provider: display,
});
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
Expand All @@ -118,12 +130,12 @@ export function ManagedByBadge({ managedBy, label, className }: ManagedByBadgePr
)}
>
<Icon className="h-3 w-3" aria-hidden="true" />
<span>{variant.short}</span>
<span>{short}</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs text-xs leading-relaxed">
<p className="font-semibold mb-1">{variant.title}</p>
<p className="text-muted-foreground">{variant.body(display)}</p>
<p className="font-semibold mb-1">{title}</p>
<p className="text-muted-foreground">{body}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/__tests__/pickLocalized.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ describe('pickLocalized', () => {
it('falls back from region to base language (zh-CN -> zh)', () => {
expect(pickLocalized({ en: 'Pricing', zh: '定价' }, 'zh-CN')).toBe('定价');
});
it('upgrades from base language to a region-qualified key (zh -> zh-CN)', () => {
expect(pickLocalized({ en: 'Pricing', 'zh-CN': '定价' }, 'zh')).toBe('定价');
expect(pickLocalized({ en: 'Pricing', 'ja-JP': '価格' }, 'ja')).toBe('価格');
// exact/base keys still win over the regional upgrade
expect(pickLocalized({ zh: '基础', 'zh-CN': '区域' }, 'zh')).toBe('基础');
});
it('falls back to default then en then first', () => {
expect(pickLocalized({ default: 'D', en: 'E' }, 'fr')).toBe('D');
expect(pickLocalized({ en: 'E', ja: 'J' }, 'fr')).toBe('E');
Expand Down
Loading
Loading