diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 8b7f912..f915ca8 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -9,8 +9,10 @@ import { Palette, UsersRound, Coins, + SlidersHorizontal, } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { useCan } from '@/hooks/use-can'; import { WhatsAppConfig } from '@/components/settings/whatsapp-config'; import { TemplateManager } from '@/components/settings/template-manager'; import { TagManager } from '@/components/settings/tag-manager'; @@ -20,12 +22,14 @@ import { SessionsCard } from '@/components/settings/sessions-card'; import { AppearancePanel } from '@/components/settings/appearance-panel'; import { MembersTab } from '@/components/settings/members-tab'; import { DealsSettings } from '@/components/settings/deals-settings'; +import { CustomFieldsSettings } from '@/components/settings/custom-fields-settings'; const TAB_VALUES = [ 'profile', 'whatsapp', 'templates', 'tags', + 'custom-fields', 'deals', 'appearance', 'members', @@ -40,12 +44,21 @@ export default function SettingsPage() { const router = useRouter(); const searchParams = useSearchParams(); + // Custom-field definitions are account-wide config, so editing them is + // admin+ only — mirror the gate on the Contacts page. The `custom_fields` + // RLS rejects non-admin writes regardless. + const canEditSettings = useCan('edit-settings'); + // The URL is the single source of truth for the active tab — no // local state, no sync effect. A previous revision duplicated this // into `useState` + a sync effect, which tripped React 19's // set-state-in-effect rule and was also redundant. const queryTab = searchParams.get('tab'); - const tab: TabValue = isTabValue(queryTab) ? queryTab : 'profile'; + // Deep-linking to the admin-only tab as a non-admin falls back to profile + // rather than landing on a tab with no trigger or content. + const resolved: TabValue = isTabValue(queryTab) ? queryTab : 'profile'; + const tab: TabValue = + resolved === 'custom-fields' && !canEditSettings ? 'profile' : resolved; const onChange = (next: TabValue) => { const params = new URLSearchParams(searchParams.toString()); @@ -57,59 +70,68 @@ export default function SettingsPage() {

Settings

-

+

Manage your profile, WhatsApp® integration, message templates, and tags.

onChange(v as TabValue)}> - + Profile WhatsApp Config Templates Tags + {canEditSettings && ( + + + Custom Fields + + )} Deals Appearance Members @@ -134,6 +156,12 @@ export default function SettingsPage() { + {canEditSettings && ( + + + + )} + diff --git a/src/components/contacts/custom-fields-manager.tsx b/src/components/contacts/custom-fields-manager.tsx index 6b759cf..9b91ac4 100644 --- a/src/components/contacts/custom-fields-manager.tsx +++ b/src/components/contacts/custom-fields-manager.tsx @@ -22,15 +22,39 @@ interface CustomFieldsManagerProps { } /** - * Create / rename / delete account-wide custom contact field definitions. - * Per-contact values are edited elsewhere (contact detail → Custom Fields); - * this only manages the field catalogue. Admin+ gated by the caller — the - * `custom_fields` RLS also rejects non-admin writes as defense in depth. + * Dialog wrapper around {@link CustomFieldsPanel}, used on the Contacts page. + * The same panel is rendered inline under Settings → Custom Fields, so the + * editing UI lives in one place. Radix unmounts the dialog content on close, + * so the panel remounts (and refetches) on each open. */ export function CustomFieldsManager({ open, onOpenChange, }: CustomFieldsManagerProps) { + return ( + + + + Custom fields + + Define extra contact fields (e.g. ZIP code, lead source). They + appear on every contact and in the “Update Contact Field” automation + action. + + + + + + ); +} + +/** + * Create / rename / delete account-wide custom contact field definitions. + * Per-contact values are edited elsewhere (contact detail → Custom Fields); + * this only manages the field catalogue. Admin+ gated by the caller — the + * `custom_fields` RLS also rejects non-admin writes as defense in depth. + */ +export function CustomFieldsPanel() { const supabase = createClient(); const { user, accountId } = useAuth(); @@ -51,27 +75,21 @@ export function CustomFieldsManager({ setLoading(false); }, [supabase, accountId]); - // Load the field list when the dialog opens. The setters inside - // fetchFields run after the Supabase await — not synchronously in the - // effect body — so the cascade the lint rule warns about doesn't apply. + // Load the field list on mount once the account is known. The setters + // inside fetchFields run after the Supabase await — not synchronously in + // the effect body — so the cascade the lint rule warns about doesn't apply. useEffect(() => { - if (open && accountId) { + if (accountId) { // eslint-disable-next-line react-hooks/set-state-in-effect fetchFields(); } - }, [open, accountId, fetchFields]); - - // Clear the draft on close (handler, not effect) so reopening starts fresh. - function handleOpenChange(next: boolean) { - if (!next) setNewName(''); - onOpenChange(next); - } + }, [accountId, fetchFields]); /** Case-insensitive name clash within the loaded list. */ function isDuplicate(name: string, exceptId?: string): boolean { const lower = name.toLowerCase(); return fields.some( - (f) => f.id !== exceptId && f.field_name.toLowerCase() === lower, + (f) => f.id !== exceptId && f.field_name.toLowerCase() === lower ); } @@ -107,7 +125,10 @@ export function CustomFieldsManager({ /** Returns true on success so the row can keep the new name, false so it * reverts to the previous one. No-ops (blank / unchanged) count as success. */ - async function handleRename(field: CustomField, nextName: string): Promise { + async function handleRename( + field: CustomField, + nextName: string + ): Promise { const name = nextName.trim(); if (!name || name === field.field_name) return true; if (isDuplicate(name, field.id)) { @@ -131,7 +152,7 @@ export function CustomFieldsManager({ async function handleDelete(field: CustomField) { if ( !window.confirm( - `Delete "${field.field_name}"? This also removes its stored value on every contact. This cannot be undone.`, + `Delete "${field.field_name}"? This also removes its stored value on every contact. This cannot be undone.` ) ) { return; @@ -151,71 +172,61 @@ export function CustomFieldsManager({ } return ( - - - - Custom fields - - Define extra contact fields (e.g. ZIP code, lead source). They appear - on every contact and in the “Update Contact Field” automation action. - - - - {/* Create */} -
- setNewName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - void handleCreate(); - } - }} - placeholder="New field name…" - className="bg-slate-800 text-white" - /> - -
- - {/* List */} -
- {loading ? ( -
- - Loading… -
- ) : fields.length === 0 ? ( -

- No custom fields yet. -

+
+ {/* Create */} +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void handleCreate(); + } + }} + placeholder="New field name…" + className="bg-slate-800 text-white" + /> +
- -
+ Add + +
+ + {/* List */} +
+ {loading ? ( +
+ + Loading… +
+ ) : fields.length === 0 ? ( +

+ No custom fields yet. +

+ ) : ( + + )} +
+ ); } @@ -254,7 +265,7 @@ function FieldRow({ if (e.key === 'Enter') e.currentTarget.blur(); }} aria-label={`Rename ${field.field_name}`} - className="h-8 border-transparent bg-transparent text-white hover:border-slate-700 focus:border-primary" + className="focus:border-primary h-8 border-transparent bg-transparent text-white hover:border-slate-700" />