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 (
+
+ );
+}
+
+/**
+ * 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 (
-