Skip to content

feat(auth): route unprovisioned users to a welcome screen - #4190

Merged
gilgardosh merged 4 commits into
mainfrom
claude/onboarding-phase-0
Aug 12, 2026
Merged

feat(auth): route unprovisioned users to a welcome screen#4190
gilgardosh merged 4 commits into
mainfrom
claude/onboarding-phase-0

Conversation

@gilgardosh

Copy link
Copy Markdown
Collaborator

Standalone, code-only version of #4183, based directly on main with the planning docs left out. The code is byte-identical to what was reviewed there, review fixes included — verified by diffing the two patches. Supersedes #4183.

The bug

An Auth0 identity with no row in business_users resolves to a null auth context, so every @requiresAuth field throws UNAUTHENTICATED. That single code made three things go wrong at once on the client:

  • didAuthError classified it as a token failure and ran refreshAuth — which succeeds, because the token was never bad, so the operation fails identically on retry;
  • that path can open the interactive re-auth modal, asking the user to sign in again to reach the same state;
  • every failed operation raised an error toast, stacked behind an empty dashboard shell that ProtectedRoute happily rendered because Auth0 said "authenticated".

Net effect for a new signup: a broken app with no explanation and no way forward.

Server

  • @requiresAuth now separates the two cases. A verified JWT identity that maps to no membership throws ONBOARDING_REQUIRED; missing or invalid credentials keep UNAUTHENTICATED. The check reuses getJwtIdentity(), which already works without a business_users row, and that method is now memoized per operation — the directive calls it on every guarded field that fails to resolve a context, so an unprovisioned user's multi-field query would otherwise re-verify the same JWT once per field. It caches the promise, so concurrent field resolution collapses into one verification too.
  • New viewer query, deliberately outside @requiresAuth (same posture as acceptInvitation): it exists precisely to describe identities that have no auth context yet. It verifies the JWT itself and returns nothing beyond the caller's own claims — email, emailVerified, and a ViewerStatus of ACTIVE / EMAIL_UNVERIFIED / NO_WORKSPACE. Null when there are no valid credentials.
  • Membership takes precedence over email verification: a linked caller is ACTIVE even with an unverified email, because mapAuth0UserToLocal never consults email_verified and the API already serves that caller's data. Reporting otherwise would bounce a working account to /welcome while its queries kept succeeding.

Client

  • OnboardingGuard, composed into ProtectedRoute, redirects a non-ACTIVE viewer to /welcome. On a query error it renders the app rather than trapping the user — a network blip should not look like a missing workspace.
  • /welcome explains the invitation-only model (or asks for email verification), offers Check again and Sign out, and bounces back to the app if a membership appears out-of-band. When the viewer query fails it says the check failed instead of asserting "No workspace yet".
  • ONBOARDING_REQUIRED is exempted from the error toast; the screen is the message. No change was needed to didAuthError — it already matched UNAUTHENTICATED exactly — so a regression test now pins that.

Testing

  • yarn lint — 0 errors; client tsc clean; 374 unit tests pass, re-verified against current main (d6fa2d0) rather than the older base the stacked branch was cut from.
  • Not verified: the full server typecheck. It needs pgtyped SQL codegen, which needs Postgres, and no Docker daemon is available in this environment — yarn workspace @accounter/server build reports pre-existing errors from the missing __generated__/*.types.ts files, none in files this PR touches. CI will cover it.

Related


Generated by Claude Code

claude added 2 commits August 11, 2026 16:05
An Auth0 identity with no row in business_users resolved to a null auth
context, so every @requiresAuth field threw UNAUTHENTICATED. The client read
that as a token failure and ran refreshAuth (which succeeds, the token was
never bad), optionally prompting an interactive re-login, while every failed
operation raised a toast behind an empty dashboard.

Server:
- @requiresAuth now distinguishes the two cases: a verified JWT identity that
  maps to no membership throws ONBOARDING_REQUIRED instead of UNAUTHENTICATED.
  Missing or invalid credentials keep the old code.
- New unauthenticated-safe `viewer` query reporting the caller's provisioning
  state (ACTIVE / EMAIL_UNVERIFIED / NO_WORKSPACE) from its own JWT claims.

Client:
- OnboardingGuard, composed into ProtectedRoute, redirects a non-ACTIVE viewer
  to the new /welcome screen; a viewer query error renders the app instead of
  trapping the user there.
- /welcome explains the invitation-only model, offers re-check and sign-out,
  and returns to the app once a membership appears.
- ONBOARDING_REQUIRED no longer raises an error toast per failed operation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01344D5q1uyAHuwRJ3yyDX46
- Memoize getJwtIdentity per operation. The auth directives call it on every
  guarded field that fails to resolve a context, so an unprovisioned user's
  multi-field query re-verified the same JWT once per field. Caches the promise,
  so concurrent field resolution collapses into one verification too.
- Show an explicit "could not check your account" state on /welcome when the
  viewer query fails, instead of asserting "No workspace yet" on unknown state.
- Document ViewerStatus precedence: membership decides before email
  verification.

Also isolates the new getJwtIdentity tests on their own Auth0 domain — the
provider's JWKS cache is module-global and keyed by domain, so warming it made
an existing test that asserts createRemoteJWKSet was called depend on order.

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 11, 2026 16:10 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 11, 2026 16:10 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Aug 11, 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-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/gmail-listener 0.1.3-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/green-invoice-graphql 0.8.7-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/hashavshevet-mesh 0.2.13-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/israeli-vat-scraper 0.1.13-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/modern-poalim-scraper 0.10.7-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/payper-mesh 0.2.13-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/scraper-app 0.0.3-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/server 0.2.0-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/shaam-uniform-format-generator 0.2.7-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎
@accounter/shaam6111-generator 0.1.9-alpha-20260812072231-a20ada96eec7712a24a2b38ff60d41f76623ea3f npm ↗︎ unpkg ↗︎

@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 11, 2026 16:39 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 11, 2026 16:39 — with GitHub Actions Inactive

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

Adds a first-class “onboarding required” state for Auth0-authenticated users who have no business_users membership yet, routing them away from the main app shell and into a dedicated /welcome experience.

Changes:

  • Server: Introduces a viewer query (JWT-verified, unguarded) and updates auth directives to throw ONBOARDING_REQUIRED (vs UNAUTHENTICATED) when the JWT is valid but unlinked; adds per-operation memoization for JWT identity resolution.
  • Client: Adds OnboardingGuard to ProtectedRoute, a /welcome route + screen, a useViewer hook, and suppresses error toasts for ONBOARDING_REQUIRED.
  • Tests: Adds coverage for the new viewer states, directive error-code split, guard behavior, and toast suppression/regression.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/server/src/modules/common/typeDefs/viewer.graphql.ts Adds viewer query + ViewerStatus/Viewer schema.
packages/server/src/modules/common/resolvers/viewer.resolver.ts Implements viewer resolver behavior.
packages/server/src/modules/common/index.ts Registers viewer typeDefs/resolvers in common module.
packages/server/src/modules/common/tests/viewer.resolver.test.ts Unit tests for viewer resolver states.
packages/server/src/modules/auth/providers/auth-context.provider.ts Memoizes JWT identity per operation via getJwtIdentity().
packages/server/src/modules/auth/providers/tests/auth-context.test.ts Tests for getJwtIdentity() memoization + concurrency.
packages/server/src/modules/auth/directives/auth-directives.ts Splits unlinked-JWT case into ONBOARDING_REQUIRED.
packages/server/src/modules/auth/directives/tests/auth-directives.test.ts Tests for new directive error-code behavior.
packages/client/src/router/routes.ts Adds ROUTES.WELCOME.
packages/client/src/router/guards/auth-guards.tsx Composes OnboardingGuard into ProtectedRoute.
packages/client/src/router/config.tsx Adds /welcome route outside the protected app shell.
packages/client/src/providers/urql-error-handler.ts Suppresses toasts for ONBOARDING_REQUIRED.
packages/client/src/hooks/use-viewer.ts Adds useViewer() query hook for viewer state.
packages/client/src/components/screens/welcome.tsx Implements the welcome/onboarding screen UI + behaviors.
packages/client/src/components/screens/tests/welcome.test.ts Welcome page behavior tests.
packages/client/src/components/tests/protected-route.test.ts Tests OnboardingGuard redirect/loading/error behavior.
packages/client/src/tests/urql-error-handler.test.ts Tests toast suppression for ONBOARDING_REQUIRED.
packages/client/src/tests/urql-client.test.ts Pins didAuthError behavior for ONBOARDING_REQUIRED.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +19 to +23
export function WelcomePage(): ReactElement {
const { fetching, error, viewer } = useViewer();
const { isAuthenticated, isLoading } = useAuth0();
const handleLogout = useLogout();
const navigate = useNavigate();
Comment on lines +104 to +108
* Memoized for the operation: the auth directives call this on every guarded
* field that fails to resolve an auth context, so an unprovisioned user with a
* multi-field query would otherwise pay for one `jwtVerify` per field. Caching
* the promise (not the value) also collapses concurrent field resolution into a
* single verification. Safe because `rawAuth` is fixed for the operation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correct, and worth fixing — done in 8d558f2. handleJwtAuth now seeds this.jwtIdentity from the payload it has already verified, so the unprovisioned path costs one jwtVerify per request instead of two. The sub-less case seeds null for the same reason: getJwtIdentity() would reach that verdict anyway, so re-verifying to get there is pure waste.

One limit worth naming: the sharing is one-directional. getJwtIdentity() reuses getAuthContext()'s verification, but not the reverse — handleJwtAuth also needs exp and permissions, which the identity record doesn't carry, so having it consume the cache would mean widening that record or re-reading the payload. Since both call sites (the auth directives and the viewer resolver) go getAuthContext()getJwtIdentity(), the direction that pays is the one now covered.

I left the failure path alone deliberately: handleJwtAuth's catch also swallows DB errors from mapAuth0UserToLocal, and caching null there would wrongly suppress a perfectly valid identity when the database is the thing that's broken.

Two tests added — one asserting a single jwtVerify across getAuthContext() + getJwtIdentity() for an unlinked identity, one for the sub-less token.


Generated by Claude Code

- Reuse the JWT verification getAuthContext() already performed. The auth
  directives call it first and fall back to getJwtIdentity() only when it
  yields no user, so every unprovisioned request was verifying the same
  signature twice. handleJwtAuth now seeds the memoized identity, including
  the null verdict for a token with no sub claim.
- Pause the viewer query on /welcome until Auth0 resolves. The route is
  public, so an unauthenticated visitor was spending a request to learn
  nothing and waiting on it before being redirected to login; an
  authenticated one could also ask before their token was attached and be
  told they have no workspace.

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 12, 2026 07:20 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack August 12, 2026 07:20 — with GitHub Actions Inactive
@gilgardosh
gilgardosh merged commit 798c141 into main Aug 12, 2026
12 checks passed
@gilgardosh
gilgardosh deleted the claude/onboarding-phase-0 branch August 12, 2026 13:11
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