Skip to content

feat(auth): claim pending invitations by verified email (onboarding phase 1) - #4189

Open
gilgardosh wants to merge 4 commits into
mainfrom
claude/new-user-onboarding-phase-1
Open

feat(auth): claim pending invitations by verified email (onboarding phase 1)#4189
gilgardosh wants to merge 4 commits into
mainfrom
claude/new-user-onboarding-phase-1

Conversation

@gilgardosh

@gilgardosh gilgardosh commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Phase 1 of the onboarding plan (#4182), on top of Phase 0 (#4190, merged as 798c141d). Rebased onto main — 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:

  • invitation tokens are stored hashed (token_hash), so a listed invitation cannot be turned back into its token — there is no way to reconstruct the link server-side;
  • the verified-email fallback in mapAuth0UserToLocal only 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 like acceptInvitation, since these callers have no auth context by definition. The security model differs from the token path in a way worth being explicit about: acceptInvitation treats 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.
  • One acceptance path, two lookups. Rather than duplicate the acceptance transaction, both entry points now share finalizeAcceptance() — claimant check, user linking, Auth0 unblock/delete, invitation marking, audit log. acceptInvitation keeps its token lookup and its stale-token diagnosis (TOKEN_ALREADY_USED / TOKEN_EXPIRED) unchanged.
  • PendingInvitationsProvider reads through the raw pool. It has to: these callers have no tenant, so TenantAwareDBClient throws UNAUTHENTICATED. That makes it a privileged read, so it is added to the eslint no-restricted-imports exemption 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

/welcome now 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, otherwise OnboardingGuard would bounce the user straight back with a stale cached NO_WORKSPACE.

The rebase merged this screen with the review fixes that landed in Phase 0: the viewer query stays paused until Auth0 resolves, and refreshViewer now comes off that same paused hook.

Testing

  • yarn lint 0 errors, yarn prettier:check clean, client tsc clean.
  • 387 unit tests pass against current 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.
  • Not verified locally: the full server typecheck and the new pgtyped query's codegen — both need Postgres, and there's no Docker daemon in this environment. getInvitationByIdForAcceptance follows 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.

Copilot AI 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.

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 viewer with pendingInvitations (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 single finalizeAcceptance() transaction tail.
  • Updates /welcome to 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.

Comment thread packages/client/src/components/screens/welcome.tsx
Comment on lines +28 to +34
" an invitation waiting to be claimed by the calling identity "
type PendingInvitation {
id: UUID!
businessId: UUID!
businessName: String
role: String!
expiresAt: DateTime!
Comment on lines +151 to +153
if (!identity.emailVerified || !identity.email) {
throw invalidTokenError();
}
@gilgardosh
gilgardosh force-pushed the claude/new-user-onboarding-phase-0 branch from 8b10307 to 87648cd Compare August 13, 2026 08:54
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
@gilgardosh
gilgardosh force-pushed the claude/new-user-onboarding-phase-1 branch from e29be2a to 1645803 Compare August 13, 2026 09:12
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 13, 2026 09:12 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 13, 2026 09:12 — with GitHub Actions Inactive
@gilgardosh
gilgardosh changed the base branch from claude/new-user-onboarding-phase-0 to main August 13, 2026 09:12
@gilgardosh
gilgardosh requested a lite review from Copilot August 13, 2026 09:32

Copilot AI 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.

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-flight viewer refetch when this component unmounts, leaving the cache stale and causing OnboardingGuard to bounce the user back to /welcome. Since the component already navigates when viewer.status becomes ACTIVE, 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 return emailVerified: true while email is null (email claim missing). The current condition treats that as EMAIL_UNVERIFIED, producing an inconsistent payload (status: EMAIL_UNVERIFIED with emailVerified: true) and misguiding the UI. Handle the cases separately: only return EMAIL_UNVERIFIED when emailVerified is false; when the email claim is missing but verified, return NO_WORKSPACE with an empty pendingInvitations list (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 claimInvitation mutation, 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
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 13, 2026 10:11 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 13, 2026 10:11 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🚀 Snapshot Release (alpha)

The latest changes of this PR are available as alpha on npm (based on the declared changesets):

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
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 13, 2026 14:05 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 13, 2026 14:05 — with GitHub Actions Inactive
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.

3 participants