feat(auth): claim pending invitations by verified email (onboarding phase 1) - #4189
feat(auth): claim pending invitations by verified email (onboarding phase 1)#4189gilgardosh wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Implements onboarding Phase 1 by letting authenticated-but-unprovisioned users discover and claim pending invitations using their identity-provider-verified email, eliminating the “signed up before clicking invite link” dead-end.
Changes:
- Extends
viewerwithpendingInvitations(only when email is verified) and adds server-side provider to fetch unaccepted/unexpired invitations by verified email. - Adds
claimInvitation(invitationId)mutation and refactors invitation acceptance logic to share a singlefinalizeAcceptance()transaction tail. - Updates
/welcometo list pending invitations with one-click Accept, adds a client claim hook, and refreshes viewer state after a claim.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/server/src/modules/common/typeDefs/viewer.graphql.ts | Adds pendingInvitations field and PendingInvitation GraphQL type to the viewer schema. |
| packages/server/src/modules/common/resolvers/viewer.resolver.ts | Resolves pendingInvitations only for verified-email, no-workspace viewers; returns empty list otherwise. |
| packages/server/src/modules/common/tests/viewer.resolver.test.ts | Adds coverage for pending invitation listing and verified-email gating. |
| packages/server/src/modules/auth/typeDefs/auth.graphql.ts | Adds claimInvitation(invitationId) mutation to the auth schema. |
| packages/server/src/modules/auth/resolvers/invitations.resolver.ts | Wires claimInvitation resolver using JWT identity (no auth context required). |
| packages/server/src/modules/auth/providers/pending-invitations.provider.ts | Implements privileged raw-pool query for pending invitations by verified email. |
| packages/server/src/modules/auth/providers/accept-invitations.provider.ts | Adds claimInvitation() and refactors shared acceptance flow into finalizeAcceptance(). |
| packages/server/src/modules/auth/providers/tests/accept-invitations.provider.test.ts | Adds test coverage for claim flow, including gating and oracle-safe failures. |
| packages/server/src/modules/auth/index.ts | Registers PendingInvitationsProvider in the auth module providers. |
| packages/client/src/hooks/use-viewer.ts | Extends viewer query to include pendingInvitations and adds refreshViewer(). |
| packages/client/src/hooks/use-claim-invitation.ts | New hook to call claimInvitation mutation with toast/error handling. |
| packages/client/src/components/screens/welcome.tsx | Shows invitation list on /welcome and allows claiming + refresh + navigation. |
| packages/client/src/components/screens/tests/welcome.test.ts | Adds UI/flow tests for invitation list rendering and claim → refresh → navigate. |
| eslint.config.mjs | Adds a documented exemption for the privileged raw DB access in pending-invitations.provider.ts. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| " an invitation waiting to be claimed by the calling identity " | ||
| type PendingInvitation { | ||
| id: UUID! | ||
| businessId: UUID! | ||
| businessName: String | ||
| role: String! | ||
| expiresAt: DateTime! |
| if (!identity.emailVerified || !identity.email) { | ||
| throw invalidTokenError(); | ||
| } |
8b10307 to
87648cd
Compare
Signing up before clicking the emailed invitation link was a dead end: invitation tokens are stored hashed, so a listed invitation cannot be turned back into its token, and the verified-email lookup in mapAuth0UserToLocal only matched invitations that were already accepted. Server: - viewer.pendingInvitations lists unaccepted, unexpired invitations addressed to the caller's verified email. Never populated for an unverified address, which would otherwise hand a victim's invitation to whoever signed up with their email first. - New claimInvitation(invitationId) mutation, unguarded like acceptInvitation. Where acceptInvitation treats possession of the token as proof, here the verified email is the only proof, so it is mandatory. - Both acceptance paths now share finalizeAcceptance(), so the claimant check, user linking, Auth0 cleanup and audit log cannot drift apart. acceptInvitation keeps its token lookup and stale-token diagnosis. - PendingInvitationsProvider reads through the raw pool (callers have no tenant, so TenantAwareDBClient would throw) and is added to the eslint exemption list with that rationale. Client: - /welcome lists waiting invitations with one-click Accept, replacing the dead-end copy, then re-reads the viewer before navigating so the guard does not bounce the user back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01344D5q1uyAHuwRJ3yyDX46
e29be2a to
1645803
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (5)
packages/client/src/components/screens/welcome.tsx:89
- After a successful claim, calling
refreshViewer()and immediately navigating to HOME can cancel the in-flightviewerrefetch when this component unmounts, leaving the cache stale and causingOnboardingGuardto bounce the user back to/welcome. Since the component already navigates whenviewer.statusbecomesACTIVE, refresh and let that effect perform the navigation once the network-only viewer result arrives.
if (result?.success) {
// The membership is what makes the app usable, so re-read the viewer
// before navigating; the guard would bounce us straight back otherwise.
refreshViewer();
navigate(ROUTES.HOME, { replace: true });
packages/server/src/modules/common/resolvers/viewer.resolver.ts:41
getJwtIdentity()can returnemailVerified: truewhileemailis null (email claim missing). The current condition treats that asEMAIL_UNVERIFIED, producing an inconsistent payload (status: EMAIL_UNVERIFIEDwithemailVerified: true) and misguiding the UI. Handle the cases separately: only returnEMAIL_UNVERIFIEDwhenemailVerifiedis false; when the email claim is missing but verified, returnNO_WORKSPACEwith an emptypendingInvitationslist (cannot match invitations without an address).
if (!identity.emailVerified || !identity.email) {
// An unverified address proves nothing about who the caller is, so it
// must never be matched against invitations.
return {
email: identity.email,
packages/server/src/modules/common/typeDefs/viewer.graphql.ts:29
- The GraphQL descriptions here include leading/trailing whitespace inside the quoted strings, which will show up in generated schema docs/SDL. Trim the descriptions and add proper capitalization/punctuation for readability.
" unaccepted, unexpired invitations addressed to the caller's verified email; always empty when the email is unverified "
pendingInvitations: [PendingInvitation!]!
}
" an invitation waiting to be claimed by the calling identity "
packages/server/src/modules/auth/typeDefs/auth.graphql.ts:34
- The mutation description has leading whitespace and reads a bit awkwardly in generated schema docs. Trimming and tightening the wording improves readability without changing behavior.
" claim an invitation listed on viewer.pendingInvitations, for a caller who does not have the emailed token; authorized by the caller's verified email matching the invitation's "
claimInvitation(invitationId: UUID!): AcceptInvitationPayload!
packages/server/src/modules/auth/resolvers/invitations.resolver.ts:147
- This is the
claimInvitationmutation, but the fallback error message still says "Failed to accept invitation". Using a message that matches the operation makes server logs and client error reporting clearer.
throw new GraphQLError('Failed to accept invitation', {
extensions: { code: 'INVITATION_ACCEPT_FAILED' },
});
Review: - Rename PendingInvitation.role to roleId, matching Invitation.roleId and AcceptInvitationPayload.roleId for the same underlying value. - Give the claim path a token-agnostic rejection message. No token is supplied there, so "Invalid invitation token" was misleading; the TOKEN_INVALID code is kept deliberately so the mutation stays useless as an id oracle. - Correct the comment on the post-claim viewer refresh. It does not prevent the guard from bouncing back today: this urql client is built without a cache exchange, so the guard re-queries on mount regardless. The refresh keeps the flow correct if a cache exchange is ever added. Integration tests: the scraper-ingestion suite cleared its tables with TRUNCATE ... CASCADE. max_creditcard_transactions is referenced by transactions_raw_list, which cascades on to transactions — so a beforeEach in one file emptied the rows the ledger scenario suites had just committed, surfacing there as "Business ... is unbalanced" rather than as an error in the suite that caused it. TRUNCATE also takes ACCESS EXCLUSIVE locks on every table it reaches, which is where the "deadlock detected" came from. DELETE takes row locks and honours foreign keys, and removes only the rows this suite inserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01344D5q1uyAHuwRJ3yyDX46
🚀 Snapshot Release (
|
| Package | Version | Info |
|---|---|---|
@accounter/client |
0.1.0-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/gmail-listener |
0.1.3-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/green-invoice-graphql |
0.8.7-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/hashavshevet-mesh |
0.2.13-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/israeli-vat-scraper |
0.1.13-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/modern-poalim-scraper |
0.11.0-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/payper-mesh |
0.2.13-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/scraper-app |
0.0.3-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/server |
0.2.0-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/shaam-uniform-format-generator |
0.2.7-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
@accounter/shaam6111-generator |
0.1.9-alpha-20260813140717-a8c2db3ecb1c523599416cf9599d2b901315f066 |
npm ↗︎ unpkg ↗︎ |
Six stories covering what /welcome can show: the invitation-only dead end,
the unverified-email branch, one waiting invitation, several invitations
(including a business with no name), the in-flight query, and the failed
query that reports an unknown state rather than "no workspace".
Which branch renders is decided entirely by the viewer query, so each story
supplies its own urql client instead of relying on the mock server, which
cannot be steered to a particular state. The fake query source emits its
result and stays open, like a real one: a source that completes makes urql
push a trailing { fetching: false }, and its state reducer carries data
across that but resets error, which would leave the failure story
unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01344D5q1uyAHuwRJ3yyDX46
Phase 1 of the onboarding plan (#4182), on top of Phase 0 (#4190, merged as
798c141d). Rebased ontomain— this is now a standalone PR, no longer stacked.The dead end
Signing up before clicking the emailed invitation link left a user permanently stuck. Two things had to be true at once:
token_hash), so a listed invitation cannot be turned back into its token — there is no way to reconstruct the link server-side;mapAuth0UserToLocalonly matches invitations that are already accepted (accepted_at IS NOT NULL), so a pending one never matched.Phase 0 gave that user a screen instead of a broken dashboard. This gives them a way out of it.
Server
viewer.pendingInvitations— unaccepted, unexpired invitations addressed to the caller's verified email, with the business name joined in. It is always empty for an unverified address: matching on an unproven email would hand a victim's pending invitation to whoever signed up with their address first.claimInvitation(invitationId)— unguarded likeacceptInvitation, since these callers have no auth context by definition. The security model differs from the token path in a way worth being explicit about:acceptInvitationtreats possession of the token as proof, so its email check is defence in depth; here the verified email is the only proof, so it is mandatory and the mutation rejects an unverified or absent email before touching the database. A missing/accepted/expired invitation is reported identically to one addressed to somebody else, so the id cannot be used as an oracle.finalizeAcceptance()— claimant check, user linking, Auth0 unblock/delete, invitation marking, audit log.acceptInvitationkeeps its token lookup and its stale-token diagnosis (TOKEN_ALREADY_USED/TOKEN_EXPIRED) unchanged.PendingInvitationsProviderreads through the raw pool. It has to: these callers have no tenant, soTenantAwareDBClientthrowsUNAUTHENTICATED. That makes it a privileged read, so it is added to the eslintno-restricted-importsexemption list with that rationale written down, and the only thing keeping it safe is that every entry point passes an identity-provider-verified email — never a client-supplied one.Client
/welcomenow lists waiting invitations with one-click Accept in place of the dead-end copy. After a successful claim it re-reads the viewer (network-only) before navigating, otherwiseOnboardingGuardwould bounce the user straight back with a stale cachedNO_WORKSPACE.The rebase merged this screen with the review fixes that landed in Phase 0: the viewer query stays paused until Auth0 resolves, and
refreshViewernow comes off that same paused hook.Testing
yarn lint0 errors,yarn prettier:checkclean, clienttscclean.main, covering both the Phase 0 pause behaviour and the new claim flow. New cases: claim happy path, unverified email, absent email claim, wrong-recipient invitation, missing/expired invitation; viewer listing and the unverified-email exclusion; the welcome-screen list and the claim → refresh → navigate flow.getInvitationByIdForAcceptancefollows the existing query/naming conventions exactly, but CI is the first place its generated types are actually checked.Follow-up
Phase 2 — disable public signup on the Auth0 database connection — is config plus docs, not code. It does not remove this state: expired or revoked invitations, pre-existing accounts, and users removed from their last business all still land on
/welcome.