Merge pull request #204 from ArnasDon/ga/members-account-sharing

feat(members): GA the Members tab (remove account_sharing beta gate)
This commit is contained in:
Arnas Donauskas 2026-06-04 17:42:27 +03:00 committed by GitHub
commit d6dbbf22b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 66 additions and 75 deletions

View File

@ -11,14 +11,17 @@ and polish.
## [Unreleased]
Foundation for multi-user accounts. Every wacrm install becomes
multi-tenant on the database side: a single user's signup creates a
fresh "account", and every row is scoped to that account rather than
to the user directly. The user-visible invite / members surface lands
in follow-up PRs gated by the `'account_sharing'` beta feature flag —
this release is wiring with no behaviour change on its own. Existing
Multi-user accounts ship. Every wacrm install is multi-tenant on the
database side: a single user's signup creates a fresh "account", and
every row is scoped to that account rather than to the user directly.
This release also opens the user-visible **Members** surface — invite
teammates by link, manage their roles, transfer ownership — to all
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
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
@ -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
yet have permission for.
- **Sidebar surfaces the active account** above the user info
when the `account_sharing` beta flag is on. Solo users keep
the original layout (their account is named after them, so
duplicating it would just add visual noise).
whenever the account name differs from your own — i.e. once
you've renamed the account or joined a shared one. A default
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
@ -91,8 +100,17 @@ as the sole owner of their own account and sees identical data.
### 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
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.
- `GET /api/account` — caller's account + role. Any member.
- `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
swap with the named member.
- **Invitation API + redeem flow** — the no-email, link-only
invite path. Backend is complete; the Members tab UI that
drives it lands in a follow-up.
invite path that powers the Members tab's "Invite member" button
and the `/join/[token]` accept page.
- `GET /api/account/invitations` — list outstanding (admin+).
- `POST /api/account/invitations` — create an invite, returns
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.
- **Real-time dashboard** — response times, daily volume, pipeline
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.
## Why fork this?

View File

@ -18,54 +18,31 @@ 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 = [
const TAB_VALUES = [
'profile',
'whatsapp',
'templates',
'tags',
'appearance',
'members',
] 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 tab: TabValue = isTabValue(queryTab) ? queryTab : 'profile';
const onChange = (next: TabValue) => {
const params = new URLSearchParams(searchParams.toString());
@ -120,18 +97,13 @@ export default function SettingsPage() {
<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>
)}
<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">
@ -156,11 +128,9 @@ export default function SettingsPage() {
<AppearancePanel />
</TabsContent>
{accountSharingEnabled && (
<TabsContent value="members">
<MembersTab />
</TabsContent>
)}
<TabsContent value="members">
<MembersTab />
</TabsContent>
</Tabs>
</div>
);

View File

@ -106,23 +106,22 @@ interface SidebarProps {
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) {
const pathname = usePathname();
const { profile, profileLoading, account, accountRole, signOut } = useAuth();
const totalUnread = useTotalUnread();
// Match the settings page's check: only treat the flag as enabled
// once the profile has finished loading. Without this, the strip
// would briefly flash absent during the initial profile fetch
// (when `profile` is null and the boolean coerces to false), then
// pop in once the row resolves — visible as a layout jump in the
// sidebar footer.
const accountSharingEnabled =
// Only surface the account-name strip when it actually carries
// information. A solo user's personal account is named after them
// (the 017 signup trigger seeds it from `full_name`), so showing it
// here would just duplicate the user name in the footer below. Once
// the account is renamed or the user joins a shared account, the
// name diverges and the strip becomes meaningful — that's the signal
// 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 &&
!!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,
// 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 */}
<div className="shrink-0 border-t border-slate-800 p-3">
{/* Account name display — only surfaced when the user is
opted into the account_sharing beta flag. For solo
users (the default) the account is named after them,
so showing it here would just duplicate the user name
below. Once the flag is on the user is at minimum
aware of which shared account they're acting in. */}
{accountSharingEnabled && account?.name ? (
{/* Account name display — surfaced only when the account
name differs from the user's own name (see
`showAccountStrip`). For a default solo account the two
match, so we hide it to avoid duplicating the user name
below; for renamed or shared accounts it tells the user
which account they're acting in. */}
{showAccountStrip && account?.name ? (
<div className="mb-2 flex items-center gap-2 px-3 text-xs text-slate-500">
<UsersRound className="size-3.5 shrink-0" />
{/* `title=` exposes the full name on hover when it