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
9 changes: 9 additions & 0 deletions docs/releases/0.34.1.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions packages/frontend/src/components/shell/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export default function UserMenu() {
<div className="text-[13px] font-semibold text-ink truncate">{name}</div>
{user?.email ? <div className="text-xs text-muted truncate">{user.email}</div> : null}
</div>
<MenuItem icon={<Settings />} to="/account">
{t('nav.accountPreferences')}
</MenuItem>
<MenuSeparator />
<MenuHeading>{t('shell.user.organization')}</MenuHeading>
{org ? (
Expand Down Expand Up @@ -153,9 +156,6 @@ export default function UserMenu() {
CLI
</MenuItem>
<MenuSeparator />
<MenuItem icon={<Settings />} to="/account">
{t('nav.accountPreferences')}
</MenuItem>
<MenuItem icon={<LogOut />} danger onSelect={() => void signOut()}>
{t('nav.logOut')}
</MenuItem>
Expand Down
176 changes: 111 additions & 65 deletions packages/frontend/src/pages/Account.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: <Sun aria-hidden="true" /> },
Expand Down Expand Up @@ -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<AccountTab>[] = [
{ 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('');
Expand Down Expand Up @@ -91,28 +119,38 @@ export default function Account() {
{t('common.back')}
</Button>

<Tabs items={tabs} value={tab} onChange={switchTab} label={t('account.title')} />

<div className="flex flex-col gap-4">
<Panel title={t('account.profile.title')}>
<div className="flex flex-col gap-4">
<Field
label={t('account.profile.displayName')}
hint={t('account.profile.displayNameDesc')}
htmlFor="account-name"
>
<input id="account-name" className="field" type="text" value={userName} readOnly />
</Field>
<Field
label={t('account.profile.email')}
hint={t('account.profile.emailDesc')}
htmlFor="account-email"
>
<input id="account-email" className="field" type="email" value={userEmail} readOnly />
</Field>
<p className="text-xs text-faint">{t('account.profile.managedBy')}</p>
</div>
</Panel>

{hasCredentialAccount ? (
{tab === 'profile' ? (
<Panel title={t('account.profile.title')}>
<div className="flex flex-col gap-4">
<Field
label={t('account.profile.displayName')}
hint={t('account.profile.displayNameDesc')}
htmlFor="account-name"
>
<input id="account-name" className="field" type="text" value={userName} readOnly />
</Field>
<Field
label={t('account.profile.email')}
hint={t('account.profile.emailDesc')}
htmlFor="account-email"
>
<input
id="account-email"
className="field"
type="email"
value={userEmail}
readOnly
/>
</Field>
<p className="text-xs text-faint">{t('account.profile.managedBy')}</p>
</div>
</Panel>
) : null}

{tab === 'security' && hasCredentialAccount ? (
<Panel title={t('account.security.title')}>
<form className="flex flex-col gap-4" onSubmit={handleChangePassword}>
<Field
Expand Down Expand Up @@ -174,48 +212,56 @@ export default function Account() {
</Panel>
) : null}

<Panel title={t('account.workspace.title')}>
<p className="mb-3 text-sm text-dim">{t('account.workspace.desc')}</p>
<div className="well flex items-center gap-2">
<code className="min-w-0 flex-1 truncate font-mono text-[13px] text-ink">{userId}</code>
<CopyButton text={userId} />
</div>
</Panel>

<Panel title={t('account.appearance.title')}>
<p className="mb-3 text-sm text-dim">{t('account.appearance.desc')}</p>
<div className="segmented" role="group" aria-label={t('account.appearance.title')}>
{THEMES.map((option) => (
<button
key={option.value}
type="button"
className="inline-flex items-center gap-1.5 [&>svg]:size-[15px]"
aria-pressed={theme === option.value}
onClick={() => setThemePreference(option.value)}
>
{option.icon}
{option.label()}
</button>
))}
</div>
</Panel>

<Panel title={t('account.language.title')}>
<p className="mb-3 text-sm text-dim">{t('account.language.desc')}</p>
<div className="segmented" role="group" aria-label={t('account.language.label')}>
{LOCALES.map((l) => (
<button
key={l}
type="button"
aria-pressed={currentLocale === l}
lang={l}
onClick={() => setLocale(l)}
>
{LOCALE_NAMES[l]}
</button>
))}
</div>
</Panel>
{tab === 'workspace' ? (
<Panel title={t('account.workspace.title')}>
<p className="mb-3 text-sm text-dim">{t('account.workspace.desc')}</p>
<div className="well flex items-center gap-2">
<code className="min-w-0 flex-1 truncate font-mono text-[13px] text-ink">
{userId}
</code>
<CopyButton text={userId} />
</div>
</Panel>
) : null}

{tab === 'appearance' ? (
<Panel title={t('account.appearance.title')}>
<p className="mb-3 text-sm text-dim">{t('account.appearance.desc')}</p>
<div className="segmented" role="group" aria-label={t('account.appearance.title')}>
{THEMES.map((option) => (
<button
key={option.value}
type="button"
className="inline-flex items-center gap-1.5 [&>svg]:size-[15px]"
aria-pressed={theme === option.value}
onClick={() => setThemePreference(option.value)}
>
{option.icon}
{option.label()}
</button>
))}
</div>
</Panel>
) : null}

{tab === 'language' ? (
<Panel title={t('account.language.title')}>
<p className="mb-3 text-sm text-dim">{t('account.language.desc')}</p>
<div className="segmented" role="group" aria-label={t('account.language.label')}>
{LOCALES.map((l) => (
<button
key={l}
type="button"
aria-pressed={currentLocale === l}
lang={l}
onClick={() => setLocale(l)}
>
{LOCALE_NAMES[l]}
</button>
))}
</div>
</Panel>
) : null}
</div>
</div>
);
Expand Down
Loading
Loading