From 2b21d1cff71f3f95c839470272a6e41e83a8da3c Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 22 May 2026 10:04:33 -0400 Subject: [PATCH 1/6] fix(trust): persist portal branding settings --- .../components/BrandSettings.test.tsx | 47 +++- .../components/BrandSettings.tsx | 202 +++++++++++------- .../TrustPortalBrandingSettings.test.tsx | 50 +++++ .../TrustPortalBrandingSettings.tsx | 42 ++++ .../components/TrustPortalSwitch.test.tsx | 63 ++++-- .../components/TrustPortalSwitch.tsx | 32 ++- .../components/UpdateTrustFavicon.test.tsx | 112 +++++++--- .../components/UpdateTrustFavicon.tsx | 44 +++- 8 files changed, 419 insertions(+), 173 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.tsx diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.test.tsx index 09aece5784..33b7102c1f 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.test.tsx @@ -1,11 +1,19 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - setMockPermissions, ADMIN_PERMISSIONS, AUDITOR_PERMISSIONS, mockHasPermission, + setMockPermissions, } from '@/test-utils/mocks/permissions'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const navigationMock = vi.hoisted(() => ({ + refresh: vi.fn(), +})); + +const trustPortalSettingsMock = vi.hoisted(() => ({ + updateToggleSettings: vi.fn(), +})); vi.mock('@/hooks/use-permissions', () => ({ usePermissions: () => ({ @@ -16,7 +24,7 @@ vi.mock('@/hooks/use-permissions', () => ({ vi.mock('@/hooks/use-trust-portal-settings', () => ({ useTrustPortalSettings: () => ({ - updateToggleSettings: vi.fn(), + updateToggleSettings: trustPortalSettingsMock.updateToggleSettings, }), })); @@ -24,6 +32,10 @@ vi.mock('@/hooks/useDebounce', () => ({ useDebounce: (value: string) => value, })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: navigationMock.refresh }), +})); + vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); @@ -32,21 +44,21 @@ import { BrandSettings } from './BrandSettings'; describe('BrandSettings permission gating', () => { const defaultProps = { - orgId: 'org-1', primaryColor: '#FF0000', }; beforeEach(() => { vi.clearAllMocks(); + trustPortalSettingsMock.updateToggleSettings.mockResolvedValue({ + success: true, + }); }); it('renders title and description regardless of permissions', () => { setMockPermissions({}); render(); expect(screen.getByText('Brand Settings')).toBeInTheDocument(); - expect( - screen.getByText('Customize the appearance of your trust portal'), - ).toBeInTheDocument(); + expect(screen.getByText('Customize the appearance of your trust portal')).toBeInTheDocument(); }); it('enables the color picker input when user has trust:update permission', () => { @@ -75,4 +87,23 @@ describe('BrandSettings permission gating', () => { render(); expect(screen.getByText('Brand Color')).toBeInTheDocument(); }); + + it('persists a valid brand color and refreshes settings', async () => { + setMockPermissions(ADMIN_PERMISSIONS); + const handlePrimaryColorChange = vi.fn(); + + render(); + + const textInput = screen.getByRole('textbox'); + fireEvent.change(textInput, { target: { value: '#00ff00' } }); + + await waitFor(() => { + expect(trustPortalSettingsMock.updateToggleSettings).toHaveBeenCalledWith({ + enabled: true, + primaryColor: '#00FF00', + }); + }); + expect(handlePrimaryColorChange).toHaveBeenCalledWith('#00FF00'); + expect(navigationMock.refresh).toHaveBeenCalled(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.tsx index ed82a875e0..85fcfed401 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.tsx @@ -1,29 +1,50 @@ 'use client'; -import { useDebounce } from '@/hooks/useDebounce'; import { usePermissions } from '@/hooks/use-permissions'; import { useTrustPortalSettings } from '@/hooks/use-trust-portal-settings'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input } from '@trycompai/design-system'; -import { Form, FormControl, FormField, FormItem, FormLabel } from '@trycompai/ui/form'; +import { useDebounce } from '@/hooks/useDebounce'; import { zodResolver } from '@hookform/resolvers/zod'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Field, + FieldLabel, + Input, +} from '@trycompai/design-system'; +import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; +const DEFAULT_PRIMARY_COLOR = '#000000'; +const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; + const trustSettingsSchema = z.object({ - primaryColor: z.string().optional(), + primaryColor: z.string().regex(HEX_COLOR_PATTERN, 'Enter a valid hex color').optional(), }); interface BrandSettingsProps { - orgId: string; + enabled?: boolean; primaryColor: string | null; + onPrimaryColorChange?: (primaryColor: string | null) => void; +} + +function normalizePrimaryColor(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0) return null; + if (!HEX_COLOR_PATTERN.test(value)) return null; + return value.toUpperCase(); } export function BrandSettings({ - orgId, + enabled = true, primaryColor, + onPrimaryColorChange, }: BrandSettingsProps) { + const router = useRouter(); const { hasPermission } = usePermissions(); const canUpdate = hasPermission('trust', 'update'); const { updateToggleSettings } = useTrustPortalSettings(); @@ -36,7 +57,7 @@ export function BrandSettings({ }); const lastSaved = useRef<{ [key: string]: string | null }>({ - primaryColor: primaryColor ?? null, + primaryColor: normalizePrimaryColor(primaryColor), }); const savingRef = useRef<{ [key: string]: boolean }>({ @@ -49,16 +70,25 @@ export function BrandSettings({ return; } - if (lastSaved.current[field] !== value) { + const nextPrimaryColor = normalizePrimaryColor(value); + if (!nextPrimaryColor) { + return; + } + + if (lastSaved.current[field] !== nextPrimaryColor) { savingRef.current[field] = true; try { await updateToggleSettings({ - enabled: true, + enabled, primaryColor: - field === 'primaryColor' ? (value as string) : (form.getValues('primaryColor') ?? undefined), + field === 'primaryColor' + ? nextPrimaryColor + : (form.getValues('primaryColor') ?? undefined), }); toast.success('Brand settings updated'); - lastSaved.current[field] = value as string | null; + lastSaved.current[field] = nextPrimaryColor; + onPrimaryColorChange?.(nextPrimaryColor); + router.refresh(); } catch { toast.error('Failed to update brand settings'); } finally { @@ -66,30 +96,43 @@ export function BrandSettings({ } } }, - [form, updateToggleSettings], + [enabled, form, onPrimaryColorChange, router, updateToggleSettings], ); const [primaryColorValue, setPrimaryColorValue] = useState(form.getValues('primaryColor') || ''); const debouncedPrimaryColor = useDebounce(primaryColorValue, 800); + useEffect(() => { + const normalizedPrimaryColor = normalizePrimaryColor(primaryColor); + form.reset({ primaryColor: normalizedPrimaryColor ?? undefined }); + setPrimaryColorValue(normalizedPrimaryColor ?? ''); + lastSaved.current.primaryColor = normalizedPrimaryColor; + }, [form, primaryColor]); + useEffect(() => { if ( debouncedPrimaryColor !== undefined && - debouncedPrimaryColor !== lastSaved.current.primaryColor && + normalizePrimaryColor(debouncedPrimaryColor) !== lastSaved.current.primaryColor && !savingRef.current.primaryColor ) { - form.setValue('primaryColor', debouncedPrimaryColor || undefined); - void autoSave('primaryColor', debouncedPrimaryColor || null); + const normalizedPrimaryColor = normalizePrimaryColor(debouncedPrimaryColor); + if (normalizedPrimaryColor) { + form.setValue('primaryColor', normalizedPrimaryColor); + void autoSave('primaryColor', normalizedPrimaryColor); + } } }, [debouncedPrimaryColor, autoSave, form]); const handlePrimaryColorBlur = useCallback( (e: React.FocusEvent) => { - const value = e.target.value; - if (value) { - form.setValue('primaryColor', value); + const value = normalizePrimaryColor(e.target.value); + if (!value) { + toast.error('Enter a valid hex color'); + return; } - void autoSave('primaryColor', value || null); + form.setValue('primaryColor', value); + setPrimaryColorValue(value); + void autoSave('primaryColor', value); }, [form, autoSave], ); @@ -101,69 +144,68 @@ export function BrandSettings({ Customize the appearance of your trust portal -
-
- ( - - Brand Color - +
+ ( + + Brand Color +
+
-
-
- { - field.onChange(e); - setPrimaryColorValue(e.target.value); - }} - onBlur={handlePrimaryColorBlur} - type="color" - className="sr-only" - id="color-picker" - disabled={!canUpdate} - /> - -
-
-
- { - let value = e.target.value; - if (!value.startsWith('#')) { - value = '#' + value; - } - field.onChange(value); - setPrimaryColorValue(value); - }} - onBlur={handlePrimaryColorBlur} - placeholder="#000000" - maxLength={7} - disabled={!canUpdate} - /> -
-
+ { + field.onChange(e); + setPrimaryColorValue(e.target.value); + }} + onBlur={handlePrimaryColorBlur} + type="color" + className="sr-only" + id="color-picker" + disabled={!canUpdate} + /> + +
+
+
+ { + let value = e.target.value; + if (value.length > 0 && !value.startsWith('#')) { + value = '#' + value; + } + field.onChange(value); + setPrimaryColorValue(value); + }} + onBlur={handlePrimaryColorBlur} + placeholder="#000000" + maxLength={7} + disabled={!canUpdate} + />
- - - )} - /> -

- Used for branding across your trust portal -

-
- +
+
+
+ )} + /> +

+ Used for branding across your trust portal +

+
); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.test.tsx new file mode 100644 index 0000000000..9f653b2472 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { TrustPortalBrandingSettings } from './TrustPortalBrandingSettings'; + +interface MockFaviconProps { + currentFaviconUrl: string | null; + onFaviconChange?: (faviconUrl: string | null) => void; +} + +interface MockBrandProps { + primaryColor: string | null; + onPrimaryColorChange?: (primaryColor: string | null) => void; +} + +vi.mock('./UpdateTrustFavicon', () => ({ + UpdateTrustFavicon: ({ currentFaviconUrl, onFaviconChange }: MockFaviconProps) => ( +
+
{currentFaviconUrl ?? 'default'}
+ +
+ ), +})); + +vi.mock('./BrandSettings', () => ({ + BrandSettings: ({ primaryColor, onPrimaryColorChange }: MockBrandProps) => ( +
+
{primaryColor ?? 'default'}
+ +
+ ), +})); + +describe('TrustPortalBrandingSettings', () => { + it('keeps saved branding values available for remounted tab content', () => { + render(); + + expect(screen.getByTestId('favicon-url')).toHaveTextContent('default'); + expect(screen.getByTestId('primary-color')).toHaveTextContent('default'); + + fireEvent.click(screen.getByRole('button', { name: /set favicon/i })); + fireEvent.click(screen.getByRole('button', { name: /set color/i })); + + expect(screen.getByTestId('favicon-url')).toHaveTextContent('https://example.com/favicon.png'); + expect(screen.getByTestId('primary-color')).toHaveTextContent('#00FF00'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.tsx new file mode 100644 index 0000000000..4ca0f86900 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { BrandSettings } from './BrandSettings'; +import { UpdateTrustFavicon } from './UpdateTrustFavicon'; + +interface TrustPortalBrandingSettingsProps { + enabled: boolean; + primaryColor: string | null; + faviconUrl: string | null; +} + +export function TrustPortalBrandingSettings({ + enabled, + primaryColor, + faviconUrl, +}: TrustPortalBrandingSettingsProps) { + const [currentPrimaryColor, setCurrentPrimaryColor] = useState(primaryColor); + const [currentFaviconUrl, setCurrentFaviconUrl] = useState(faviconUrl); + + useEffect(() => { + setCurrentPrimaryColor(primaryColor); + }, [primaryColor]); + + useEffect(() => { + setCurrentFaviconUrl(faviconUrl); + }, [faviconUrl]); + + return ( +
+ + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx index 56a1be66ba..90c2e71e89 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx @@ -1,11 +1,11 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - setMockPermissions, ADMIN_PERMISSIONS, AUDITOR_PERMISSIONS, mockHasPermission, + setMockPermissions, } from '@/test-utils/mocks/permissions'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/hooks/use-permissions', () => ({ usePermissions: () => ({ @@ -100,8 +100,12 @@ vi.mock('@dnd-kit/utilities', () => ({ // Mock design system vi.mock('@trycompai/design-system', () => ({ - Button: ({ children, onClick, disabled, iconLeft, iconRight, ...props }: any) => ( - + Button: ({ children, onClick, disabled, iconLeft, iconRight, loading, ...props }: any) => ( + ), Card: ({ children }: any) =>
{children}
, CardContent: ({ children }: any) =>
{children}
, @@ -112,6 +116,8 @@ vi.mock('@trycompai/design-system', () => ({ DropdownMenuContent: ({ children }: any) =>
{children}
, DropdownMenuItem: ({ children, onClick }: any) => , DropdownMenuTrigger: ({ children }: any) => , + Field: ({ children }: any) =>
{children}
, + FieldLabel: ({ children }: any) => , Input: (props: any) => , Select: ({ children, disabled }: any) =>
{children}
, SelectContent: ({ children }: any) =>
{children}
, @@ -130,7 +136,11 @@ vi.mock('@trycompai/design-system', () => ({ Tabs: ({ children }: any) =>
{children}
, TabsContent: ({ children, value }: any) =>
{children}
, TabsList: ({ children }: any) =>
{children}
, - TabsTrigger: ({ children, value }: any) => , + TabsTrigger: ({ children, value }: any) => ( + + ), Textarea: (props: any) =>