Skip to content

feat(invites): OAuth + members UI + revoke + next= - #1273

Merged
2witstudios merged 19 commits into
masterfrom
pu/drive-invite-followups-pt2
May 7, 2026
Merged

2witstudios merged 19 commits into
masterfrom
pu/drive-invite-followups-pt2

Conversation

@2witstudios

@2witstudios 2witstudios commented May 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Closes the four items deferred from PR #1267 (the from-scratch invite architecture rebuild):

  • OAuth invite consumption (Google + Apple, web + native + one-tap + desktop + iOS): inviteToken now plumbs through oauthStateDataSchema, both signin schemas, the useOAuthSignIn hook, and the iOS auth modules. Web callbacks consume invites inline and override returnUrl to /dashboard/<driveId>?invited=1. Native + one-tap routes share a consumeInviteIfPresent helper and return invitedDriveId + inviteError in the response payload. Desktop and iOS web-callback paths run the SAME helper before deep-link handoff, attaching invitedDriveId=<id> to the pagespace://auth-exchange URL as a forward-compat query param so the user is correctly added to the drive regardless of platform.
  • Members API + UI: GET /api/drives/[driveId]/members returns { members, currentUserRole, pendingInvites }; new PendingInvitesSection + PendingInviteRow components render the OWNER/ADMIN-only pending list. The legacy MemberRow.isPending branch and pendingMembers filter (which were dead post-cutover) are removed.
  • Revoke endpoint: DELETE /api/drives/[driveId]/pending-invites/[inviteId] calls the existing revokePendingInvite pipe via a new buildRevokePorts adapter. 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 via isSafeNextPath against ['/dashboard', '/invite/', '/account'], and threads the safe value to both the cloud and on-prem PasskeyLoginButton instances. Magic-link signin honoring next is 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 the timestamp field that verifyOAuthState requires. Apple POST signin would have failed with oauth_error for every user on master. T1's migration to the shared createSignedState helper 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

  • Pure-core untouched. All business logic lives in 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).
  • emitAcceptanceSideEffects invariant maintained. Every drive-membership write goes through one of the pipes, which in turn fires the four side-effect ports.
  • Drive-member gate coverage: the new auth/revoke-adapters.ts is added to LIB_ACCEPTED_AT_GATE_EXEMPT with the rationale that findActorMembership deliberately returns raw {role, acceptedAt} so the strict "accepted OWNER/ADMIN" gate lives once in the pure-core validator.
  • Hard-cutover discipline: no nullable bridges, no compat re-exports, no shim code.
  • Single OAuth invite acceptance code path: the consumeInviteIfPresent helper 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

  • Codex sanity-check after T1 (OAuth state architecture): clean.
  • Codex sanity-check after T9 (revoke authz model): clean.
  • Adversarial security fan-out at T1: confirmed no current vulnerability; called out the pre-existing Apple bug.
  • Adversarial security fan-out at T9: clean. One non-exploitable low-severity finding (duplicate audit events on concurrent revoke — log noise, not a security hole) intentionally deferred.
  • Codex whole-diff sanity before PR: clean — no blockers.
  • CodeRabbit review: 4 inline + 2 outside-diff findings addressed (test flakiness, a11y, type tightening, cloud passkey nextPath, desktop/iOS invite gap, vi.hoisted nit). Permission-helper suggestion declined with rationale (page-perm helpers are wrong domain for drive-management role checks; pattern matches MemberRow / DriveMembers).

Test plan

  • pnpm typecheck clean monorepo-wide
  • pnpm lint clean
  • pnpm 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 master
  • drive-member-gate-coverage 8/8 (with new revoke-adapters.ts allow-list entry)
  • security-audit-coverage green (revoke route emits authz.access.denied on FORBIDDEN, authz.permission.revoked on success via adapter)
  • Apple + Google callback test suites: 130/130 pass including new desktop + iOS deep-link invitedDriveId propagation tests
  • Manual smoke: invite known user → see in pending list → revoke → row gone → invite again → accept via /invite/[token]/accept
  • Manual smoke: OAuth Google + Apple sign-in with ?invite=<token> consumes correctly (web)
  • Manual smoke: signin with ?next=/dashboard/<driveId> honored; ?next=//evil.com ignored

References

🤖 Generated with Claude Code

2witstudios and others added 16 commits May 6, 2026 23:27
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>
@coderabbitai

coderabbitai Bot commented May 7, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 21 minutes and 12 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a7b916aa-eba2-4167-9030-b92aa61da4e2

📥 Commits

Reviewing files that changed from the base of the PR and between 82379a7 and 2ba6cc7.

📒 Files selected for processing (12)
  • apps/web/src/app/api/auth/__tests__/google-callback-redirect.test.ts
  • apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/apple/callback/route.ts
  • apps/web/src/app/api/auth/google/__tests__/google-callback-redirect.test.ts
  • apps/web/src/app/api/auth/google/__tests__/one-tap.test.ts
  • apps/web/src/app/api/auth/google/__tests__/open-redirect-protection.test.ts
  • apps/web/src/app/api/auth/google/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/callback/route.ts
  • apps/web/src/app/auth/signin/page.tsx
  • apps/web/src/components/members/PendingInviteRow.tsx
  • apps/web/src/components/members/__tests__/PendingInviteRow.test.tsx
  • apps/web/src/lib/auth/native-invite-acceptance.ts
📝 Walkthrough

Walkthrough

This PR introduces invite-token acceptance during OAuth/native authentication flows and adds invite revocation capabilities in the drive members UI. It threads an optional inviteToken through sign-in/callback routes, implements invite consumption logic (consumeInviteIfPresent, acceptInviteForNewUser/ExistingUser), and refactors the members list to display pending invitations separately from accepted members.

Changes

Invite Token Threading and OAuth State

Layer / File(s) Summary
Schema & Constants
apps/web/src/lib/auth/oauth-state.ts
Defines INVITE_TOKEN_MAX_LENGTH = 128 and extends oauthStateDataSchema with optional inviteToken field.
OAuth Signin Routes
apps/web/src/app/api/auth/{apple,google}/signin/route.ts
apps/web/src/app/api/auth/{apple,google}/signin/__tests__/route.test.ts
Both POST and GET now use createSignedState helper to mint OAuth state, optionally including inviteToken from validated request. Tests verify state round-trip and length validation.
Native Auth Routes
apps/web/src/app/api/auth/{apple,google}/native/route.ts
apps/web/src/app/api/auth/{apple,google}/native/__tests__/route.test.ts
Request schemas accept optional inviteToken (bounded by max length). Handlers pass it to consumeInviteIfPresent and return invitedDriveId and inviteError in response. Tests verify forwarding and error handling.
iOS Auth Libraries
apps/web/src/lib/ios-{apple,google}-auth.ts
Both functions now accept optional inviteToken in an options parameter, forward it in backend requests, and return invitation results (invitedDriveId, inviteError) in the success payload.

OAuth Callback Invite Acceptance

Layer / File(s) Summary
Invite Acceptance Service Layer
apps/web/src/lib/auth/native-invite-acceptance.ts
apps/web/src/lib/auth/__tests__/native-invite-acceptance.test.ts
New consumeInviteIfPresent helper routes to acceptInviteForNewUser or acceptInviteForExistingUser based on user state, returns invitedDriveId on success or inviteError on failure, logs and recovers from thrown errors. Tests verify all control flows including error cases and email normalization.
Callback Routes
apps/web/src/app/api/auth/{apple,google}/callback/route.ts
apps/web/src/app/api/auth/{apple,google}/callback/__tests__/route.test.ts
After user creation check, capture wasNewUser, then conditionally call invite acceptance pipe (new vs existing user) when inviteToken is present. Update returnUrl to invited drive (with invited=1) on success or append inviteError on failure; log exceptions without blocking auth. Tests verify pipe selection, URL updates, error propagation, and error logging.
One-Tap Route
apps/web/src/app/api/auth/google/one-tap/route.ts
apps/web/src/app/api/auth/google/one-tap/__tests__/route.test.ts
Request schema includes optional inviteToken. After provisioning Getting Started drive, call consumeInviteIfPresent to potentially override redirectTo or append inviteError. Tests verify redirect override and error handling.
OAuth Redirect Helper
apps/web/src/hooks/useOAuthSignIn.ts
apps/web/src/hooks/__tests__/useOAuthSignIn.test.ts
New buildPostNativeAuthRedirect and buildOAuthSigninBody helpers; native success redirection now routes to invited drive first, then new-user welcome, else dashboard. Tests verify precedence and default behaviors.

Pending Invite Revocation

Layer / File(s) Summary
Revoke Adapter Factory
apps/web/src/lib/auth/revoke-adapters.ts
New buildRevokePorts function wires pre-commit reads (pending invite lookup, actor membership with acceptedAt), commit delete, and post-commit audit logging for invite revocation.
Revoke Endpoint
apps/web/src/app/api/drives/[driveId]/pending-invites/[inviteId]/route.ts
apps/web/src/app/api/drives/[driveId]/pending-invites/[inviteId]/__tests__/route.test.ts
New DELETE handler authenticates, reads params, calls revokePendingInvite with ports, maps result codes (NOT_FOUND → 404, other failures → 403 with audit event), returns revoked identifiers on success, logs and returns 500 on exceptions. Tests verify all status codes, authorization checks, and error logging.
Members List Endpoint
apps/web/src/app/api/drives/[driveId]/members/route.ts
apps/web/src/app/api/drives/[driveId]/members/__tests__/route.test.ts
GET now computes owner/admin permission and conditionally fetches unconsumed drive invites, including pendingInvites array in response. Tests verify role-based fetch behavior and response shape.
Revoke Coverage Scan
apps/web/src/app/api/__tests__/drive-member-gate-coverage.test.ts
Adds auth/revoke-adapters.ts to lib-level allow-list with justification that findActorMembership selectsRole and acceptedAt; strict role validation in validateRevokeRequest defers acceptedAt filtering to request validation.

Members UI Refactor

Layer / File(s) Summary
Pending Invite Component
apps/web/src/components/members/PendingInviteRow.tsx
apps/web/src/components/members/__tests__/PendingInviteRow.test.tsx
New component renders pending invite email, role badge, expiration status, inviter name, and optional revoke button with confirmation dialog. Tests verify rendering, expiration logic, role badges, and async revoke callback behavior.
Pending Invites Section
apps/web/src/components/members/PendingInvitesSection.tsx
apps/web/src/components/members/__tests__/PendingInvitesSection.test.tsx
New client component conditionally renders pending invite rows based on owner/admin access; returns null when user lacks permission or invites list is empty. Tests verify role-based visibility and heading count display.
Drive Members List
apps/web/src/components/members/DriveMembers.tsx
apps/web/src/components/members/__tests__/DriveMembers.test.tsx
Adds separate pendingInvites state (from API response), simplifies removal to single handleRemoveMember flow, introduces handleRevokeInvite to delete via pending-invites endpoint. UI now renders members and pending sections separately. Tests verify both sections render with counts and section omission when empty.
Member Row Simplification
apps/web/src/components/members/MemberRow.tsx
apps/web/src/components/members/__tests__/MemberRow.test.tsx
Removes isPending conditional logic; permission counts and "Remove Member" button now always render for non-pending rows. Simplified tests to focus on accepted member behavior.

Sign-in Page Enhancements

Layer / File(s) Summary
Signin Page
apps/web/src/app/auth/signin/page.tsx
Derives safe nextPath from query using isSafeNextPath with allowed prefixes, passes nextPath to passkey button and forwards inviteToken to useOAuthSignIn.
Signup Client
apps/web/src/app/auth/signup/SignUpClient.tsx
Forwards optional inviteToken into useOAuthSignIn hook configuration.
Passkey Button
apps/web/src/components/auth/PasskeyLoginButton.tsx
Adds optional nextPath prop; on successful authentication, uses nextPath ?? verifyData.redirectUrl for the final redirect.

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
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

  • 2witstudios/PageSpace#1236: Main PR and this PR both implement invite-acceptance logic in the same auth callback/native routes, calling acceptInviteForNewUser/ExistingUser during post-login flows.
  • 2witstudios/PageSpace#1243: Both PRs refactor the drive-members UI to separate pending invites into a dedicated component (PendingInviteRow, PendingInvitesSection) and modify DriveMembers to manage them separately from accepted members.
  • 2witstudios/PageSpace#1245: Both PRs add pending-invite revocation endpoints and routes (DELETE /api/drives/[driveId]/pending-invites/[inviteId]), revoke-adapters factories, and integration with the drive-members API.

🐰 A flutter of invites through the OAuth door,
Pending acceptances we've brought to your floor.
Revoke what you'd rather with just one swift click,
Members and pending—now both logically split.
🎉 Sign-in flows soar with tokens in tow!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(invites): OAuth + members UI + revoke + next=' directly summarizes the four main changes in the PR: OAuth invite consumption, members UI updates, revoke endpoint, and next-path honoring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/drive-invite-followups-pt2

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Propagate validated nextPath to web passkey flow too

nextPath is computed in Line 31-34 but not passed to the non-on-prem PasskeyLoginButton at 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

inviteToken in OAuth state is silently dropped for platform === 'desktop'.

The invite acceptance block (lines 339–363) runs only on the web platform path. Both the desktop branch (line 238, returns at line 280) and the ios branch (line 284, returns at line 320) exit before this code is reached.

For desktop: when a desktop user authenticates via Apple OAuth, inviteToken is correctly signed into the state (in apple/signin/route.ts), HMAC-verified here, but then silently discarded. Neither the createExchangeCode payload nor the pagespace://auth-exchange deep-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 calls consumeInviteIfPresent), 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 inviteToken in 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 value

LGTM — consider adding a test for the suspendedAt: undefined edge case

The existing suite covers the happy path for existing users with a concrete suspendedAt value. If the user.suspendedAt type is tightened to required (per the suggestion in native-invite-acceptance.ts), this becomes moot; otherwise a test asserting that undefined maps to null in 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 win

Add 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 win

Prefer vi.hoisted() for pipe stub variables used in vi.mock() factories.

acceptInviteForNewUserPipe and acceptInviteForExistingUserPipe are module-level const declarations referenced inside a vi.mock() factory. While this works here because the variables are only read inside nested closures (not at factory-evaluation time), plain const declarations are technically in the TDZ when vi.mock() is hoisted. The Vitest-idiomatic pattern is vi.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

📥 Commits

Reviewing files that changed from the base of the PR and between 771dfd8 and 82379a7.

📒 Files selected for processing (39)
  • apps/web/src/app/api/__tests__/drive-member-gate-coverage.test.ts
  • apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/apple/callback/route.ts
  • apps/web/src/app/api/auth/apple/native/__tests__/route.test.ts
  • apps/web/src/app/api/auth/apple/native/route.ts
  • apps/web/src/app/api/auth/apple/signin/__tests__/route.test.ts
  • apps/web/src/app/api/auth/apple/signin/route.ts
  • apps/web/src/app/api/auth/google/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/callback/route.ts
  • apps/web/src/app/api/auth/google/native/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/native/route.ts
  • apps/web/src/app/api/auth/google/one-tap/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/one-tap/route.ts
  • apps/web/src/app/api/auth/google/signin/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/signin/route.ts
  • apps/web/src/app/api/drives/[driveId]/members/__tests__/route.test.ts
  • apps/web/src/app/api/drives/[driveId]/members/route.ts
  • apps/web/src/app/api/drives/[driveId]/pending-invites/[inviteId]/__tests__/route.test.ts
  • apps/web/src/app/api/drives/[driveId]/pending-invites/[inviteId]/route.ts
  • apps/web/src/app/auth/signin/page.tsx
  • apps/web/src/app/auth/signup/SignUpClient.tsx
  • apps/web/src/components/auth/PasskeyLoginButton.tsx
  • apps/web/src/components/members/DriveMembers.tsx
  • apps/web/src/components/members/MemberRow.tsx
  • apps/web/src/components/members/PendingInviteRow.tsx
  • apps/web/src/components/members/PendingInvitesSection.tsx
  • apps/web/src/components/members/__tests__/DriveMembers.test.tsx
  • apps/web/src/components/members/__tests__/MemberRow.test.tsx
  • apps/web/src/components/members/__tests__/PendingInviteRow.test.tsx
  • apps/web/src/components/members/__tests__/PendingInvitesSection.test.tsx
  • apps/web/src/hooks/__tests__/useOAuthSignIn.test.ts
  • apps/web/src/hooks/useOAuthSignIn.ts
  • apps/web/src/lib/auth/__tests__/native-invite-acceptance.test.ts
  • apps/web/src/lib/auth/__tests__/oauth-state.test.ts
  • apps/web/src/lib/auth/native-invite-acceptance.ts
  • apps/web/src/lib/auth/oauth-state.ts
  • apps/web/src/lib/auth/revoke-adapters.ts
  • apps/web/src/lib/ios-apple-auth.ts
  • apps/web/src/lib/ios-google-auth.ts

Comment thread apps/web/src/app/api/auth/google/callback/__tests__/route.test.ts Outdated
Comment thread apps/web/src/components/members/PendingInviteRow.tsx
Comment thread apps/web/src/components/members/PendingInvitesSection.tsx
Comment thread apps/web/src/lib/auth/native-invite-acceptance.ts
2witstudios and others added 2 commits May 7, 2026 00:38
- 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>
@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai Addressed all 4 inline + both outside-diff findings:

Inline (resolved):

  • google/callback/__tests__/route.test.ts:1295 (test flakiness): per-test factory for stateWithInvite() — commit 2b7c7dd
  • PendingInviteRow.tsx:111 (a11y): added aria-label per-invite — commit 2b7c7dd
  • native-invite-acceptance.ts:25 (type tightening): user.suspendedAt is now required — commit 2b7c7dd
  • PendingInvitesSection.tsx:18 (permission helpers): declined with rationale — getUserAccessLevel/canUserEditPage are page-permission helpers, not drive-membership-management helpers; the inline OWNER || ADMIN pattern matches MemberRow.tsx:43 and DriveMembers.tsx:174. A future helper sweep across all three would be a separate PR.

Outside diff range (resolved):

  • signin/page.tsx:166-169 (cloud nextPath): now propagated to the cloud passkey button (on-prem already had it) — commit 2b7c7dd
  • apple/callback/route.ts:238-363 (desktop/iOS invite gap): fixed symmetrically in both Apple and Google callbacks — commit 4d1c3af. Invite consumption now runs BEFORE platform branching using the existing consumeInviteIfPresent helper. Deep-link URL gets invitedDriveId=<id> as a forward-compat query param. The DB-side membership row is created regardless of platform, so the user is correctly added to the drive even if the desktop/iOS client doesn't yet read the param.

Nitpicks also addressed:

  • OWNER badge test added to PendingInviteRow.test.tsx
  • vi.hoisted() migration for acceptInviteForNewUserPipe / acceptInviteForExistingUserPipe in both apple + google callback tests
  • suspendedAt: undefined test moot (type tightening makes the case impossible)

New tests cover desktop + iOS deep-link invitedDriveId propagation in both providers (4 new test cases).

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>
@2witstudios 2witstudios mentioned this pull request May 7, 2026
5 of 6 tasks
@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@2witstudios: Excellent — thorough resolution across the board. A few notes:

  • Test flakiness, a11y, type tightening, nextPath cloud propagation: All clean fixes, appreciated.
  • Symmetrical Apple+Google callback fix (commit 4d1c3af7f): The approach of running invite consumption before platform branching is exactly the right structural call — it guarantees the DB membership row is created regardless of client, and the forward-compat invitedDriveId=<id> query param on deep-links is a sensible handshake for when the native client is ready to act on it.
  • Permission helper deferral: The rationale is sound. getUserAccessLevel/canUserEditPage are indeed page-permission helpers with a different semantic scope, and keeping the inline OWNER || ADMIN consistent with MemberRow.tsx:43 and DriveMembers.tsx:174 avoids a partial refactor. A future sweep across all three is the right venue.

✏️ Learnings added
Learnt from: 2witstudios
URL: https://github.com/2witstudios/PageSpace/pull/1273

Timestamp: 2026-05-07T06:23:36.828Z
Learning: In `apps/web/src/components/members/`, the inline `OWNER || ADMIN` role check pattern is intentional for drive-membership management (used in PendingInvitesSection.tsx, MemberRow.tsx:43, and DriveMembers.tsx:174). The existing `getUserAccessLevel`/`canUserEditPage` helpers are page-permission helpers with a different semantic scope and should not be applied here. Any consolidation should be done as a coordinated sweep across all three files in a separate PR.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@2witstudios
2witstudios merged commit 2073719 into master May 7, 2026
10 checks passed
2witstudios added a commit that referenced this pull request May 15, 2026
* 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>
@2witstudios
2witstudios deleted the pu/drive-invite-followups-pt2 branch May 27, 2026 02:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant