mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
PR 6 of the multi-user accounts series. First user-facing surface
for the feature — the Members tab in Settings.
What lands
- `src/components/settings/members-tab.tsx` — roster + pending
invitations. Every member can see the roster (read-only).
Admin+ gets an inline role-select dropdown (excluding the
owner row and self), a Remove button (same restrictions), and
the "Pending invitations" section with revoke.
- `src/components/settings/invite-member-dialog.tsx` — two-step
modal. Form (role + expiry + optional label) → result
(read-only URL with copy-to-clipboard + a "Send via WhatsApp"
deep link). The result step is dismissed-once: the plaintext
token is only ever shown here and never resurfaced, so the
copy carries a warning ("Save this link now").
- `src/app/(dashboard)/settings/page.tsx` — adds `members` to
the tab list, gated behind the existing
`profiles.beta_features.includes('account_sharing')` flag
(same column migration 011 added; flip it via Supabase Studio
on a per-profile basis to dogfood). When the flag is off the
tab trigger AND content render nothing — the page degrades
to the original 5-tab layout. A user landing on
`/settings?tab=members` without the flag is silently bounced
to `?tab=profile` rather than seeing an empty pane.
Why a feature flag at all
- Locked decision from the planning phase: ship the schema and
API unconditionally (they're structural, harmless to existing
single-user installs) but keep the *user-visible* invite
surface behind a flag until the full UI series ships (Join
page, sidebar gating, webhook account routing). Mirrors how
migration 011 / Flows beta worked.
Role-gating
- The tab is reachable by any member of an account. Inside the
tab, mutation controls (Invite member button, role dropdown,
Remove button, pending invitations section + revoke buttons)
are wrapped in `<RequireRole min="admin">` or hidden via
`useAuth().canManageMembers` from #172.
- Server-side RPCs (set_member_role, remove_account_member from
#170; invitation create/revoke from #171) double-check role
anyway — the client gate is UX, not security.
Owner row
- Non-editable. Inline role select and Remove button are hidden
on the owner's row across the board. Owner role changes go
through the (deferred) Transfer Ownership flow.
Self row
- "You" badge on the caller's own row. Role select and Remove
are hidden on it — admins can't accidentally demote or remove
themselves, even though the server RPC would also catch it.
Email visibility
- Admin+ sees teammate emails; agent/viewer sees name + avatar
+ role + joined date only. Implemented server-side by the
`/api/account/members` route in #170; the component just
renders `member.email` when present.
UI patterns
- Mirrors existing tag-manager / template-manager card +
divide-y list layout and the slate-900/slate-700/primary
color tokens from the dark-only theme.
- Dialogs follow the established `bg-slate-900 border-slate-700`
pattern. Destructive actions get the `bg-red-600 hover:bg-red-700`
treatment with `Loader2` spinner.
- "Send via WhatsApp" anchor uses `buttonVariants` directly
rather than `<Button asChild>` — the wacrm Button is the
Base UI ButtonPrimitive and has no Radix-style asChild.
- Base UI Select's `onValueChange` is
`(value: string | null) => void`; every consumer guards on
null. We don't expose a clear affordance, so null shouldn't
fire at runtime — but typescript requires the guard.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds; /settings stays static (data
fetches happen client-side via useEffect)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
5.6 KiB
TypeScript
168 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import {
|
|
Settings,
|
|
MessageSquare,
|
|
Tag,
|
|
User,
|
|
Palette,
|
|
UsersRound,
|
|
} from 'lucide-react';
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
|
import { TemplateManager } from '@/components/settings/template-manager';
|
|
import { TagManager } from '@/components/settings/tag-manager';
|
|
import { ProfileForm } from '@/components/settings/profile-form';
|
|
import { PasswordForm } from '@/components/settings/password-form';
|
|
import { SessionsCard } from '@/components/settings/sessions-card';
|
|
import { AppearancePanel } from '@/components/settings/appearance-panel';
|
|
import { MembersTab } from '@/components/settings/members-tab';
|
|
import { useAuth } from '@/hooks/use-auth';
|
|
|
|
const BASE_TAB_VALUES = [
|
|
'profile',
|
|
'whatsapp',
|
|
'templates',
|
|
'tags',
|
|
'appearance',
|
|
] as const;
|
|
const FLAGGED_TAB_VALUES = ['members'] as const;
|
|
const TAB_VALUES = [...BASE_TAB_VALUES, ...FLAGGED_TAB_VALUES] as const;
|
|
type TabValue = (typeof TAB_VALUES)[number];
|
|
|
|
function isTabValue(v: string | null): v is TabValue {
|
|
return !!v && (TAB_VALUES as readonly string[]).includes(v);
|
|
}
|
|
|
|
// Flag key matches what migration 011 introduced. The Members tab
|
|
// stays hidden until the user's profile.beta_features array contains
|
|
// this string; flip it via Supabase Studio:
|
|
// UPDATE profiles SET beta_features = beta_features || ARRAY['account_sharing']
|
|
// WHERE user_id = '<theirs>';
|
|
const ACCOUNT_SHARING_FLAG = 'account_sharing';
|
|
|
|
export default function SettingsPage() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { profile, profileLoading } = useAuth();
|
|
|
|
const accountSharingEnabled =
|
|
!profileLoading &&
|
|
!!profile?.beta_features?.includes(ACCOUNT_SHARING_FLAG);
|
|
|
|
// 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 requestedTab: TabValue = isTabValue(queryTab) ? queryTab : 'profile';
|
|
|
|
// If a user lands on /settings?tab=members but the flag is off
|
|
// (e.g. their profile was reset, or they followed a stale link),
|
|
// fall back to the profile tab silently rather than rendering an
|
|
// empty TabsContent.
|
|
const tab: TabValue =
|
|
requestedTab === 'members' && !accountSharingEnabled
|
|
? 'profile'
|
|
: requestedTab;
|
|
|
|
const onChange = (next: TabValue) => {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
params.set('tab', next);
|
|
router.replace(`/settings?${params.toString()}`, { scroll: false });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
|
<p className="text-sm text-slate-400 mt-1">
|
|
Manage your profile, WhatsApp® integration, message templates, and
|
|
tags.
|
|
</p>
|
|
</div>
|
|
|
|
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}>
|
|
<TabsList className="bg-slate-900 border border-slate-700">
|
|
<TabsTrigger
|
|
value="profile"
|
|
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
|
>
|
|
<User className="size-4" />
|
|
Profile
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="whatsapp"
|
|
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
|
>
|
|
<Settings className="size-4" />
|
|
WhatsApp Config
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="templates"
|
|
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
|
>
|
|
<MessageSquare className="size-4" />
|
|
Templates
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="tags"
|
|
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
|
>
|
|
<Tag className="size-4" />
|
|
Tags
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="appearance"
|
|
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
|
>
|
|
<Palette className="size-4" />
|
|
Appearance
|
|
</TabsTrigger>
|
|
{/* Members tab is feature-flagged. We render the trigger
|
|
only when the flag is enabled, so users without the
|
|
flag see the original 5-tab layout. */}
|
|
{accountSharingEnabled && (
|
|
<TabsTrigger
|
|
value="members"
|
|
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
|
>
|
|
<UsersRound className="size-4" />
|
|
Members
|
|
</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
|
|
<TabsContent value="profile" className="space-y-6">
|
|
<ProfileForm />
|
|
<PasswordForm />
|
|
<SessionsCard />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="whatsapp">
|
|
<WhatsAppConfig />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="templates">
|
|
<TemplateManager />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="tags">
|
|
<TagManager />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="appearance">
|
|
<AppearancePanel />
|
|
</TabsContent>
|
|
|
|
{accountSharingEnabled && (
|
|
<TabsContent value="members">
|
|
<MembersTab />
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|