feat(members): GA the Members tab — remove account_sharing beta gate

The multi-user account-sharing surface (Members tab + invite/role
management APIs + /join redeem flow) is complete, so drop the
`account_sharing` beta flag that hid it during development. Mirrors
the Flows soft-GA in 0.2.0.

- settings: Members tab is now always rendered (no beta_features
  check, no stale-link fallback). The `profiles.beta_features`
  column itself stays for future betas.
- sidebar: the active-account strip no longer keys off the flag.
  It now shows whenever the account name differs from the user's
  own name — i.e. once you've renamed the account or joined a
  shared one — preserving the original "don't duplicate a solo
  user's name" behaviour without the flag.
- CHANGELOG/README: document Members as a shipped, all-users
  feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-04 17:39:46 +03:00
parent 80b4639c60
commit 5332f77831
No known key found for this signature in database
4 changed files with 66 additions and 75 deletions

View File

@ -11,14 +11,17 @@ and polish.
## [Unreleased] ## [Unreleased]
Foundation for multi-user accounts. Every wacrm install becomes Multi-user accounts ship. Every wacrm install is multi-tenant on the
multi-tenant on the database side: a single user's signup creates a database side: a single user's signup creates a fresh "account", and
fresh "account", and every row is scoped to that account rather than every row is scoped to that account rather than to the user directly.
to the user directly. The user-visible invite / members surface lands This release also opens the user-visible **Members** surface — invite
in follow-up PRs gated by the `'account_sharing'` beta feature flag — teammates by link, manage their roles, transfer ownership — to all
this release is wiring with no behaviour change on its own. Existing users. The `'account_sharing'` beta gate that hid it during
development is removed (mirrors the Flows soft-GA in 0.2.0). Existing
self-hosted instances keep working: every existing user is backfilled self-hosted instances keep working: every existing user is backfilled
as the sole owner of their own account and sees identical data. as the sole owner of their own account and sees identical data, and a
solo owner who never invites anyone sees the same single-user app they
always did.
### Changed ### Changed
@ -71,9 +74,15 @@ as the sole owner of their own account and sees identical data.
silently broken to a teammate looking at a feature they don't silently broken to a teammate looking at a feature they don't
yet have permission for. yet have permission for.
- **Sidebar surfaces the active account** above the user info - **Sidebar surfaces the active account** above the user info
when the `account_sharing` beta flag is on. Solo users keep whenever the account name differs from your own — i.e. once
the original layout (their account is named after them, so you've renamed the account or joined a shared one. A default
duplicating it would just add visual noise). solo account is named after you, so the strip stays hidden to
avoid duplicating your name in the footer.
- **Members is open to all users.** The `account_sharing` beta
flag that hid the Settings → Members tab and the sidebar
account strip during development is gone; the multi-user
surface is now part of the standard app. (Same soft-GA move as
Flows in 0.2.0.)
### Fixed ### Fixed
@ -91,8 +100,17 @@ as the sole owner of their own account and sees identical data.
### Added ### Added
- **Members tab in Settings.** The user-facing surface for the
multi-user APIs below, available to everyone (no beta flag). From
Settings → **Members** an admin or owner can: see who's on the
account with their role and join date, invite teammates by
generating a one-time share link (pick the role + optional
expiry), revoke pending invites, change a member's role, remove a
member, and — as owner — transfer ownership. Recipients accept via
a public `/join/[token]` page. Full guide:
[Members docs](https://wacrm.tech/docs/members).
- **Account & member management API** — server-side endpoints - **Account & member management API** — server-side endpoints
for the upcoming Members tab UI. All routes are role-gated and backing the Members tab. All routes are role-gated and
return Supabase-RLS-scoped data. return Supabase-RLS-scoped data.
- `GET /api/account` — caller's account + role. Any member. - `GET /api/account` — caller's account + role. Any member.
- `PATCH /api/account` — rename the account. Admin+. - `PATCH /api/account` — rename the account. Admin+.
@ -108,8 +126,8 @@ as the sole owner of their own account and sees identical data.
- `POST /api/account/transfer-ownership` — owner only. Atomic - `POST /api/account/transfer-ownership` — owner only. Atomic
swap with the named member. swap with the named member.
- **Invitation API + redeem flow** — the no-email, link-only - **Invitation API + redeem flow** — the no-email, link-only
invite path. Backend is complete; the Members tab UI that invite path that powers the Members tab's "Invite member" button
drives it lands in a follow-up. and the `/join/[token]` accept page.
- `GET /api/account/invitations` — list outstanding (admin+). - `GET /api/account/invitations` — list outstanding (admin+).
- `POST /api/account/invitations` — create an invite, returns - `POST /api/account/invitations` — create an invite, returns
the plaintext token + share URL **exactly once** (we store the plaintext token + share URL **exactly once** (we store

View File

@ -35,6 +35,10 @@ clone or fork it to run your own CRM.
tags, webhooks. Visual builder. tags, webhooks. Visual builder.
- **Real-time dashboard** — response times, daily volume, pipeline - **Real-time dashboard** — response times, daily volume, pipeline
value, cross-module activity feed. value, cross-module activity feed.
- **Team accounts** — invite teammates by link, role-based access
(owner / admin / agent / viewer), ownership transfer. Every install
is account-scoped, so one shared inbox can be staffed by a whole
team. Solo use stays single-user with zero setup.
- **Account management** — email, password, avatar, global sign-out. - **Account management** — email, password, avatar, global sign-out.
## Why fork this? ## Why fork this?

View File

@ -18,54 +18,31 @@ import { PasswordForm } from '@/components/settings/password-form';
import { SessionsCard } from '@/components/settings/sessions-card'; import { SessionsCard } from '@/components/settings/sessions-card';
import { AppearancePanel } from '@/components/settings/appearance-panel'; import { AppearancePanel } from '@/components/settings/appearance-panel';
import { MembersTab } from '@/components/settings/members-tab'; import { MembersTab } from '@/components/settings/members-tab';
import { useAuth } from '@/hooks/use-auth';
const BASE_TAB_VALUES = [ const TAB_VALUES = [
'profile', 'profile',
'whatsapp', 'whatsapp',
'templates', 'templates',
'tags', 'tags',
'appearance', 'appearance',
'members',
] as const; ] 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]; type TabValue = (typeof TAB_VALUES)[number];
function isTabValue(v: string | null): v is TabValue { function isTabValue(v: string | null): v is TabValue {
return !!v && (TAB_VALUES as readonly string[]).includes(v); 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() { export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); 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 // The URL is the single source of truth for the active tab — no
// local state, no sync effect. A previous revision duplicated this // local state, no sync effect. A previous revision duplicated this
// into `useState` + a sync effect, which tripped React 19's // into `useState` + a sync effect, which tripped React 19's
// set-state-in-effect rule and was also redundant. // set-state-in-effect rule and was also redundant.
const queryTab = searchParams.get('tab'); const queryTab = searchParams.get('tab');
const requestedTab: TabValue = isTabValue(queryTab) ? queryTab : 'profile'; const tab: 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 onChange = (next: TabValue) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@ -120,18 +97,13 @@ export default function SettingsPage() {
<Palette className="size-4" /> <Palette className="size-4" />
Appearance Appearance
</TabsTrigger> </TabsTrigger>
{/* Members tab is feature-flagged. We render the trigger <TabsTrigger
only when the flag is enabled, so users without the value="members"
flag see the original 5-tab layout. */} className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
{accountSharingEnabled && ( >
<TabsTrigger <UsersRound className="size-4" />
value="members" Members
className="data-active:bg-slate-800 data-active:text-primary text-slate-400" </TabsTrigger>
>
<UsersRound className="size-4" />
Members
</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="profile" className="space-y-6"> <TabsContent value="profile" className="space-y-6">
@ -156,11 +128,9 @@ export default function SettingsPage() {
<AppearancePanel /> <AppearancePanel />
</TabsContent> </TabsContent>
{accountSharingEnabled && ( <TabsContent value="members">
<TabsContent value="members"> <MembersTab />
<MembersTab /> </TabsContent>
</TabsContent>
)}
</Tabs> </Tabs>
</div> </div>
); );

View File

@ -106,23 +106,22 @@ interface SidebarProps {
onClose?: () => void; onClose?: () => void;
} }
// Same flag key the Members tab and Settings page use. Flip per
// profile via Supabase Studio to dogfood the multi-user surface.
const ACCOUNT_SHARING_FLAG = "account_sharing";
export function Sidebar({ open = false, onClose }: SidebarProps) { export function Sidebar({ open = false, onClose }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const { profile, profileLoading, account, accountRole, signOut } = useAuth(); const { profile, profileLoading, account, accountRole, signOut } = useAuth();
const totalUnread = useTotalUnread(); const totalUnread = useTotalUnread();
// Match the settings page's check: only treat the flag as enabled // Only surface the account-name strip when it actually carries
// once the profile has finished loading. Without this, the strip // information. A solo user's personal account is named after them
// would briefly flash absent during the initial profile fetch // (the 017 signup trigger seeds it from `full_name`), so showing it
// (when `profile` is null and the boolean coerces to false), then // here would just duplicate the user name in the footer below. Once
// pop in once the row resolves — visible as a layout jump in the // the account is renamed or the user joins a shared account, the
// sidebar footer. // name diverges and the strip becomes meaningful — that's the signal
const accountSharingEnabled = // we gate on. Wait for the profile fetch to settle first, otherwise
// the strip flashes in once the row resolves (a layout jump).
const showAccountStrip =
!profileLoading && !profileLoading &&
!!profile?.beta_features?.includes(ACCOUNT_SHARING_FLAG); !!account?.name &&
account.name !== profile?.full_name;
// Close the drawer when route changes — users opened it to navigate, // Close the drawer when route changes — users opened it to navigate,
// so once they pick a destination the drawer should get out of the way. // so once they pick a destination the drawer should get out of the way.
@ -272,13 +271,13 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
{/* User section */} {/* User section */}
<div className="shrink-0 border-t border-slate-800 p-3"> <div className="shrink-0 border-t border-slate-800 p-3">
{/* Account name display — only surfaced when the user is {/* Account name display — surfaced only when the account
opted into the account_sharing beta flag. For solo name differs from the user's own name (see
users (the default) the account is named after them, `showAccountStrip`). For a default solo account the two
so showing it here would just duplicate the user name match, so we hide it to avoid duplicating the user name
below. Once the flag is on the user is at minimum below; for renamed or shared accounts it tells the user
aware of which shared account they're acting in. */} which account they're acting in. */}
{accountSharingEnabled && account?.name ? ( {showAccountStrip && account?.name ? (
<div className="mb-2 flex items-center gap-2 px-3 text-xs text-slate-500"> <div className="mb-2 flex items-center gap-2 px-3 text-xs text-slate-500">
<UsersRound className="size-3.5 shrink-0" /> <UsersRound className="size-3.5 shrink-0" />
{/* `title=` exposes the full name on hover when it {/* `title=` exposes the full name on hover when it