feat(auth): route unprovisioned users to a welcome screen - #4190
Conversation
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
🚀 Snapshot Release (
|
| 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 ↗︎ |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01344D5q1uyAHuwRJ3yyDX46
There was a problem hiding this comment.
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
viewerquery (JWT-verified, unguarded) and updates auth directives to throwONBOARDING_REQUIRED(vsUNAUTHENTICATED) when the JWT is valid but unlinked; adds per-operation memoization for JWT identity resolution. - Client: Adds
OnboardingGuardtoProtectedRoute, a/welcomeroute + screen, auseViewerhook, and suppresses error toasts forONBOARDING_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.
| export function WelcomePage(): ReactElement { | ||
| const { fetching, error, viewer } = useViewer(); | ||
| const { isAuthenticated, isLoading } = useAuth0(); | ||
| const handleLogout = useLogout(); | ||
| const navigate = useNavigate(); |
| * 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. |
There was a problem hiding this comment.
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
Standalone, code-only version of #4183, based directly on
mainwith 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_usersresolves to anullauth context, so every@requiresAuthfield throwsUNAUTHENTICATED. That single code made three things go wrong at once on the client:didAuthErrorclassified it as a token failure and ranrefreshAuth— which succeeds, because the token was never bad, so the operation fails identically on retry;ProtectedRoutehappily rendered because Auth0 said "authenticated".Net effect for a new signup: a broken app with no explanation and no way forward.
Server
@requiresAuthnow separates the two cases. A verified JWT identity that maps to no membership throwsONBOARDING_REQUIRED; missing or invalid credentials keepUNAUTHENTICATED. The check reusesgetJwtIdentity(), which already works without abusiness_usersrow, 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.viewerquery, deliberately outside@requiresAuth(same posture asacceptInvitation): 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 aViewerStatusofACTIVE/EMAIL_UNVERIFIED/NO_WORKSPACE. Null when there are no valid credentials.ACTIVEeven with an unverified email, becausemapAuth0UserToLocalnever consultsemail_verifiedand the API already serves that caller's data. Reporting otherwise would bounce a working account to/welcomewhile its queries kept succeeding.Client
OnboardingGuard, composed intoProtectedRoute, redirects a non-ACTIVEviewer 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./welcomeexplains 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_REQUIREDis exempted from the error toast; the screen is the message. No change was needed todidAuthError— it already matchedUNAUTHENTICATEDexactly — so a regression test now pins that.Testing
yarn lint— 0 errors; clienttscclean; 374 unit tests pass, re-verified against currentmain(d6fa2d0) rather than the older base the stacked branch was cut from.yarn workspace @accounter/server buildreports pre-existing errors from the missing__generated__/*.types.tsfiles, none in files this PR touches. CI will cover it.Related
claude/new-user-onboarding-phase-0branch; it needs retargeting once this merges.Generated by Claude Code