diff --git a/docs/releases/0.34.1.md b/docs/releases/0.34.1.md
new file mode 100644
index 00000000..f51313d6
--- /dev/null
+++ b/docs/releases/0.34.1.md
@@ -0,0 +1,9 @@
+# Thinker 0.34.1
+
+```bash
+docker pull tmsoftbrasil/thinker:0.34.1
+```
+
+## Mudanças
+
+- feat(dashboard): Account preferences at the top of the user menu and account page split into tabs
diff --git a/package-lock.json b/package-lock.json
index c3e7d957..9ce8d562 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14768,7 +14768,7 @@
}
},
"packages/thinker": {
- "version": "0.34.0"
+ "version": "0.34.1"
}
}
}
diff --git a/packages/frontend/src/components/shell/UserMenu.tsx b/packages/frontend/src/components/shell/UserMenu.tsx
index e10e8fe2..66c1573e 100644
--- a/packages/frontend/src/components/shell/UserMenu.tsx
+++ b/packages/frontend/src/components/shell/UserMenu.tsx
@@ -83,6 +83,9 @@ export default function UserMenu() {
{name}
{user?.email ? {user.email}
: null}
+ } to="/account">
+ {t('nav.accountPreferences')}
+
{t('shell.user.organization')}
{org ? (
@@ -153,9 +156,6 @@ export default function UserMenu() {
CLI
- } to="/account">
- {t('nav.accountPreferences')}
-
} danger onSelect={() => void signOut()}>
{t('nav.logOut')}
diff --git a/packages/frontend/src/pages/Account.tsx b/packages/frontend/src/pages/Account.tsx
index e30d1c61..f4ed7eb0 100644
--- a/packages/frontend/src/pages/Account.tsx
+++ b/packages/frontend/src/pages/Account.tsx
@@ -1,5 +1,5 @@
import { useState, type FormEvent, type ReactNode } from 'react';
-import { useNavigate } from 'react-router';
+import { useNavigate, useSearchParams } from 'react-router';
import { ChevronLeft, Monitor, Moon, Sun } from 'lucide-react';
import { authClient } from '../services/auth-client.js';
import { toast } from '../services/toast-store.js';
@@ -8,7 +8,9 @@ import { setThemePreference, themePreference, type ThemePreference } from '../li
import { useSignal } from '../lib/signal.js';
import { useResource } from '../lib/use-resource.js';
import CopyButton from '../components/CopyButton.js';
-import { Button, Field, Panel } from '../components/ui.js';
+import { Button, Field, Panel, Tabs, type TabItem } from '../components/ui.js';
+
+type AccountTab = 'profile' | 'security' | 'workspace' | 'appearance' | 'language';
const THEMES: Array<{ value: ThemePreference; label: () => string; icon: ReactNode }> = [
{ value: 'light', label: () => t('account.appearance.light'), icon: },
@@ -41,6 +43,32 @@ export default function Account() {
const userId = user?.id ?? '';
const currentLocale = useLocale();
+ // One tab per section, kept in the URL (?tab=) like the Organization page.
+ const tabs: TabItem[] = [
+ { key: 'profile', label: t('account.profile.title') },
+ ...(hasCredentialAccount
+ ? [{ key: 'security' as const, label: t('account.security.title') }]
+ : []),
+ { key: 'workspace', label: t('account.workspace.title') },
+ { key: 'appearance', label: t('account.appearance.title') },
+ { key: 'language', label: t('account.language.title') },
+ ];
+ const [searchParams, setSearchParams] = useSearchParams();
+ const requested = searchParams.get('tab');
+ const tab: AccountTab = tabs.some((i) => i.key === requested)
+ ? (requested as AccountTab)
+ : 'profile';
+ const switchTab = (next: AccountTab) =>
+ setSearchParams(
+ (prev) => {
+ const params = new URLSearchParams(prev);
+ if (next === 'profile') params.delete('tab');
+ else params.set('tab', next);
+ return params;
+ },
+ { replace: true },
+ );
+
const handleChangePassword = async (e: FormEvent) => {
e.preventDefault();
setPwError('');
@@ -91,28 +119,38 @@ export default function Account() {
{t('common.back')}
+
+
-
-
-
-
- {hasCredentialAccount ? (
+ {tab === 'profile' ? (
+
+
+
+ ) : null}
+
+ {tab === 'security' && hasCredentialAccount ? (
);
diff --git a/packages/frontend/tests/pages/Account.test.tsx b/packages/frontend/tests/pages/Account.test.tsx
index 4c7b8fab..0f73d869 100644
--- a/packages/frontend/tests/pages/Account.test.tsx
+++ b/packages/frontend/tests/pages/Account.test.tsx
@@ -2,9 +2,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
const mockNavigate = vi.fn();
-vi.mock('react-router', () => ({
- useNavigate: () => mockNavigate,
-}));
+// The page keeps its tab in ?tab=; tests start on `routeTab.current`.
+const routeTab = vi.hoisted(() => ({ current: '' }));
+vi.mock('react-router', async () => {
+ const { useState } = await import('react');
+ return {
+ useNavigate: () => mockNavigate,
+ useSearchParams: () => {
+ const [params, setParams] = useState(
+ () => new URLSearchParams(routeTab.current ? `tab=${routeTab.current}` : ''),
+ );
+ const update = (next: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams)) =>
+ setParams((prev) => (typeof next === 'function' ? next(prev) : next));
+ return [params, update] as const;
+ },
+ };
+});
const mockListAccounts = vi.fn();
const mockChangePassword = vi.fn();
@@ -59,6 +72,7 @@ describe('Account', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
+ routeTab.current = '';
setThemePreference('system');
mockListAccounts.mockResolvedValue({ data: [{ providerId: 'credential' }], error: null });
mockChangePassword.mockResolvedValue({ data: {}, error: null });
@@ -66,7 +80,7 @@ describe('Account', () => {
it('renders the profile panel', () => {
render();
- expect(screen.getByText('Profile information')).toBeDefined();
+ expect(screen.getByRole('tab', { name: 'Profile information' })).toBeDefined();
});
it('shows display name input with user name', () => {
@@ -82,6 +96,7 @@ describe('Account', () => {
});
it('shows theme options', () => {
+ routeTab.current = 'appearance';
render();
expect(screen.getByText('Light')).toBeDefined();
expect(screen.getByText('Dark')).toBeDefined();
@@ -89,8 +104,9 @@ describe('Account', () => {
});
it('shows workspace section', () => {
+ routeTab.current = 'workspace';
render();
- expect(screen.getByText('Workspace')).toBeDefined();
+ expect(screen.getByRole('tab', { name: 'Workspace' })).toBeDefined();
});
it('shows Back button', () => {
@@ -101,13 +117,14 @@ describe('Account', () => {
it('shows profile information section', () => {
render();
- expect(screen.getByText('Profile information')).toBeDefined();
+ expect(screen.getByRole('tab', { name: 'Profile information' })).toBeDefined();
});
it('shows Security section for a credential account', async () => {
mockListAccounts.mockResolvedValue({ data: [{ providerId: 'credential' }], error: null });
+ routeTab.current = 'security';
render();
- expect(await screen.findByText('Security')).toBeDefined();
+ expect(await screen.findByRole('tab', { name: 'Security' })).toBeDefined();
expect(screen.getByLabelText('Current password')).toBeDefined();
expect(screen.getByLabelText('New password')).toBeDefined();
expect(screen.getByLabelText('Confirm new password')).toBeDefined();
@@ -116,7 +133,7 @@ describe('Account', () => {
it('hides Security section for an OAuth-only account', async () => {
mockListAccounts.mockResolvedValue({ data: [{ providerId: 'google' }], error: null });
render();
- await screen.findByText('Profile information');
+ await screen.findByRole('tab', { name: 'Profile information' });
await waitFor(() => expect(mockListAccounts).toHaveBeenCalled());
expect(screen.queryByText('Security')).toBeNull();
});
@@ -124,7 +141,7 @@ describe('Account', () => {
it('hides Security section when listAccounts returns no data', async () => {
mockListAccounts.mockResolvedValue({ data: null, error: { message: 'boom' } });
render();
- await screen.findByText('Profile information');
+ await screen.findByRole('tab', { name: 'Profile information' });
await waitFor(() => expect(mockListAccounts).toHaveBeenCalled());
expect(screen.queryByText('Security')).toBeNull();
});
@@ -138,8 +155,9 @@ describe('Account', () => {
};
it('rejects when new and confirm do not match', async () => {
+ routeTab.current = 'security';
render();
- await screen.findByText('Security');
+ await screen.findByRole('tab', { name: 'Security' });
fillPw('oldpass1', 'newpass12', 'different12');
fireEvent.click(screen.getByText('Change password'));
expect(await screen.findByText('New passwords do not match')).toBeDefined();
@@ -147,8 +165,9 @@ describe('Account', () => {
});
it('rejects a new password shorter than 8 characters', async () => {
+ routeTab.current = 'security';
render();
- await screen.findByText('Security');
+ await screen.findByRole('tab', { name: 'Security' });
fillPw('oldpass1', 'short', 'short');
fireEvent.click(screen.getByText('Change password'));
expect(await screen.findByText('New password must be at least 8 characters')).toBeDefined();
@@ -156,8 +175,9 @@ describe('Account', () => {
});
it('rejects when the new password equals the current password', async () => {
+ routeTab.current = 'security';
render();
- await screen.findByText('Security');
+ await screen.findByRole('tab', { name: 'Security' });
fillPw('samepass1', 'samepass1', 'samepass1');
fireEvent.click(screen.getByText('Change password'));
expect(
@@ -167,8 +187,9 @@ describe('Account', () => {
});
it('changes the password and revokes other sessions on success', async () => {
+ routeTab.current = 'security';
render();
- await screen.findByText('Security');
+ await screen.findByRole('tab', { name: 'Security' });
fillPw('oldpass1', 'newpass12', 'newpass12');
fireEvent.click(screen.getByText('Change password'));
await waitFor(() =>
@@ -190,8 +211,9 @@ describe('Account', () => {
it('shows an inline error when changePassword fails', async () => {
mockChangePassword.mockResolvedValue({ data: null, error: { message: 'Invalid password' } });
+ routeTab.current = 'security';
render();
- await screen.findByText('Security');
+ await screen.findByRole('tab', { name: 'Security' });
fillPw('wrongpass1', 'newpass12', 'newpass12');
fireEvent.click(screen.getByText('Change password'));
expect(await screen.findByText('Invalid password')).toBeDefined();
@@ -202,8 +224,9 @@ describe('Account', () => {
it('falls back to a generic error message when changePassword error has no message', async () => {
mockChangePassword.mockResolvedValue({ data: null, error: {} });
+ routeTab.current = 'security';
render();
- await screen.findByText('Security');
+ await screen.findByRole('tab', { name: 'Security' });
fillPw('wrongpass1', 'newpass12', 'newpass12');
fireEvent.click(screen.getByText('Change password'));
expect(await screen.findByText('Failed to change password')).toBeDefined();
@@ -211,8 +234,9 @@ describe('Account', () => {
it('recovers and re-enables the button when changePassword rejects', async () => {
mockChangePassword.mockRejectedValue(new Error('network down'));
+ routeTab.current = 'security';
render();
- await screen.findByText('Security');
+ await screen.findByRole('tab', { name: 'Security' });
fillPw('oldpass1', 'newpass12', 'newpass12');
const button = screen.getByText('Change password').closest('button') as HTMLButtonElement;
fireEvent.click(button);
@@ -221,16 +245,19 @@ describe('Account', () => {
});
it('shows appearance section', () => {
+ routeTab.current = 'appearance';
render();
- expect(screen.getByText('Appearance')).toBeDefined();
+ expect(screen.getByRole('tab', { name: 'Appearance' })).toBeDefined();
});
it('shows workspace ID', () => {
+ routeTab.current = 'workspace';
const { container } = render();
expect(container.textContent).toContain('u1');
});
it('applies light theme when Light clicked', () => {
+ routeTab.current = 'appearance';
render();
fireEvent.click(screen.getByText('Light'));
expect(localStorage.getItem('theme')).toBe('light');
@@ -238,6 +265,7 @@ describe('Account', () => {
});
it('applies dark theme when Dark clicked', () => {
+ routeTab.current = 'appearance';
render();
fireEvent.click(screen.getByText('Dark'));
expect(localStorage.getItem('theme')).toBe('dark');
@@ -246,6 +274,7 @@ describe('Account', () => {
it('removes theme from storage when System clicked', () => {
setThemePreference('dark');
+ routeTab.current = 'appearance';
render();
fireEvent.click(screen.getByText('System'));
expect(localStorage.getItem('theme')).toBeNull();
@@ -253,6 +282,7 @@ describe('Account', () => {
});
it('copies user ID to clipboard when copy button clicked', async () => {
+ routeTab.current = 'workspace';
const { container } = render();
const copyBtn = container.querySelector('.copy-btn') as HTMLElement;
await act(async () => {
@@ -263,12 +293,14 @@ describe('Account', () => {
it('reads stored theme on mount', () => {
setThemePreference('dark');
+ routeTab.current = 'appearance';
render();
expect(pressed('Dark')).toBe('true');
expect(pressed('System')).toBe('false');
});
it('follows a theme change made elsewhere, like the topbar toggle', () => {
+ routeTab.current = 'appearance';
render();
expect(pressed('System')).toBe('true');
act(() => setThemePreference('light'));
@@ -276,6 +308,16 @@ describe('Account', () => {
expect(pressed('System')).toBe('false');
});
+ it('shows one section per tab, starting on Profile', async () => {
+ render();
+ expect(screen.getByLabelText('Display name')).toBeDefined();
+ expect(screen.queryByRole('button', { name: 'Dark' })).toBeNull();
+ fireEvent.click(screen.getByRole('tab', { name: 'Appearance' }));
+ expect(screen.getByRole('button', { name: 'Dark' })).toBeDefined();
+ expect(screen.queryByLabelText('Display name')).toBeNull();
+ expect(await screen.findByRole('tab', { name: 'Security' })).toBeDefined();
+ });
+
it('shows no billing, plan or usage-email sections', async () => {
const { container } = render();
await waitFor(() => expect(mockListAccounts).toHaveBeenCalled());
diff --git a/packages/thinker/package.json b/packages/thinker/package.json
index eec6a1c8..532584b5 100644
--- a/packages/thinker/package.json
+++ b/packages/thinker/package.json
@@ -1,6 +1,6 @@
{
"name": "thinker",
- "version": "0.34.0",
+ "version": "0.34.1",
"private": true,
"description": "Canonical release version for the Thinker Docker image. This package holds no code — the real backend and frontend live in ../backend and ../frontend. See README.md for details."
}