This page documents Multi-Account Login for the Partners Portal (apps/partners, served at partners.matters.ai): a Gmail-style experience where one browser can hold up to three distinct Clerk accounts at once, list them, switch between them, add a new one, and sign out of one (or all) — with absolute per-account data isolation.
The feature is flag-gated and ships OFF by default. With the flag unset, the portal is behaviorally identical to the single-account portal it has always been: no switcher, plain email in the navbar, original sign-in routing, Sentry untouched. Nothing changes or breaks unless you deliberately turn it on.
This page covers the why, the two switches that turn it on, the account-switcher UI, the cross-tab coherence watcher, the database scoping that prevents one org's data from bleeding into another, the cross-account isolation hardening, the test suite, and — critically — one known caveat you must resolve before flipping the flag on in production (the account-type RBAC resolver).
Related pages:
partner_sessions, the middleware chain, and the gate tiers this feature relies on.partner_sessions table and its composite unique key.| Question | Answer |
|---|---|
| What is an "account"? | A distinct Clerk user / login (Gmail-style), not an org switch. Each slot is a separate Clerk session for a different Clerk user. |
| How many at once? | 3 (MAX_CONCURRENT_ACCOUNTS). A product constraint, not a security boundary. |
| What turns it on? | Two switches: NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED=true and Clerk Dashboard → Multi-session handling = ON. |
| Default state | OFF. Portal behaves exactly as single-account; flipping the flag back is an instant, code-free kill switch. |
| How is isolation guaranteed? | Every account transition is a hard full-document navigation (window.location.assign). The server re-derives identity from the active Clerk session on every request. |
| Blocker before prod | The account-type RBAC resolver looks up by clerkUserId alone — it must be made org-scoped first. Tracked in PENDING_TASKS.md §5. |
MULTI_ACCOUNT_ENABLED#The entire feature — UI, Sentry changes, sign-in routing, cross-tab watcher, the org-scoped session reads — is gated by a single master flag defined in lib/auth/multi-account.ts:
export const MULTI_ACCOUNT_ENABLED =
process.env.NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED === "true";
The flag is NEXT_PUBLIC_* on purpose: client and server must read the same value (it gates UI and request behavior, not a secret), and a server/client mismatch on a session-isolation feature would be a correctness hazard. It is read identically in instrumentation-client.ts (where there is no @/lib import), so the literal process.env.NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED === "true" is duplicated there.
Default OFF = behavior unchanged. When the flag is unset or not "true":
{session.email}, not the switcher;<MultisessionAppSupport> is a transparent passthrough (no session-keyed remount);<ActiveSessionWatcher> cross-tab watcher is not mounted;PartnerSignIn routes to /dashboard as before (the /post-auth router is harmless but unused);clerkUserId alone (the production DB still has the single-column unique).So the flag is a complete, instant kill switch with no code rollback. Flip it off and the feature evaporates.
There is exactly one always-on change that the flag does not gate: keying the partner-status cache by (clerkUserId, clerkOrgId) when the flag is on. That is a pure correctness fix — single-session it returns identical results — and is described under Composite scoping below.
Set in apps/partners/.env (local) and the prod Vercel env, then restart dev / redeploy:
NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED=true
In Clerk Dashboard → Sessions → Multi-session handling → ON (per Clerk instance — dev and prod are separate apps, so toggle each). This is what lets a single browser client (__client cookie) hold more than one session token at a time.
This toggle lives in Clerk's SaaS console under your admin login — there is no API the codebase calls to flip it; it is a human action recorded in PENDING_TASKS.md §1b.
Degrades gracefully. If the app flag is on but Clerk multi-session is off, the browser holds only one session: the switcher renders a single account and "Add account" simply re-runs the sign-in flow rather than adding a second slot. Nothing errors.
From lib/auth/multi-account.ts:
/** Max accounts signed in concurrently in one browser. Product constraint, NOT a security boundary. */
export const MAX_CONCURRENT_ACCOUNTS = 3;
/** Whether another account may be added given how many are already signed in. */
export function canAddAccount(currentCount: number): boolean {
return currentCount < MAX_CONCURRENT_ACCOUNTS;
}
/** Post-activation landing for an account. Internal @matters.ai users land in the admin zone. */
export function landingFor(opts: { isInternal: boolean }): string {
return opts.isInternal ? "/admin" : "/dashboard";
}
Key facts about the model:
clerkUserIds. This is load-bearing for isolation: distinct users map to physically distinct partner_sessions rows.partner login and an internal @matters.ai admin login side by side. Isolation must hold regardless of the mix.(user, org) pair.isInternalEmail() delegates to isMattersEmail() (the single source of truth in lib/auth/account-type.ts); accountLabel() derives a display label only from Clerk client session data (email, firstName, lastName) — never a server fetch (see the inactive-label principle).
AccountSwitcher (components/auth/AccountSwitcher.tsx) is a client component mounted in the navbar (components/aceternity/navbar.tsx) only when the flag is on:
{MULTI_ACCOUNT_ENABLED ? (
<AccountSwitcher
active={{
email: session.email,
kindLabel:
session.partnerType === "internal" ? "Admin" : (session.partnerTier ?? "Partner"),
}}
/>
) : (
<span style={{ fontSize: "0.75rem", color: T.inkMuted }}>{session.email}</span>
)}
It reads Clerk's client sessions via useSessionList() and useClerk(). The admin shell (app/(admin)/layout.tsx) reuses the same Navbar, so the switcher is available identically in the admin zone.
| Element | Source of its label |
|---|---|
| Active chip | email + a kindLabel badge ("Admin" for internal, else the partner tier or "Partner") — the active account reading its own server-derived data. Safe. |
| Dropdown — other accounts | Each row labeled exclusively from client.sessions[i].user (Clerk client session objects). No server read. |
| Add account | Enabled while activeSessions.length < 3; at cap shows a disabled "Max 3 accounts" row. |
| Sign out | Signs out the active session; falls back to a remaining account if one exists, else /sign-in. |
| Sign out of all | Shown only when more than one account is signed in. |
All four flows raise a full-viewport SwitchOverlay (components/auth/SwitchOverlay.tsx) — position: fixed; inset: 0; zIndex: 9999 — the moment a transition begins. It captures all pointer events so no underlying control (e.g. the notification bell's "mark all read", which clears all unread for the active org) can fire in the window between setActive() and the hard navigation.
// Switch account
await setActive({
session: target.id,
navigate: () => {
void recordEvent("account_switched");
window.location.assign(landingFor({ isInternal: isInternalEmail(emailOf(target)) }));
},
});
// Sign out of this account — fall back to a remaining account, else sign-in
await signOut(activeId ? { sessionId: activeId } : undefined);
const remaining = activeSessions.filter((s) => s.id !== activeId);
if (remaining.length > 0) {
await setActive({ session: remaining[0].id, navigate: () => window.location.assign(landingFor(/* … */)) });
} else {
window.location.assign("/sign-in");
}
The hard-navigation rule is the heart of isolation: setActive(...) is always followed by window.location.assign(...), never a soft router.push. The full-document load discards the entire Next.js Router Cache (including any prefetched RSC payloads), and the server re-derives identity from the now-active session cookie on the next request. Nothing account-scoped survives the transition.
Add account (window.location.assign("/sign-in")) routes the completed sign-in through /post-auth (app/post-auth/page.tsx) — a neutral server-component router that reads the now-active session and redirect()s to landingFor(...), so an added internal account lands on /admin and never transits the partner /dashboard gate.
A hard rule that makes a cross-account leak from the switcher structurally impossible: the dropdown must show other accounts' labels without the server ever reading their data under the active session — that read would itself be the leak. So inactive rows are labeled only from Clerk's client-held session objects; there is no /api/... or server-action call to enrich them. Rich, DB-derived labels (tier, company) appear only once an account becomes active and reads its own data.
ActiveSessionWatcher#All tabs share one active Clerk session (one __client cookie). A background tab rendered under account A still displays A after a switch to B happens in another tab. ActiveSessionWatcher (components/auth/ActiveSessionWatcher.tsx) closes that display gap:
const CHANNEL = "pm-active-session";
export function ActiveSessionWatcher({ renderedSessionId }: { renderedSessionId: string | null }) {
const { session } = useClerk();
useEffect(() => {
if (typeof window === "undefined" || !("BroadcastChannel" in window)) return;
const ch = new BroadcastChannel(CHANNEL);
ch.postMessage({ activeId: session?.id ?? null });
const onMessage = (e: MessageEvent) => {
const activeId = (e.data as { activeId?: string | null })?.activeId ?? null;
if (activeId && renderedSessionId && activeId !== renderedSessionId) {
window.location.reload();
}
};
ch.addEventListener("message", onMessage);
return () => { ch.removeEventListener("message", onMessage); ch.close(); };
}, [session?.id, renderedSessionId]);
return null;
}
Each layout passes the session id it rendered with (renderedSessionId); the watcher broadcasts the live active id over BroadcastChannel("pm-active-session"), and any tab whose rendered id no longer matches the active id does a window.location.reload(). It is only mounted when the flag is on, and only in the portal/admin layouts:
// app/(admin)/layout.tsx
const { sessionId } = MULTI_ACCOUNT_ENABLED ? await auth() : { sessionId: null };
// …
{MULTI_ACCOUNT_ENABLED && <ActiveSessionWatcher renderedSessionId={sessionId ?? null} />}
The stale-display window is sub-second; any action in a background tab still runs under the genuinely active session, and the overlay plus reload close the display gap.
MultisessionAppSupport#MultisessionAppSupport (app/components/MultisessionAppSupport.tsx) wraps the app in the root layout and re-keys the client subtree by the active session id, forcing React to discard and recreate client state on a switch:
export function MultisessionAppSupport({ children }: { children: ReactNode }) {
const { session } = useSession();
if (!MULTI_ACCOUNT_ENABLED) return <>{children}</>;
return <Fragment key={session ? session.id : "no-session"}>{children}</Fragment>;
}
This is explicitly defense-in-depth, not the primary guarantee. The session-key remount protects React state only — it does not clear module scope. The load-bearing guarantee remains the hard navigation. When the flag is off, the component is a transparent passthrough.
The root layout (app/layout.tsx) also sets <ClerkProvider afterMultiSessionSingleSignOutUrl="/sign-in/choose"> so signing out of all accounts lands on Clerk's account chooser.
(clerkUserId, clerkOrgId) scoping#The partner_sessions table caches each login's partner standing (tier, type, Salesforce account/contact ids, status) resolved from Salesforce. Its unique key is composite (lib/db/schema.ts):
// A Clerk user maps to exactly one row PER ORG. Composite unique prevents one
// org's tier/SF-ids from bleeding into another org's render.
uniqueIndex("partner_sessions_user_org_unique").on(t.clerkUserId, t.clerkOrgId),
The reads that hydrate a session are scoped accordingly. In lib/auth/gate.ts (requirePartnerSession) the provisioning re-read is composite when the flag is on:
rows = await db()
.select()
.from(partnerSessions)
.where(
MULTI_ACCOUNT_ENABLED
? and(eq(partnerSessions.clerkUserId, userId), eq(partnerSessions.clerkOrgId, orgId))
: eq(partnerSessions.clerkUserId, userId)
)
.limit(1);
And the partner-status cache read/refetch in lib/sf/partner-status.ts keys the same way:
const where = MULTI_ACCOUNT_ENABLED
? and(eq(partnerSessions.clerkUserId, clerkUserId), eq(partnerSessions.clerkOrgId, clerkOrgId))
: eq(partnerSessions.clerkUserId, clerkUserId);
Why it matters. Before this change the cache keyed by clerkUserId alone (single-column unique), dropping orgId on the refetch/provision path. Harmless in single-session (one row per user), but in a future multi-org world it could read or write an arbitrary org's row — bleeding one org's tier and Salesforce ids into another org's render. The composite key + composite reads make every read/write (user, org)-precise.
The DDL change (replacing the single-column unique with partner_sessions_user_org_unique) is applied to the manually-managed Partners Neon DB via pnpm --filter @matters/partners db:push. partner_sessions has ~0 rows, so there is no data conflict. See PENDING_TASKS.md §3 for the exact migration (and the SQL-console fallback).
The portal is server-component-first: there is no account-scoped client state that survives a navigation (no Zustand/Redux/SWR/React-Query — only theme and sidebar-group UI prefs in localStorage). The hard navigation flushes the Router Cache. On top of that, the flag toggles two privacy hardenings:
Sentry Session Replay persists a buffer across navigations, so in a multi-account browser one buffer could span two accounts' screens and upload both — a genuine cross-account leak vector. instrumentation-client.ts (instrumentation-client.ts) therefore strips Replay and PII when the flag is on, and leaves them untouched otherwise:
const MULTI_ACCOUNT = process.env.NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED === "true";
Sentry.init({
integrations: MULTI_ACCOUNT ? [] : [Sentry.replayIntegration()],
replaysSessionSampleRate: MULTI_ACCOUNT ? 0 : 0.1,
replaysOnErrorSampleRate: MULTI_ACCOUNT ? 0 : 1.0,
sendDefaultPii: !MULTI_ACCOUNT, // request data could span accounts
});
Trade-off: enabling multi-account loses session-replay debugging for the partners app. Accepted for the isolation bar.
Account transitions are audited via POST /api/auth/account-event (app/api/auth/account-event/route.ts), rate-limited by authLimiter, accepting account_switched | account_added | signed_out | signed_out_all. Critically, these rows are written with clerkOrgId = null — the schema's system/global partition — so they never surface in any partner org's activity view, and one account's identity is never stored in another org's audit log:
await recordActivity({
clerkOrgId: null,
action: `session.${parsed.data.action}`,
summary: `Account ${parsed.data.action.replaceAll("_", " ")}`,
ipAddress: ip === "unknown" ? null : ip,
});
The switcher fires these fire-and-forget (keepalive: true) so they never block a transition.
This is the one blocker that must be resolved before MULTI_ACCOUNT_ENABLED=true in production.
The composite key (clerk_user_id, clerk_org_id) lets one Clerk user hold multiple partner_sessions rows — one per org. But the account-type resolver (lib/auth/account-type-store.ts → resolveAccountType / setAccountType, documented at /partners/rbac) currently looks a user up by clerk_user_id alone (.limit(1)). For a multi-org user it would read or write an arbitrary org's row.
partner_sessions row per user, so the resolver is correct. This is not a current bug.The fix is small (≈30 min) but requires a product decision on what account_type means across orgs:
| Model | Behavior |
|---|---|
| Per-(user, org) for external, global for internal (recommended) | A person can be a partner in org A and a prospect in org B (external types org-scoped); internal tiers (internal_team_member / admin / super_admin) describe the @matters.ai person and stay global, floored by the SUPER_ADMIN_EMAILS / Matters-org allowlist. |
| Fully global | One account type per user, ignoring org. Simpler, but cannot express "partner here, prospect there." |
Once the model is chosen, the resolver/writer lookups get scoped by (clerkUserId, clerkOrgId) (with a global short-circuit for internal/allowlist tiers under the recommended model), plus unit tests for a two-org user.
The decision and its implementation are tracked in the repository PENDING_TASKS.md §5. Until that lands, do not flip the flag on in production. See /partners/rbac for the resolver internals and the 7-type model.
e2e/multi-account.spec.ts is split into two describe blocks:
| Block | Runs when | Asserts |
|---|---|---|
| baseline intact (flag OFF) | always (no auth needed) | sign-in widget still renders; /directory stays public; /dashboard still redirects unauth; the new /post-auth route exists and bounces signed-out visitors to /sign-in. Proves the feature does not change the single-account app. |
| isolation (flag ON) | only when NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED=true and PARTNER_TEST_EMAIL + INTERNAL_TEST_EMAIL are set | full isolation: switching shows only the active account's data, an added internal account lands on /admin (never /dashboard), switching back hides admin surfaces, sign-out fallback, Back does not restore a portal page. |
The isolation block uses test.skip(...) so it skips cleanly until those env vars and Clerk multi-session are present — CI stays green without proving isolation, rather than failing.
Run it with:
pnpm --filter @matters/partners test:e2e -- multi-account
The isolation suite needs two seeded Clerk accounts with known passwords (PARTNER_TEST_EMAIL / INTERNAL_TEST_EMAIL) plus Clerk multi-session enabled — real credentials that only a human can create and hold (tracked in PENDING_TASKS.md §2). The eventual implementation uses @clerk/testing/playwright (clerkSetup + setupClerkTestingToken + clerk.signIn) to authenticate each account.
__tests__/auth/multi-account.test.ts covers the pure helpers — landingFor, accountLabel, isInternalEmail, and the canAddAccount cap predicate.
| File | Role |
|---|---|
lib/auth/multi-account.ts | MULTI_ACCOUNT_ENABLED flag, MAX_CONCURRENT_ACCOUNTS, pure helpers |
components/auth/AccountSwitcher.tsx | Chip + dropdown + switch / add / sign-out flows |
components/auth/SwitchOverlay.tsx | Full-viewport transition blocker |
components/auth/ActiveSessionWatcher.tsx | Cross-tab reload-on-switch |
app/components/MultisessionAppSupport.tsx | Session-keyed remount (defense-in-depth) |
app/post-auth/page.tsx | Neutral post-sign-in router |
app/api/auth/account-event/route.ts | Audit endpoint (writes clerkOrgId = null) |
app/layout.tsx, app/(admin)/layout.tsx | Wire the provider / watcher behind the flag |
components/aceternity/navbar.tsx | Mounts the switcher behind the flag |
lib/auth/gate.ts, lib/sf/partner-status.ts | Composite (clerkUserId, clerkOrgId) session reads |
lib/db/schema.ts | partner_sessions_user_org_unique composite unique |
instrumentation-client.ts | Sentry Replay + PII disabled in multi-account mode |
e2e/multi-account.spec.ts, __tests__/auth/multi-account.test.ts | e2e isolation gate + unit tests |