mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
80b4639c60
commit
5332f77831
44
CHANGELOG.md
44
CHANGELOG.md
@ -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
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user