diff --git a/apps/web/src/components/settings/account-settings-form.test.tsx b/apps/web/src/components/settings/account-settings-form.test.tsx new file mode 100644 index 0000000..9273c70 --- /dev/null +++ b/apps/web/src/components/settings/account-settings-form.test.tsx @@ -0,0 +1,221 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AccountSettingsForm } from './account-settings-form'; + +/** + * account-settings-form.test — Akzentfarbe Hex-Eingabe (quick-260917-gsh). + * next-intl-Mock de.json-gestuetzt (Muster: desktop-app-settings.test.tsx), + * `@/lib/auth-actions` per vi.hoisted + vi.mock ersetzt. + */ +vi.mock('next-intl', async () => { + const messages = (await import('@/messages/de.json')).default as Record; + const lookup = (path: string): string | undefined => + path.split('.').reduce((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as + | string + | undefined; + return { + useTranslations: + (ns?: string) => + (key: string, values?: Record) => { + const raw = lookup(ns ? `${ns}.${key}` : key) ?? key; + return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw; + }, + useLocale: () => 'de', + }; +}); + +const { fetchCurrentUser, updateAccentColorAction, changePasswordAction, uploadAvatarAction, deleteAvatarAction } = + vi.hoisted(() => ({ + fetchCurrentUser: vi.fn(), + updateAccentColorAction: vi.fn(), + changePasswordAction: vi.fn(), + uploadAvatarAction: vi.fn(), + deleteAvatarAction: vi.fn(), + })); + +vi.mock('@/lib/auth-actions', () => ({ + fetchCurrentUser, + updateAccentColorAction, + changePasswordAction, + uploadAvatarAction, + deleteAvatarAction, +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('AccountSettingsForm — Akzentfarbe Hex-Eingabe', () => { + it('prefills the hex textfield and the color picker from the loaded user', async () => { + fetchCurrentUser.mockResolvedValue({ + id: 'u1', + username: 'max', + displayName: 'Max', + role: 'USER', + tenantId: 't1', + isLocalUser: true, + hasAvatar: false, + accentColor: '#123456', + }); + + render(); + + const hexInput = (await screen.findByRole('textbox', { + name: 'Hex-Code', + })) as HTMLInputElement; + expect(hexInput.value).toBe('#123456'); + + const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement; + expect(colorPicker.value).toBe('#123456'); + }); + + it('accepts a valid hex value typed without # and syncs the color picker', async () => { + fetchCurrentUser.mockResolvedValue({ + id: 'u1', + username: 'max', + displayName: 'Max', + role: 'USER', + tenantId: 't1', + isLocalUser: true, + hasAvatar: false, + accentColor: '#123456', + }); + + render(); + const hexInput = (await screen.findByRole('textbox', { + name: 'Hex-Code', + })) as HTMLInputElement; + const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement; + + fireEvent.change(hexInput, { target: { value: 'FFED00' } }); + + expect(colorPicker.value).toBe('#ffed00'); + expect(hexInput).toHaveAttribute('aria-invalid', 'false'); + expect(screen.getByText('Farbe speichern')).not.toBeDisabled(); + }); + + it('marks an invalid hex value, blocks save, and keeps the last valid color picker value', async () => { + fetchCurrentUser.mockResolvedValue({ + id: 'u1', + username: 'max', + displayName: 'Max', + role: 'USER', + tenantId: 't1', + isLocalUser: true, + hasAvatar: false, + accentColor: '#123456', + }); + + render(); + const hexInput = (await screen.findByRole('textbox', { + name: 'Hex-Code', + })) as HTMLInputElement; + const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement; + + fireEvent.change(hexInput, { target: { value: '#ggg' } }); + + expect(hexInput).toHaveAttribute('aria-invalid', 'true'); + expect(hexInput.className).toContain('border-destructive'); + expect( + screen.getByText('Ungültiger Farbwert. Bitte geben Sie sechs Hexadezimalzeichen ein, z. B. #ffed00.'), + ).toBeInTheDocument(); + expect(screen.getByText('Farbe speichern')).toBeDisabled(); + expect(colorPicker.value).toBe('#123456'); + }); + + it('syncs the hex textfield when the color picker changes', async () => { + fetchCurrentUser.mockResolvedValue({ + id: 'u1', + username: 'max', + displayName: 'Max', + role: 'USER', + tenantId: 't1', + isLocalUser: true, + hasAvatar: false, + accentColor: '#123456', + }); + + render(); + await screen.findByRole('textbox', { name: 'Hex-Code' }); + const hexInput = screen.getByRole('textbox', { name: 'Hex-Code' }) as HTMLInputElement; + const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement; + + fireEvent.change(colorPicker, { target: { value: '#00ff00' } }); + + expect(hexInput.value).toBe('#00ff00'); + }); + + it('normalizes the hex textfield to canonical form on blur', async () => { + fetchCurrentUser.mockResolvedValue({ + id: 'u1', + username: 'max', + displayName: 'Max', + role: 'USER', + tenantId: 't1', + isLocalUser: true, + hasAvatar: false, + accentColor: '#123456', + }); + + render(); + const hexInput = (await screen.findByRole('textbox', { + name: 'Hex-Code', + })) as HTMLInputElement; + + fireEvent.change(hexInput, { target: { value: '#fe0' } }); + fireEvent.blur(hexInput); + + expect(hexInput.value).toBe('#ffee00'); + }); + + it('resets both the hex textfield and the color picker on "Zurücksetzen"', async () => { + fetchCurrentUser.mockResolvedValue({ + id: 'u1', + username: 'max', + displayName: 'Max', + role: 'USER', + tenantId: 't1', + isLocalUser: true, + hasAvatar: false, + accentColor: '#123456', + }); + updateAccentColorAction.mockResolvedValue({ success: true }); + + render(); + const hexInput = (await screen.findByRole('textbox', { + name: 'Hex-Code', + })) as HTMLInputElement; + const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement; + + fireEvent.click(screen.getByText('Zurücksetzen')); + + await waitFor(() => { + expect(hexInput.value).toBe('#ffed00'); + }); + expect(colorPicker.value).toBe('#ffed00'); + expect(updateAccentColorAction).toHaveBeenCalledWith(null); + }); + + it('saves the normalized color and shows the success message', async () => { + fetchCurrentUser.mockResolvedValue({ + id: 'u1', + username: 'max', + displayName: 'Max', + role: 'USER', + tenantId: 't1', + isLocalUser: true, + hasAvatar: false, + accentColor: '#ffed00', + }); + updateAccentColorAction.mockResolvedValue({ success: true }); + + render(); + await screen.findByRole('textbox', { name: 'Hex-Code' }); + + fireEvent.click(screen.getByText('Farbe speichern')); + + expect(await screen.findByText('Akzentfarbe erfolgreich gespeichert.')).toBeInTheDocument(); + expect(updateAccentColorAction).toHaveBeenCalledWith('#ffed00'); + }); +}); diff --git a/apps/web/src/components/settings/account-settings-form.tsx b/apps/web/src/components/settings/account-settings-form.tsx index d026943..eba691d 100644 --- a/apps/web/src/components/settings/account-settings-form.tsx +++ b/apps/web/src/components/settings/account-settings-form.tsx @@ -11,6 +11,7 @@ import { } from '@/lib/auth-actions'; import { useAuthStore } from '@/lib/stores/auth-store'; import { BRAND_YELLOW } from '@/components/brand/brand'; +import { normalizeHexColor } from '@/lib/color'; const DEFAULT_ACCENT = BRAND_YELLOW; @@ -38,6 +39,8 @@ export function AccountSettingsForm() { // Accent color state const [accentColor, setAccentColor] = useState(DEFAULT_ACCENT); + const [hexInput, setHexInput] = useState(DEFAULT_ACCENT); + const isHexValid = normalizeHexColor(hexInput) !== null; const [accentSuccess, setAccentSuccess] = useState(false); const [accentError, setAccentError] = useState(null); const [isAccentPending, startAccentTransition] = useTransition(); @@ -48,7 +51,9 @@ export function AccountSettingsForm() { setIsLocalUser(u.isLocalUser ?? false); setUsername(u.displayName ?? u.username ?? ''); setHasAvatar(!!u.hasAvatar); - setAccentColor(u.accentColor ?? DEFAULT_ACCENT); + const c = u.accentColor ?? DEFAULT_ACCENT; + setAccentColor(c); + setHexInput(c); } }); }, []); @@ -113,6 +118,7 @@ export function AccountSettingsForm() { } function handleSaveAccentColor() { + if (!isHexValid) return; setAccentSuccess(false); setAccentError(null); startAccentTransition(async () => { @@ -128,6 +134,7 @@ export function AccountSettingsForm() { function handleResetAccentColor() { setAccentColor(DEFAULT_ACCENT); + setHexInput(DEFAULT_ACCENT); setAccentSuccess(false); setAccentError(null); startAccentTransition(async () => { @@ -212,14 +219,41 @@ export function AccountSettingsForm() { {t('account.accentColorLabel')}

{t('account.accentColorHelp')}

-
+
setAccentColor(e.target.value)} + onChange={(e) => { + setAccentColor(e.target.value); + setHexInput(e.target.value); + }} className="h-10 w-16 cursor-pointer rounded-md border border-input p-1" /> - {accentColor} + { + const raw = e.target.value; + setHexInput(raw); + const n = normalizeHexColor(raw); + if (n) setAccentColor(n); + }} + onBlur={() => { + const n = normalizeHexColor(hexInput); + if (n) setHexInput(n); + }} + className={`h-10 w-28 rounded-md border bg-background px-3 py-2 font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${ + isHexValid ? 'border-input' : 'border-destructive' + }`} + />
+ {!isHexValid && ( +

{t('account.accentColorHexInvalid')}

+ )} {accentSuccess && (
{t('account.accentColorSuccess')} @@ -242,7 +279,7 @@ export function AccountSettingsForm() {