wacrm/src/app/(dashboard)/settings/page.tsx
Arnas Donauskas 7554ce7cd8
feat(settings): members tab + invite member dialog
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>
2026-05-31 18:21:30 +03:00

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>
);
}