Repository navigation
feat(invites): OAuth + members UI + revoke + next= - #1273
Conversation
Adds optional `inviteToken` (1-128 chars) to oauthStateDataSchema so signed OAuth state can carry an invite token through the provider round-trip. Verified via tests for round-trip preservation, max-length rejection, and empty-string rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional `inviteToken` field (1-128 chars, matching oauthStateDataSchema) to googleSigninSchema, validated and conditionally forwarded into the HMAC-signed state via createSignedState. Tests cover round-trip into state, omission when absent, and 400 rejection for empty or oversized tokens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds inviteToken to appleSigninSchema and migrates POST + GET handlers from inline crypto.createHmac to the shared createSignedState helper. The helper auto-attaches the timestamp that verifyOAuthState requires at the callback (the prior inline build omitted timestamp, which would have caused malformed-state rejection). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional `inviteToken` to useOAuthSignIn options and forwards it through a new pure `buildOAuthSigninBody` helper into the web POST body. Native (iOS) paths are plumbed in T4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex review nits: route schemas now import INVITE_TOKEN_MAX_LENGTH from oauth-state.ts instead of hardcoding 128, and the hook drops a redundant double-guard around the optional inviteToken (buildOAuthSigninBody already guards internally). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After successful Google OAuth on web, if state carries inviteToken: branches new vs existing user (using the natural `!user` from findUserByGoogleIdOrEmail) and routes through acceptInviteForNewUser or acceptInviteForExistingUser. On success, returnUrl is overridden to /dashboard/<driveId>?invited=1; on failure, the error code is appended (auth itself still succeeds). Pipe throws are caught and logged - never bounce auth. Desktop/iOS branches are intentionally skipped here and handled in T4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the Google callback wiring: branches new vs existing user via findUserByAppleIdOrEmail and routes through the appropriate acceptance pipe. Includes an explicit Apple-private-relay regression guard test (`@privaterelay.appleid.com` mismatch surfaces EMAIL_MISMATCH instead of silently joining). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a shared `consumeInviteIfPresent` helper (apps/web/src/lib/auth/native-invite-acceptance.ts) and wires it into the 3 native auth routes (google/native, apple/native, google/one-tap). Each route accepts inviteToken in its body schema, returns invitedDriveId + inviteError fields. The one-tap route also overrides its existing redirectTo when an invite was consumed. Helper has its own unit tests covering branching; route tests confirm wiring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ios-google-auth and ios-apple-auth now accept an optional inviteToken option, forward it to their respective /native routes, and surface invitedDriveId + inviteError on the result. useOAuthSignIn passes its inviteToken through to those calls and consults a new pure buildPostNativeAuthRedirect helper to land users at /dashboard/<driveId>?invited=1 when an invite was consumed (taking precedence over the generic /dashboard?welcome=true new-user landing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SignInForm reads ?invite= from query params; SignUpClient already received inviteToken as a prop. Both now forward it to useOAuthSignIn so the OAuth Google + Apple paths consume the invite end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/drives/[driveId]/members now returns a pendingInvites array. Populated for OWNER/ADMIN viewers (via findUnconsumedInvitesByDrive); empty array for regular MEMBER (no information leak, but stable SWR cache shape across role changes). Repo is not queried at all when the viewer is not OWNER/ADMIN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New OWNER/ADMIN-only UI surface for pending drive invites. Row shows invitee email, role badge, and a Pending or Expired badge based on expiresAt vs now. Section returns null for non-OWNER/ADMIN viewers and for empty arrays. Revoke button is intentionally not yet present — added in T9 once the DELETE endpoint and adapter are in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DriveMembers now renders accepted-only members in the main list and feeds the new PendingInvitesSection from the API's pendingInvites field. The legacy pendingMembers filter (drive_members.acceptedAt IS NULL) is gone — post-cutover, drive_members rows are always accepted, so that branch was dead. MemberRow drops the isPending logic entirely. Tests covering the old behavior are removed; new ones cover the API-driven section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-adds revoke-adapters.ts with buildRevokePorts wired to driveInviteRepository, plus DELETE /api/drives/[driveId]/pending-invites/[inviteId]. Maps validator codes to HTTP: NOT_FOUND -> 404 (covers cross-drive enumeration), FORBIDDEN -> 403, ok -> 200 with inviteId+driveId. Adds the auth/revoke-adapters.ts entry to the drive-member gate-coverage allow-list (validator enforces the accepted-OWNER/ADMIN gate). PendingInviteRow gains an AlertDialog-confirmed revoke button; DriveMembers wires it through optimistic local state + toast. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SignInForm reads ?next= from query, validates via isSafeNextPath against the documented allowlist (/dashboard, /invite/, /account), and threads the safe value to PasskeyLoginButton via a new nextPath prop. PasskeyLoginButton uses nextPath to override the server's default redirectUrl on success. Magic-link signin honoring next is a separate follow-up since it requires the email link itself to carry the next param through the send + verify backend. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds explicit auditRequest('authz.access.denied') to the FORBIDDEN branch of the revoke route so a malicious enumeration attempt leaves an audit trail (the success-side audit is already emitted by the adapter's auditPermissionRevoked port). Removes the unused userEvent import in DriveMembers.test.tsx left over from the T8 cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Warning Rate limit exceeded
To continue reviewing without waiting, purchase usage credits in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (12)
📝 WalkthroughWalkthroughThis PR introduces invite-token acceptance during OAuth/native authentication flows and adds invite revocation capabilities in the drive members UI. It threads an optional ChangesInvite Token Threading and OAuth State
OAuth Callback Invite Acceptance
Pending Invite Revocation
Members UI Refactor
Sign-in Page Enhancements
Sequence Diagram(s)sequenceDiagram
participant User
participant SignInUI as Sign-in UI
participant OAuthProvider as OAuth Provider
participant CallbackRoute as Callback Route
participant InviteService as Invite Service
participant DriveSvc as Drive Service
participant Client
User->>SignInUI: Click OAuth Sign-In with inviteToken
SignInUI->>SignInUI: inviteToken → OAuth state
SignInUI->>OAuthProvider: POST signin with state
OAuthProvider-->>SignInUI: Redirect to callback
SignInUI->>CallbackRoute: GET callback?code=...&state=...
CallbackRoute->>CallbackRoute: Verify OAuth state, extract inviteToken
CallbackRoute->>CallbackRoute: Exchange code for user profile
CallbackRoute->>DriveSvc: Create or fetch user session
CallbackRoute->>CallbackRoute: Determine wasNewUser
alt inviteToken present
CallbackRoute->>InviteService: acceptInviteFor[NewUser|ExistingUser]
InviteService-->>CallbackRoute: { ok: true, driveId } or { ok: false, error }
alt success
CallbackRoute->>CallbackRoute: returnUrl = /drives/{driveId}?invited=1
else failure
CallbackRoute->>CallbackRoute: returnUrl += ?inviteError={error}
end
end
CallbackRoute-->>Client: 302 redirect to returnUrl
sequenceDiagram
participant User
participant DriveUI as Drive Members UI
participant RevokeAPI as Revoke API
participant AuthSvc as Auth Service
participant InviteSvc as Invite Service
participant DriveSvc as Drive Service
participant Audit as Audit Log
User->>DriveUI: View pending invitations
DriveUI->>RevokeAPI: GET /api/drives/{driveId}/members
RevokeAPI->>InviteSvc: findUnconsumedInvitesByDrive
InviteSvc-->>RevokeAPI: [{ id, email, role, ... }]
RevokeAPI-->>DriveUI: { members, pendingInvites }
DriveUI->>DriveUI: Render PendingInvitesSection
User->>DriveUI: Click revoke on pending invite
DriveUI->>DriveUI: Show confirmation dialog
User->>DriveUI: Confirm revoke
DriveUI->>RevokeAPI: DELETE /api/drives/{driveId}/pending-invites/{inviteId}
RevokeAPI->>AuthSvc: Authenticate + CSRF check
RevokeAPI->>InviteSvc: revokePendingInvite(ports)
InviteSvc->>DriveSvc: deletePendingInviteForDrive
DriveSvc-->>InviteSvc: success
InviteSvc->>Audit: auditPermissionRevoked event
Audit-->>InviteSvc: logged
InviteSvc-->>RevokeAPI: { ok: true }
RevokeAPI-->>DriveUI: 200 { inviteId, driveId }
DriveUI->>DriveUI: Remove invite from pendingInvites state
DriveUI->>DriveUI: Show success toast
Estimated code review effort🎯 4 (Complex) | ⏱️ ~55 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/src/app/auth/signin/page.tsx (1)
166-169:⚠️ Potential issue | 🟠 Major | ⚡ Quick winPropagate validated
nextPathto web passkey flow too
nextPathis computed in Line 31-34 but not passed to the non-on-premPasskeyLoginButtonat Line 166-169, so passkey sign-in in the default web path can ignore?next=while on-prem honors it.Suggested fix
<PasskeyLoginButton csrfToken={csrfToken} refreshToken={refreshToken} + {...(nextPath && { nextPath })} />🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/auth/signin/page.tsx` around lines 166 - 169, The PasskeyLoginButton call is not receiving the validated nextPath, so update the JSX where PasskeyLoginButton is rendered to pass the computed nextPath prop (e.g., <PasskeyLoginButton csrfToken={csrfToken} refreshToken={refreshToken} nextPath={nextPath} />) so the web/non-on-prem passkey flow receives the same validated nextPath used elsewhere; ensure you use the existing nextPath variable (the one computed earlier) when adding the prop.apps/web/src/app/api/auth/apple/callback/route.ts (1)
238-363:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift
inviteTokenin OAuth state is silently dropped forplatform === 'desktop'.The invite acceptance block (lines 339–363) runs only on the web platform path. Both the
desktopbranch (line 238, returns at line 280) and theiosbranch (line 284, returns at line 320) exit before this code is reached.For desktop: when a desktop user authenticates via Apple OAuth,
inviteTokenis correctly signed into the state (inapple/signin/route.ts), HMAC-verified here, but then silently discarded. Neither thecreateExchangeCodepayload nor thepagespace://auth-exchangedeep-link URL carries invite information, so the desktop app has no way to consume it. The user completes auth but never joins the drive.For iOS: likely mitigated in practice because native iOS apps use
/api/auth/apple/native/route.ts(which callsconsumeInviteIfPresent), but any iOS client using the web callback flow has the same gap.To fix the desktop case, invite consumption should run before the exchange code is created:
🐛 Sketch of fix for the desktop branch
if (platform === 'desktop') { // ...device token creation... + const inviteToken = verifiedState.inviteToken; + if (inviteToken) { + try { + const ports = buildAcceptancePorts(req); + const acceptInput = { token: inviteToken, userId: user.id, userEmail: email.toLowerCase(), suspendedAt: wasNewUser ? null : (user.suspendedAt ?? null), now: new Date() }; + const result = wasNewUser + ? await acceptInviteForNewUser(ports)(acceptInput) + : await acceptInviteForExistingUser(ports)(acceptInput); + if (result.ok) { + deepLinkUrl.searchParams.set('invitedDriveId', result.data.driveId); + } + } catch (err) { + loggers.auth.error('Invite acceptance pipe threw (desktop)', err as Error); + } + } return buildHandoffBridgeResponse(deepLinkUrl.toString(), "You're signed in"); }If desktop Apple OAuth with invite links is not yet a supported scenario, this should at minimum be tracked, and the
inviteTokenin the signed state should be documented as a no-op for non-web platforms.
🧹 Nitpick comments (3)
apps/web/src/lib/auth/__tests__/native-invite-acceptance.test.ts (1)
75-94: 💤 Low valueLGTM — consider adding a test for the
suspendedAt: undefinededge caseThe existing suite covers the happy path for existing users with a concrete
suspendedAtvalue. If theuser.suspendedAttype is tightened to required (per the suggestion innative-invite-acceptance.ts), this becomes moot; otherwise a test asserting thatundefinedmaps tonullin the pipe call would document and guard the fallback behaviour.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/lib/auth/__tests__/native-invite-acceptance.test.ts` around lines 75 - 94, Add a new test for consumeInviteIfPresent that passes user: { id: 'user-1', suspendedAt: undefined } and isNewUser: false, mock acceptForExistingPipe to resolve as in the existing test, then assert acceptForExistingPipe was called with an objectContaining({ suspendedAt: null }) and that the returned result.invitedDriveId matches the mock; this documents and verifies the fallback mapping of undefined -> null when calling acceptForExistingPipe from consumeInviteIfPresent.apps/web/src/components/members/__tests__/PendingInviteRow.test.tsx (1)
36-44: ⚡ Quick winAdd an OWNER badge test case.
The role union includes
OWNER; adding this assertion closes the remaining role-render branch.Proposed test addition
it('renders role-specific badge: Admin', () => { render(<PendingInviteRow invite={buildInvite({ role: 'ADMIN' })} />); expect(screen.getByText('Admin')).toBeInTheDocument(); }); + + it('renders role-specific badge: Owner', () => { + render(<PendingInviteRow invite={buildInvite({ role: 'OWNER' })} />); + expect(screen.getByText('Owner')).toBeInTheDocument(); + });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/members/__tests__/PendingInviteRow.test.tsx` around lines 36 - 44, Add a test case in PendingInviteRow.test.tsx to cover the OWNER role branch: render <PendingInviteRow invite={buildInvite({ role: 'OWNER' })} /> and assert that screen.getByText('Owner') is in the document; place it alongside the existing 'Member' and 'Admin' tests to ensure PendingInviteRow and buildInvite handle the OWNER badge.apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts (1)
134-140: ⚡ Quick winPrefer
vi.hoisted()for pipe stub variables used invi.mock()factories.
acceptInviteForNewUserPipeandacceptInviteForExistingUserPipeare module-levelconstdeclarations referenced inside avi.mock()factory. While this works here because the variables are only read inside nested closures (not at factory-evaluation time), plainconstdeclarations are technically in the TDZ whenvi.mock()is hoisted. The Vitest-idiomatic pattern isvi.hoisted(), which explicitly initialises the value before the mock registry runs.♻️ Proposed refactor
-const acceptInviteForNewUserPipe = vi.fn(); -const acceptInviteForExistingUserPipe = vi.fn(); +const acceptInviteForNewUserPipe = vi.hoisted(() => vi.fn()); +const acceptInviteForExistingUserPipe = vi.hoisted(() => vi.fn()); vi.mock('@pagespace/lib/services/invites', () => ({ acceptInviteForNewUser: vi.fn(() => acceptInviteForNewUserPipe), acceptInviteForExistingUser: vi.fn(() => acceptInviteForExistingUserPipe), }));Based on learnings: "In vitest, avoid using two type arguments with vi.fn<>() in tests... Use a plain vi.fn()"; the companion pattern for variables referenced in mock factories is
vi.hoisted().🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts` around lines 134 - 140, Replace the module-level const stubs acceptInviteForNewUserPipe and acceptInviteForExistingUserPipe with vitest hoisted declarations (use vi.hoisted() to create those variables before mocks run) and keep the vi.mock factory returning those stub values (i.e., change the declarations so they are initialized via vi.hoisted() rather than plain const while leaving the mock factory that references acceptInviteForNewUser and acceptInviteForExistingUser unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/app/api/auth/google/callback/__tests__/route.test.ts`:
- Around line 1295-1299: The test suite uses a single timestamped signed state
(stateWithInvite) created via createSignedState which can expire and cause flaky
tests; change the tests to generate the signed state per test (or in a
beforeEach) instead of reusing the module-level stateWithInvite—move the call to
createSignedState into each test that needs it or into a beforeEach hook so a
fresh signed state is produced for every test run (refer to createSignedState
and stateWithInvite identifiers to locate and update the code).
In `@apps/web/src/components/members/PendingInviteRow.tsx`:
- Around line 103-111: The icon-only revoke Button in PendingInviteRow.tsx (the
<Button> rendering <Trash2 /> and using props like variant, size, title,
disabled={isRevoking}) lacks an explicit aria-label for screen readers; update
the Button props to include a descriptive aria-label (e.g., aria-label="Revoke
invitation" or similar) so the destructive action is announced, keeping the
existing title and disabled logic intact.
In `@apps/web/src/components/members/PendingInvitesSection.tsx`:
- Around line 17-18: Replace the inline role check in PendingInvitesSection (the
canManage/currentUserRole === 'OWNER' || currentUserRole === 'ADMIN' logic) with
the centralized permission helpers: import getUserAccessLevel and
canUserEditPage from '@pagespace/lib/permissions/permissions' and use them to
compute permission (e.g., const access = getUserAccessLevel(currentUser) and
const canManage = canUserEditPage(access) or the equivalent helper call your
permission API expects), then keep the early return (if (!canManage ||
invites.length === 0) return null) unchanged.
In `@apps/web/src/lib/auth/native-invite-acceptance.ts`:
- Around line 19-25: The user.suspendedAt field is optional which lets callers
omit it and accidentally treat suspended users as active; make suspendedAt
required on the NativeInviteAcceptanceInput.user type (remove the optional '?'
so user: { id: string; suspendedAt: Date | null }) and update any call sites
that construct NativeInviteAcceptanceInput to pass the known suspendedAt value;
ensure the code paths that call acceptInviteForExistingUser continue to use the
explicit suspendedAt (no nullish-coalescing fallback) so suspension is never
silently bypassed.
---
Outside diff comments:
In `@apps/web/src/app/auth/signin/page.tsx`:
- Around line 166-169: The PasskeyLoginButton call is not receiving the
validated nextPath, so update the JSX where PasskeyLoginButton is rendered to
pass the computed nextPath prop (e.g., <PasskeyLoginButton csrfToken={csrfToken}
refreshToken={refreshToken} nextPath={nextPath} />) so the web/non-on-prem
passkey flow receives the same validated nextPath used elsewhere; ensure you use
the existing nextPath variable (the one computed earlier) when adding the prop.
---
Nitpick comments:
In `@apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts`:
- Around line 134-140: Replace the module-level const stubs
acceptInviteForNewUserPipe and acceptInviteForExistingUserPipe with vitest
hoisted declarations (use vi.hoisted() to create those variables before mocks
run) and keep the vi.mock factory returning those stub values (i.e., change the
declarations so they are initialized via vi.hoisted() rather than plain const
while leaving the mock factory that references acceptInviteForNewUser and
acceptInviteForExistingUser unchanged).
In `@apps/web/src/components/members/__tests__/PendingInviteRow.test.tsx`:
- Around line 36-44: Add a test case in PendingInviteRow.test.tsx to cover the
OWNER role branch: render <PendingInviteRow invite={buildInvite({ role: 'OWNER'
})} /> and assert that screen.getByText('Owner') is in the document; place it
alongside the existing 'Member' and 'Admin' tests to ensure PendingInviteRow and
buildInvite handle the OWNER badge.
In `@apps/web/src/lib/auth/__tests__/native-invite-acceptance.test.ts`:
- Around line 75-94: Add a new test for consumeInviteIfPresent that passes user:
{ id: 'user-1', suspendedAt: undefined } and isNewUser: false, mock
acceptForExistingPipe to resolve as in the existing test, then assert
acceptForExistingPipe was called with an objectContaining({ suspendedAt: null })
and that the returned result.invitedDriveId matches the mock; this documents and
verifies the fallback mapping of undefined -> null when calling
acceptForExistingPipe from consumeInviteIfPresent.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b490faeb-c932-456c-882f-0e04814f3056
📒 Files selected for processing (39)
apps/web/src/app/api/__tests__/drive-member-gate-coverage.test.tsapps/web/src/app/api/auth/apple/callback/__tests__/route.test.tsapps/web/src/app/api/auth/apple/callback/route.tsapps/web/src/app/api/auth/apple/native/__tests__/route.test.tsapps/web/src/app/api/auth/apple/native/route.tsapps/web/src/app/api/auth/apple/signin/__tests__/route.test.tsapps/web/src/app/api/auth/apple/signin/route.tsapps/web/src/app/api/auth/google/callback/__tests__/route.test.tsapps/web/src/app/api/auth/google/callback/route.tsapps/web/src/app/api/auth/google/native/__tests__/route.test.tsapps/web/src/app/api/auth/google/native/route.tsapps/web/src/app/api/auth/google/one-tap/__tests__/route.test.tsapps/web/src/app/api/auth/google/one-tap/route.tsapps/web/src/app/api/auth/google/signin/__tests__/route.test.tsapps/web/src/app/api/auth/google/signin/route.tsapps/web/src/app/api/drives/[driveId]/members/__tests__/route.test.tsapps/web/src/app/api/drives/[driveId]/members/route.tsapps/web/src/app/api/drives/[driveId]/pending-invites/[inviteId]/__tests__/route.test.tsapps/web/src/app/api/drives/[driveId]/pending-invites/[inviteId]/route.tsapps/web/src/app/auth/signin/page.tsxapps/web/src/app/auth/signup/SignUpClient.tsxapps/web/src/components/auth/PasskeyLoginButton.tsxapps/web/src/components/members/DriveMembers.tsxapps/web/src/components/members/MemberRow.tsxapps/web/src/components/members/PendingInviteRow.tsxapps/web/src/components/members/PendingInvitesSection.tsxapps/web/src/components/members/__tests__/DriveMembers.test.tsxapps/web/src/components/members/__tests__/MemberRow.test.tsxapps/web/src/components/members/__tests__/PendingInviteRow.test.tsxapps/web/src/components/members/__tests__/PendingInvitesSection.test.tsxapps/web/src/hooks/__tests__/useOAuthSignIn.test.tsapps/web/src/hooks/useOAuthSignIn.tsapps/web/src/lib/auth/__tests__/native-invite-acceptance.test.tsapps/web/src/lib/auth/__tests__/oauth-state.test.tsapps/web/src/lib/auth/native-invite-acceptance.tsapps/web/src/lib/auth/oauth-state.tsapps/web/src/lib/auth/revoke-adapters.tsapps/web/src/lib/ios-apple-auth.tsapps/web/src/lib/ios-google-auth.ts
- a11y: revoke button now exposes per-invite aria-label so screen readers announce the destructive action target. - type tightening: NativeInviteAcceptanceInput.user.suspendedAt is required (Date | null) — drops the nullish-coalesce that could silently treat a caller-omitted suspendedAt as not-suspended. - nextPath wiring: cloud-path PasskeyLoginButton now also receives the validated nextPath (on-prem branch already had it). - test stability: google/callback __tests__ now mints stateWithInvite per test via a factory so the embedded HMAC-state timestamp can't expire mid suite. - vi.hoisted: apple + google callback test pipes (acceptInviteForNewUserPipe, acceptInviteForExistingUserPipe) are now declared via vi.hoisted to keep vi.mock factory references safe during hoist phase. - coverage: PendingInviteRow gains an OWNER badge render test, closing the remaining role branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Invite consumption now runs BEFORE the platform === 'desktop' / 'ios' branches in google/callback and apple/callback so a user who arrives at the OAuth flow with a signed inviteToken in state actually becomes a member of the invited drive regardless of platform. Previously the inviteToken was verified into the state but silently dropped at the deep-link handoff for non-web platforms. The deep-link URL now carries `invitedDriveId=<id>` as a forward-compat query param so a future desktop/iOS client can route directly to the invited drive; today's clients that ignore the param still get correct membership and can navigate to the drive manually. Web behavior is unchanged: returnUrl is still overridden to /dashboard/<driveId>?invited=1 on success and gets `inviteError=<code>` appended on validator failure. Refactor uses the existing `consumeInviteIfPresent` helper so all OAuth entry points (web, native, one-tap, web-callback-desktop, web-callback-iOS) share one acceptance code path. Tests: - google + apple callback now have desktop and iOS test cases asserting invitedDriveId appears in the deep-link query string. Addresses CodeRabbit "outside diff range" finding on apple/callback route.ts:238-363; same bug existed in google/callback so fixed symmetrically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@coderabbitai Addressed all 4 inline + both outside-diff findings: Inline (resolved):
Outside diff range (resolved):
Nitpicks also addressed:
New tests cover desktop + iOS deep-link |
The 4 failing Unit Tests checks (google-callback-redirect ×2, one-tap, open-redirect-protection) were dropping the `logger` export when partially mocking `@pagespace/lib/logging/logger-config`. The ws-connections module (transitively imported via invite acceptance adapters → @/lib/websocket) calls `logger.child(...)`, which throws "No 'logger' export is defined on the mock" without it. Mocks now declare a self-referencing `childLogger` that returns itself from `.child()`, so the transitive websocket import resolves cleanly under partial mock and these tests pass in CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Tip For best results, initiate chat on the files or code changes.
✏️ Learnings added
If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
* feat(invites): inviteToken in OAuth state schema
Adds optional `inviteToken` (1-128 chars) to oauthStateDataSchema so signed OAuth state can carry an invite token through the provider round-trip. Verified via tests for round-trip preservation, max-length rejection, and empty-string rejection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): forward inviteToken in Google signin
Adds optional `inviteToken` field (1-128 chars, matching oauthStateDataSchema) to googleSigninSchema, validated and conditionally forwarded into the HMAC-signed state via createSignedState. Tests cover round-trip into state, omission when absent, and 400 rejection for empty or oversized tokens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): forward inviteToken in Apple signin
Adds inviteToken to appleSigninSchema and migrates POST + GET handlers from inline crypto.createHmac to the shared createSignedState helper. The helper auto-attaches the timestamp that verifyOAuthState requires at the callback (the prior inline build omitted timestamp, which would have caused malformed-state rejection).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): inviteToken in useOAuthSignIn hook
Adds optional `inviteToken` to useOAuthSignIn options and forwards it through a new pure `buildOAuthSigninBody` helper into the web POST body. Native (iOS) paths are plumbed in T4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(invites): import INVITE_TOKEN_MAX_LENGTH constant
Codex review nits: route schemas now import INVITE_TOKEN_MAX_LENGTH from oauth-state.ts instead of hardcoding 128, and the hook drops a redundant double-guard around the optional inviteToken (buildOAuthSigninBody already guards internally).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): consume invite in Google web callback
After successful Google OAuth on web, if state carries inviteToken: branches new vs existing user (using the natural `!user` from findUserByGoogleIdOrEmail) and routes through acceptInviteForNewUser or acceptInviteForExistingUser. On success, returnUrl is overridden to /dashboard/<driveId>?invited=1; on failure, the error code is appended (auth itself still succeeds). Pipe throws are caught and logged - never bounce auth. Desktop/iOS branches are intentionally skipped here and handled in T4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): consume invite in Apple web callback
Mirrors the Google callback wiring: branches new vs existing user via findUserByAppleIdOrEmail and routes through the appropriate acceptance pipe. Includes an explicit Apple-private-relay regression guard test (`@privaterelay.appleid.com` mismatch surfaces EMAIL_MISMATCH instead of silently joining).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): consume invite in native + one-tap routes
Adds a shared `consumeInviteIfPresent` helper (apps/web/src/lib/auth/native-invite-acceptance.ts) and wires it into the 3 native auth routes (google/native, apple/native, google/one-tap). Each route accepts inviteToken in its body schema, returns invitedDriveId + inviteError fields. The one-tap route also overrides its existing redirectTo when an invite was consumed. Helper has its own unit tests covering branching; route tests confirm wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): plumb inviteToken through native auth + hook
ios-google-auth and ios-apple-auth now accept an optional inviteToken option, forward it to their respective /native routes, and surface invitedDriveId + inviteError on the result. useOAuthSignIn passes its inviteToken through to those calls and consults a new pure buildPostNativeAuthRedirect helper to land users at /dashboard/<driveId>?invited=1 when an invite was consumed (taking precedence over the generic /dashboard?welcome=true new-user landing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): pass inviteToken from auth pages to OAuth
SignInForm reads ?invite= from query params; SignUpClient already received inviteToken as a prop. Both now forward it to useOAuthSignIn so the OAuth Google + Apple paths consume the invite end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): expose pendingInvites in members API
GET /api/drives/[driveId]/members now returns a pendingInvites array. Populated for OWNER/ADMIN viewers (via findUnconsumedInvitesByDrive); empty array for regular MEMBER (no information leak, but stable SWR cache shape across role changes). Repo is not queried at all when the viewer is not OWNER/ADMIN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): PendingInvitesSection + PendingInviteRow
New OWNER/ADMIN-only UI surface for pending drive invites. Row shows invitee email, role badge, and a Pending or Expired badge based on expiresAt vs now. Section returns null for non-OWNER/ADMIN viewers and for empty arrays. Revoke button is intentionally not yet present — added in T9 once the DELETE endpoint and adapter are in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): integrate PendingInvitesSection in DriveMembers
DriveMembers now renders accepted-only members in the main list and feeds the new PendingInvitesSection from the API's pendingInvites field. The legacy pendingMembers filter (drive_members.acceptedAt IS NULL) is gone — post-cutover, drive_members rows are always accepted, so that branch was dead. MemberRow drops the isPending logic entirely. Tests covering the old behavior are removed; new ones cover the API-driven section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): revoke pending invite end-to-end
Re-adds revoke-adapters.ts with buildRevokePorts wired to driveInviteRepository, plus DELETE /api/drives/[driveId]/pending-invites/[inviteId]. Maps validator codes to HTTP: NOT_FOUND -> 404 (covers cross-drive enumeration), FORBIDDEN -> 403, ok -> 200 with inviteId+driveId. Adds the auth/revoke-adapters.ts entry to the drive-member gate-coverage allow-list (validator enforces the accepted-OWNER/ADMIN gate). PendingInviteRow gains an AlertDialog-confirmed revoke button; DriveMembers wires it through optimistic local state + toast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): honor next= on passkey signin
SignInForm reads ?next= from query, validates via isSafeNextPath against the documented allowlist (/dashboard, /invite/, /account), and threads the safe value to PasskeyLoginButton via a new nextPath prop. PasskeyLoginButton uses nextPath to override the server's default redirectUrl on success. Magic-link signin honoring next is a separate follow-up since it requires the email link itself to carry the next param through the send + verify backend.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invites): audit denied-revoke + drop unused import
Adds explicit auditRequest('authz.access.denied') to the FORBIDDEN branch of the revoke route so a malicious enumeration attempt leaves an audit trail (the success-side audit is already emitted by the adapter's auditPermissionRevoked port). Removes the unused userEvent import in DriveMembers.test.tsx left over from the T8 cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invites): address CodeRabbit review feedback
- a11y: revoke button now exposes per-invite aria-label so screen readers
announce the destructive action target.
- type tightening: NativeInviteAcceptanceInput.user.suspendedAt is required
(Date | null) — drops the nullish-coalesce that could silently treat a
caller-omitted suspendedAt as not-suspended.
- nextPath wiring: cloud-path PasskeyLoginButton now also receives the
validated nextPath (on-prem branch already had it).
- test stability: google/callback __tests__ now mints stateWithInvite per
test via a factory so the embedded HMAC-state timestamp can't expire mid
suite.
- vi.hoisted: apple + google callback test pipes (acceptInviteForNewUserPipe,
acceptInviteForExistingUserPipe) are now declared via vi.hoisted to keep
vi.mock factory references safe during hoist phase.
- coverage: PendingInviteRow gains an OWNER badge render test, closing the
remaining role branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(invites): consume OAuth invite for desktop + iOS callback paths
Invite consumption now runs BEFORE the platform === 'desktop' / 'ios'
branches in google/callback and apple/callback so a user who arrives at
the OAuth flow with a signed inviteToken in state actually becomes a
member of the invited drive regardless of platform. Previously the
inviteToken was verified into the state but silently dropped at the
deep-link handoff for non-web platforms.
The deep-link URL now carries `invitedDriveId=<id>` as a forward-compat
query param so a future desktop/iOS client can route directly to the
invited drive; today's clients that ignore the param still get correct
membership and can navigate to the drive manually.
Web behavior is unchanged: returnUrl is still overridden to
/dashboard/<driveId>?invited=1 on success and gets `inviteError=<code>`
appended on validator failure.
Refactor uses the existing `consumeInviteIfPresent` helper so all OAuth
entry points (web, native, one-tap, web-callback-desktop, web-callback-iOS)
share one acceptance code path.
Tests:
- google + apple callback now have desktop and iOS test cases asserting
invitedDriveId appears in the deep-link query string.
Addresses CodeRabbit "outside diff range" finding on apple/callback
route.ts:238-363; same bug existed in google/callback so fixed
symmetrically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): add logger export to logger-config mocks
The 4 failing Unit Tests checks (google-callback-redirect ×2, one-tap,
open-redirect-protection) were dropping the `logger` export when
partially mocking `@pagespace/lib/logging/logger-config`. The
ws-connections module (transitively imported via invite acceptance
adapters → @/lib/websocket) calls `logger.child(...)`, which throws
"No 'logger' export is defined on the mock" without it.
Mocks now declare a self-referencing `childLogger` that returns itself
from `.child()`, so the transitive websocket import resolves cleanly
under partial mock and these tests pass in CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes the four items deferred from PR #1267 (the from-scratch invite architecture rebuild):
inviteTokennow plumbs throughoauthStateDataSchema, both signin schemas, theuseOAuthSignInhook, and the iOS auth modules. Web callbacks consume invites inline and overridereturnUrlto/dashboard/<driveId>?invited=1. Native + one-tap routes share aconsumeInviteIfPresenthelper and returninvitedDriveId+inviteErrorin the response payload. Desktop and iOS web-callback paths run the SAME helper before deep-link handoff, attachinginvitedDriveId=<id>to thepagespace://auth-exchangeURL as a forward-compat query param so the user is correctly added to the drive regardless of platform.GET /api/drives/[driveId]/membersreturns{ members, currentUserRole, pendingInvites }; newPendingInvitesSection+PendingInviteRowcomponents render the OWNER/ADMIN-only pending list. The legacyMemberRow.isPendingbranch andpendingMembersfilter (which were dead post-cutover) are removed.DELETE /api/drives/[driveId]/pending-invites/[inviteId]calls the existingrevokePendingInvitepipe via a newbuildRevokePortsadapter. Maps validator codes to HTTP: NOT_FOUND→404 (covers cross-drive enumeration), FORBIDDEN→403, ok→200. UI gets an AlertDialog-confirmed revoke button on each pending row.next=honoring on passkey signin: SignInForm reads?next=from the URL, validates viaisSafeNextPathagainst['/dashboard', '/invite/', '/account'], and threads the safe value to both the cloud and on-premPasskeyLoginButtoninstances. Magic-link signin honoringnextis a separate follow-up — that path requires the email link itself to carry the param through the send + verify backend.Bonus fix (silent)
The Apple POST signin route was building OAuth state inline with
crypto.createHmac, omitting thetimestampfield thatverifyOAuthStaterequires. Apple POST signin would have failed withoauth_errorfor every user on master. T1's migration to the sharedcreateSignedStatehelper auto-attaches the timestamp, so this PR silently fixes that pre-existing bug. Test:apps/web/src/app/api/auth/apple/signin/__tests__/route.test.ts"includes timestamp in state".Architecture notes
packages/lib/src/services/invites/(pipes, validators, predicates) and was not modified by this PR. New IO sits in adapters (apps/web/src/lib/auth/{invite-acceptance-adapters,revoke-adapters,native-invite-acceptance}.ts).emitAcceptanceSideEffectsinvariant maintained. Every drive-membership write goes through one of the pipes, which in turn fires the four side-effect ports.auth/revoke-adapters.tsis added toLIB_ACCEPTED_AT_GATE_EXEMPTwith the rationale thatfindActorMembershipdeliberately returns raw{role, acceptedAt}so the strict "accepted OWNER/ADMIN" gate lives once in the pure-core validator.consumeInviteIfPresenthelper is now called from native (google/native, apple/native, google/one-tap) AND web callbacks (google/callback web/desktop/iOS branches, apple/callback web/desktop/iOS branches). One acceptance contract, six call sites.Reviews already done
Test plan
pnpm typecheckclean monorepo-widepnpm lintcleanpnpm test:unit— 7236+ pass; remaining failures are pre-existing local-DB integration tests (role "test" does not exist) and 4 pre-existing import-resolution test files unchanged from masterdrive-member-gate-coverage8/8 (with new revoke-adapters.ts allow-list entry)security-audit-coveragegreen (revoke route emitsauthz.access.deniedon FORBIDDEN,authz.permission.revokedon success via adapter)invitedDriveIdpropagation tests/invite/[token]/accept?invite=<token>consumes correctly (web)?next=/dashboard/<driveId>honored;?next=//evil.comignoredReferences
~/.claude/plans/async-churning-waterfall.md🤖 Generated with Claude Code