Skip to content

Commit ab7ba22

Browse files
tofikwestclaude
andcommitted
fix(auth): honor explicit ?inviteCode= before the offboard guard on the root page
cubic review (CS-569, P2): the centralization refactor made the root page short-circuit offboarded users to /auth/access-removed before the /setup passthrough, so an explicit ?inviteCode= on / was swallowed for offboarded users — a regression from the prior behavior where 0-org users fell through to /setup (which hands the code to /invite downstream). Restore invite-first precedence: when ?inviteCode= is present, redirect to /setup (preserving the code) so the downstream /invite handling runs, before applying the offboard guard. Mirrors the /setup route precedence. Adds a regression test: offboarded + ?inviteCode= -> /setup (not access-removed). Refs: CS-569 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M6zcF9bvPP1UwZgzK21cw1
1 parent 43d9195 commit ab7ba22

2 files changed

Lines changed: 22 additions & 3 deletions

File tree

‎apps/app/src/app/page.test.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ describe('RootPage onboarding-loop guard (CS-569)', () => {
7979
expect(mockRedirect).not.toHaveBeenCalledWith('/auth/access-removed');
8080
});
8181

82+
it('lets an explicit ?inviteCode= win over the offboard guard (hands off to /setup for downstream invite handling)', async () => {
83+
// Regression (cubic P2): an offboarded user landing on /?inviteCode=... must
84+
// still be able to accept the invite, not be short-circuited to access-removed.
85+
mockMe({ organizations: [], pendingInvitation: null, hasInactiveMembership: true });
86+
87+
await expect(
88+
Page({ searchParams: Promise.resolve({ inviteCode: 'inv_abc' }) }),
89+
).rejects.toThrow('REDIRECT:/setup?inviteCode=inv_abc');
90+
91+
expect(mockRedirect).not.toHaveBeenCalledWith('/auth/access-removed');
92+
});
93+
8294
it('prefers a pending invitation over the access-removed page', async () => {
8395
mockMe({
8496
organizations: [],

‎apps/app/src/app/page.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,16 @@ export default async function RootPage({
5959
const memberships = meRes.data?.organizations ?? [];
6060

6161
if (memberships.length === 0) {
62-
// CS-569: route a user with no active org through the shared decision so
63-
// this page and the /setup route can't diverge (invite > offboarded > new).
64-
// `null` = genuinely new user → onboarding.
62+
// An explicit ?inviteCode= (the user is accepting a specific invitation)
63+
// always wins over the offboard guard: hand off to /setup, which preserves
64+
// the code and passes it through to /invite downstream. Mirrors the /setup
65+
// route precedence so an offboarded user can still accept a valid invite.
66+
const inviteCode = (await searchParams)?.inviteCode;
67+
if (inviteCode) {
68+
return redirect(await buildUrlWithParams('/setup'));
69+
}
70+
// Otherwise route via the shared decision (pending invite > offboarded >
71+
// new user). `null` = genuinely new user → onboarding.
6572
const target = resolveNoActiveOrgRedirect(meRes.data);
6673
return redirect(await buildUrlWithParams(target ?? '/setup'));
6774
}

0 commit comments

Comments
 (0)