From 1645803ebbf3ff627c6738815630d5cfae68365b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 15:55:46 +0000 Subject: [PATCH 1/4] feat(auth): let users claim pending invitations by verified email 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 Claude-Session: https://claude.ai/code/session_01344D5q1uyAHuwRJ3yyDX46 --- eslint.config.mjs | 4 + .../screens/__tests__/welcome.test.ts | 111 +++++++- .../client/src/components/screens/welcome.tsx | 61 +++- .../client/src/hooks/use-claim-invitation.ts | 62 ++++ packages/client/src/hooks/use-viewer.ts | 16 +- packages/server/src/modules/auth/index.ts | 2 + .../accept-invitations.provider.test.ts | 77 +++++ .../providers/accept-invitations.provider.ts | 264 ++++++++++++------ .../providers/pending-invitations.provider.ts | 62 ++++ .../auth/resolvers/invitations.resolver.ts | 29 ++ .../src/modules/auth/typeDefs/auth.graphql.ts | 2 + .../common/__tests__/viewer.resolver.test.ts | 65 ++++- .../common/resolvers/viewer.resolver.ts | 24 +- .../modules/common/typeDefs/viewer.graphql.ts | 11 + 14 files changed, 682 insertions(+), 108 deletions(-) create mode 100644 packages/client/src/hooks/use-claim-invitation.ts create mode 100644 packages/server/src/modules/auth/providers/pending-invitations.provider.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index e7a556a898..594b9cab9f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -331,6 +331,10 @@ export default [ // Exempt global/auth providers that need direct DB access for RLS bypass to function 'packages/server/src/modules/auth/providers/auth-context.provider.ts', 'packages/server/src/modules/auth/providers/accept-invitations.provider.ts', + // pending-invitations.provider.ts serves callers with no membership at all, + // so TenantAwareDBClient would throw UNAUTHENTICATED. Isolation comes from + // filtering on an identity-provider-verified email, never a client-supplied one. + 'packages/server/src/modules/auth/providers/pending-invitations.provider.ts', 'packages/server/src/modules/business-trips/providers/business-trips-tax-variables.provider.ts', 'packages/server/src/modules/countries/providers/countries.provider.ts', 'packages/server/src/modules/depreciation/providers/depreciation-categories.provider.ts', diff --git a/packages/client/src/components/screens/__tests__/welcome.test.ts b/packages/client/src/components/screens/__tests__/welcome.test.ts index 4278be5cd7..cab242c483 100644 --- a/packages/client/src/components/screens/__tests__/welcome.test.ts +++ b/packages/client/src/components/screens/__tests__/welcome.test.ts @@ -8,11 +8,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ROUTES } from '../../../router/routes.js'; import { WelcomePage } from '../welcome.js'; -const { useAuth0Mock, useViewerMock, useLogoutMock } = vi.hoisted(() => ({ - useAuth0Mock: vi.fn(), - useViewerMock: vi.fn(), - useLogoutMock: vi.fn(), -})); +const { useAuth0Mock, useViewerMock, useLogoutMock, claimInvitationMock, refreshViewerMock } = + vi.hoisted(() => ({ + useAuth0Mock: vi.fn(), + useViewerMock: vi.fn(), + useLogoutMock: vi.fn(), + claimInvitationMock: vi.fn(), + refreshViewerMock: vi.fn(), + })); vi.mock('@auth0/auth0-react', () => ({ useAuth0: useAuth0Mock, @@ -26,6 +29,14 @@ vi.mock('../../../hooks/use-logout.js', () => ({ useLogout: useLogoutMock, })); +vi.mock('../../../hooks/use-claim-invitation.js', () => ({ + useClaimInvitation: () => ({ + fetching: false, + error: undefined, + claimInvitation: claimInvitationMock, + }), +})); + ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -37,7 +48,7 @@ async function renderWelcome( authState = { isAuthenticated: true, isLoading: false }, ) { useAuth0Mock.mockReturnValue(authState); - useViewerMock.mockReturnValue(viewerState); + useViewerMock.mockReturnValue({ refreshViewer: refreshViewerMock, ...viewerState }); useLogoutMock.mockReturnValue(vi.fn()); const router = createMemoryRouter( @@ -69,7 +80,7 @@ async function renderWelcome( container.remove(); }; - return { html, router, cleanup }; + return { html, container, router, cleanup }; } describe('WelcomePage', () => { @@ -81,7 +92,12 @@ describe('WelcomePage', () => { const { html, cleanup } = await renderWelcome({ fetching: false, error: undefined, - viewer: { email: 'new@example.com', emailVerified: true, status: 'NO_WORKSPACE' }, + viewer: { + email: 'new@example.com', + emailVerified: true, + status: 'NO_WORKSPACE', + pendingInvitations: [], + }, }); expect(html).toContain('No workspace yet'); @@ -93,7 +109,12 @@ describe('WelcomePage', () => { const { html, cleanup } = await renderWelcome({ fetching: false, error: undefined, - viewer: { email: 'new@example.com', emailVerified: false, status: 'EMAIL_UNVERIFIED' }, + viewer: { + email: 'new@example.com', + emailVerified: false, + status: 'EMAIL_UNVERIFIED', + pendingInvitations: [], + }, }); expect(html).toContain('Verify your email'); @@ -136,11 +157,81 @@ describe('WelcomePage', () => { await cleanup(); }); + it('offers a waiting invitation instead of the dead-end copy', async () => { + const { html, cleanup } = await renderWelcome({ + fetching: false, + error: undefined, + viewer: { + email: 'new@example.com', + emailVerified: true, + status: 'NO_WORKSPACE', + pendingInvitations: [ + { + id: 'inv-1', + businessId: 'biz-1', + businessName: 'Acme Ltd', + role: 'employee', + expiresAt: '2030-01-01T00:00:00.000Z', + }, + ], + }, + }); + + expect(html).toContain("You've been invited"); + expect(html).toContain('Acme Ltd'); + expect(html).toContain('Accept'); + expect(html).not.toContain('invitation-only'); + await cleanup(); + }); + + it('claims the invitation and returns to the app', async () => { + claimInvitationMock.mockResolvedValue({ success: true, businessId: 'biz-1' }); + + const { container, router, cleanup } = await renderWelcome({ + fetching: false, + error: undefined, + viewer: { + email: 'new@example.com', + emailVerified: true, + status: 'NO_WORKSPACE', + pendingInvitations: [ + { + id: 'inv-1', + businessId: 'biz-1', + businessName: 'Acme Ltd', + role: 'employee', + expiresAt: '2030-01-01T00:00:00.000Z', + }, + ], + }, + }); + + const acceptButton = [...container.querySelectorAll('button')].find( + button => button.textContent === 'Accept', + ); + await act(async () => { + acceptButton?.click(); + await Promise.resolve(); + }); + + expect(claimInvitationMock).toHaveBeenCalledWith('inv-1'); + // The membership only exists server-side, so the viewer must be re-read + // before navigating or the guard would bounce us straight back here. + expect(refreshViewerMock).toHaveBeenCalled(); + expect(router.state.location.pathname).toBe(ROUTES.HOME); + await cleanup(); + }); + it('returns an active viewer to the app', async () => { const { router, cleanup } = await renderWelcome({ fetching: false, error: undefined, - viewer: { email: 'member@example.com', emailVerified: true, status: 'ACTIVE' }, + viewer: { + email: 'member@example.com', + emailVerified: true, + status: 'ACTIVE', + pendingInvitations: [], + }, }); expect(router.state.location.pathname).toBe(ROUTES.HOME); diff --git a/packages/client/src/components/screens/welcome.tsx b/packages/client/src/components/screens/welcome.tsx index 13d6507fd2..05733bc6cc 100644 --- a/packages/client/src/components/screens/welcome.tsx +++ b/packages/client/src/components/screens/welcome.tsx @@ -2,6 +2,7 @@ import { useEffect, type ReactElement } from 'react'; import { Loader2 } from 'lucide-react'; import { Navigate, useNavigate } from 'react-router-dom'; import { useAuth0 } from '@auth0/auth0-react'; +import { useClaimInvitation } from '../../hooks/use-claim-invitation.js'; import { useLogout } from '../../hooks/use-logout.js'; import { useViewer } from '../../hooks/use-viewer.js'; import { ROUTES } from '../../router/routes.js'; @@ -15,6 +16,9 @@ import { Button } from '../ui/button.js'; * who signed up directly, whose invitation expired before they accepted, or who * was removed from their last business. It replaces what used to be an empty * dashboard buried under failing queries. + * + * When an invitation is waiting for the caller's verified email, this is also + * where they claim it — the emailed link is no longer the only way in. */ export function WelcomePage(): ReactElement { const { isAuthenticated, isLoading } = useAuth0(); @@ -22,7 +26,10 @@ export function WelcomePage(): ReactElement { // Auth0 keeps an unauthenticated visitor from spending a request to learn // nothing, and keeps an authenticated one from asking before their token is // attached — which would answer "no workspace" for a perfectly good account. - const { fetching, error, viewer } = useViewer({ pause: isLoading || !isAuthenticated }); + const { fetching, error, viewer, refreshViewer } = useViewer({ + pause: isLoading || !isAuthenticated, + }); + const { fetching: claiming, claimInvitation } = useClaimInvitation(); const handleLogout = useLogout(); const navigate = useNavigate(); @@ -71,13 +78,28 @@ export function WelcomePage(): ReactElement { } const isEmailUnverified = viewer?.status === 'EMAIL_UNVERIFIED'; + const pendingInvitations = viewer?.pendingInvitations ?? []; + + const handleClaim = async (invitationId: string) => { + const result = await claimInvitation(invitationId); + 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 }); + } + }; return (

- {isEmailUnverified ? 'Verify your email' : 'No workspace yet'} + {isEmailUnverified + ? 'Verify your email' + : pendingInvitations.length > 0 + ? "You've been invited" + : 'No workspace yet'}

{viewer?.email ? (

Signed in as {viewer.email}

@@ -89,6 +111,34 @@ export function WelcomePage(): ReactElement { We sent a verification link to your email address. Open it, then reload this page to continue.

+ ) : pendingInvitations.length > 0 ? ( + <> +

+ {pendingInvitations.length === 1 + ? 'An invitation is waiting for your email address. Accept it to get started.' + : 'These invitations are waiting for your email address. Accept one to get started.'} +

+
    + {pendingInvitations.map(invitation => ( +
  • +
    +

    {invitation.businessName ?? 'Unnamed business'}

    +

    as {invitation.role}

    +
    + +
  • + ))} +
+ ) : (

Accounter is invitation-only. Your account is not linked to any business yet — ask an @@ -98,7 +148,12 @@ export function WelcomePage(): ReactElement { )}

- + diff --git a/packages/client/src/hooks/use-claim-invitation.ts b/packages/client/src/hooks/use-claim-invitation.ts new file mode 100644 index 0000000000..7e5c47e3c0 --- /dev/null +++ b/packages/client/src/hooks/use-claim-invitation.ts @@ -0,0 +1,62 @@ +import { useCallback } from 'react'; +import { toast } from 'sonner'; +import { useMutation, type CombinedError } from 'urql'; +import { ClaimInvitationDocument, type ClaimInvitationMutation } from '../gql/graphql.js'; +import { handleCommonErrors } from '../helpers/error-handling.js'; + +// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- used by codegen +/* GraphQL */ ` + mutation ClaimInvitation($invitationId: UUID!) { + claimInvitation(invitationId: $invitationId) { + success + businessId + roleId + } + } +`; + +type UseClaimInvitation = { + fetching: boolean; + error: CombinedError | undefined; + claimInvitation: ( + invitationId: string, + ) => Promise; +}; + +const NOTIFICATION_ID = 'claimInvitation'; + +export const useClaimInvitation = (): UseClaimInvitation => { + const [{ fetching, error }, mutate] = useMutation(ClaimInvitationDocument); + const claimInvitation = useCallback( + async (invitationId: string) => { + const message = 'Error joining business'; + const notificationId = NOTIFICATION_ID; + toast.loading('Joining business', { + id: notificationId, + }); + try { + const result = await mutate({ invitationId }); + const data = handleCommonErrors(result, message, notificationId); + if (data) { + toast.success('Success', { + id: notificationId, + description: 'Invitation accepted successfully', + }); + return data.claimInvitation; + } + } catch (e) { + console.error(message, e); + toast.error('Error', { + id: notificationId, + description: message, + duration: 10_000, + closeButton: true, + }); + } + return void 0; + }, + [mutate], + ); + + return { fetching, error, claimInvitation }; +}; diff --git a/packages/client/src/hooks/use-viewer.ts b/packages/client/src/hooks/use-viewer.ts index ae183f5e47..361a9d0802 100644 --- a/packages/client/src/hooks/use-viewer.ts +++ b/packages/client/src/hooks/use-viewer.ts @@ -8,6 +8,13 @@ import { ViewerDocument, type ViewerQuery } from '../gql/graphql.js'; email emailVerified status + pendingInvitations { + id + businessId + businessName + role + expiresAt + } } } `; @@ -16,6 +23,7 @@ type UseViewer = { fetching: boolean; error: CombinedError | undefined; viewer: ViewerQuery['viewer']; + refreshViewer: () => void; }; /** @@ -25,10 +33,14 @@ type UseViewer = { * still answers for a user who is logged in to Auth0 but linked to no business. */ export const useViewer = (options?: { pause?: boolean }): UseViewer => { - const [{ data, fetching, error }] = useQuery({ + const [{ data, fetching, error }, reexecuteQuery] = useQuery({ query: ViewerDocument, pause: options?.pause ?? false, }); - return { fetching, error, viewer: data?.viewer ?? null }; + // Claiming an invitation changes the caller's provisioning state, so the + // result has to come from the server rather than the cache. + const refreshViewer = () => reexecuteQuery({ requestPolicy: 'network-only' }); + + return { fetching, error, viewer: data?.viewer ?? null, refreshViewer }; }; diff --git a/packages/server/src/modules/auth/index.ts b/packages/server/src/modules/auth/index.ts index 3acfe568de..8dd2f7b0b2 100644 --- a/packages/server/src/modules/auth/index.ts +++ b/packages/server/src/modules/auth/index.ts @@ -5,6 +5,7 @@ import { Auth0ManagementProvider } from './providers/auth0-management.provider.j import { AuthorizationProvider } from './providers/authorization.provider.js'; import { BusinessUsersProvider } from './providers/business-users.provider.js'; import { InvitationsProvider } from './providers/invitations.provider.js'; +import { PendingInvitationsProvider } from './providers/pending-invitations.provider.js'; import { ScopeProvider } from './providers/scope.provider.js'; import { SuperAdminProvider } from './providers/super-admin.provider.js'; import { apiKeysResolvers } from './resolvers/api-keys.resolver.js'; @@ -28,6 +29,7 @@ export const authModule = createModule({ ApiKeysProvider, BusinessUsersProvider, InvitationsProvider, + PendingInvitationsProvider, ScopeProvider, SuperAdminProvider, ], diff --git a/packages/server/src/modules/auth/providers/__tests__/accept-invitations.provider.test.ts b/packages/server/src/modules/auth/providers/__tests__/accept-invitations.provider.test.ts index cf9de4e774..20c51fa21e 100644 --- a/packages/server/src/modules/auth/providers/__tests__/accept-invitations.provider.test.ts +++ b/packages/server/src/modules/auth/providers/__tests__/accept-invitations.provider.test.ts @@ -6,6 +6,7 @@ const pgTypedRuntimeMock = vi.hoisted(() => { const runMocks = { updateInvitationAcceptanceRun: vi.fn(), getInvitationForAcceptanceRun: vi.fn(), + getInvitationByIdForAcceptanceRun: vi.fn(), getInvitationByTokenRun: vi.fn(), getUserIdByAuth0UserIdRun: vi.fn(), insertAcceptedBusinessUserRun: vi.fn(), @@ -19,6 +20,9 @@ const pgTypedRuntimeMock = vi.hoisted(() => { if (query.includes('SET accepted_at = NOW()')) { return { run: runMocks.updateInvitationAcceptanceRun }; } + if (query.includes('WHERE id = $id') && query.includes('FOR UPDATE')) { + return { run: runMocks.getInvitationByIdForAcceptanceRun }; + } if (query.includes('AND accepted_at IS NULL') && query.includes('FOR UPDATE')) { return { run: runMocks.getInvitationForAcceptanceRun }; } @@ -74,6 +78,7 @@ import { AcceptInvitationsProvider } from '../accept-invitations.provider.js'; const [ updateInvitationAcceptanceRun, getInvitationForAcceptanceRun, + getInvitationByIdForAcceptanceRun, getInvitationByTokenRun, getUserIdByAuth0UserIdRun, insertAcceptedBusinessUserRun, @@ -81,6 +86,7 @@ const [ ] = [ pgTypedRuntimeMock.runMocks.updateInvitationAcceptanceRun, pgTypedRuntimeMock.runMocks.getInvitationForAcceptanceRun, + pgTypedRuntimeMock.runMocks.getInvitationByIdForAcceptanceRun, pgTypedRuntimeMock.runMocks.getInvitationByTokenRun, pgTypedRuntimeMock.runMocks.getUserIdByAuth0UserIdRun, pgTypedRuntimeMock.runMocks.insertAcceptedBusinessUserRun, @@ -314,4 +320,75 @@ describe('AcceptInvitationsProvider', () => { expect(auth0ManagementProvider.deleteUser).not.toHaveBeenCalled(); expect(updateInvitationAcceptanceRun).not.toHaveBeenCalled(); }); + + describe('claimInvitation', () => { + const verifiedIdentity = { + auth0UserId: 'auth0|caller', + email: 'invitee@example.com', + emailVerified: true, + }; + + it('accepts an invitation matched by verified email, without a token', async () => { + getInvitationByIdForAcceptanceRun.mockResolvedValue([activeInvitation()]); + getUserIdByAuth0UserIdRun.mockResolvedValue([{ user_id: 'existing-user' }]); + insertAcceptedBusinessUserRun.mockResolvedValue([]); + updateInvitationAcceptanceRun.mockResolvedValue([]); + + const result = await provider.claimInvitation('inv-1', verifiedIdentity); + + expect(result).toEqual({ success: true, businessId: 'business-1', roleId: 'employee' }); + expect(getInvitationByIdForAcceptanceRun).toHaveBeenCalledWith({ id: 'inv-1' }, dbClient); + // The token lookup must not be involved — tokens are hashed and unavailable here. + expect(getInvitationForAcceptanceRun).not.toHaveBeenCalled(); + expect(dbClient.query).toHaveBeenNthCalledWith(2, 'COMMIT'); + expect(updateInvitationAcceptanceRun).toHaveBeenCalledWith({ id: 'inv-1' }, dbClient); + }); + + it('refuses an unverified email outright', async () => { + // The verified email is the only proof of ownership on this path, so an + // unverified one must not even reach the lookup. + await expect( + provider.claimInvitation('inv-1', { ...verifiedIdentity, emailVerified: false }), + ).rejects.toMatchObject({ extensions: { code: 'TOKEN_INVALID' } }); + + expect(dbProvider.pool.connect).not.toHaveBeenCalled(); + expect(getInvitationByIdForAcceptanceRun).not.toHaveBeenCalled(); + }); + + it('refuses an identity with no email claim', async () => { + await expect( + provider.claimInvitation('inv-1', { ...verifiedIdentity, email: null }), + ).rejects.toMatchObject({ extensions: { code: 'TOKEN_INVALID' } }); + + expect(dbProvider.pool.connect).not.toHaveBeenCalled(); + }); + + it('refuses an invitation addressed to somebody else', async () => { + getInvitationByIdForAcceptanceRun.mockResolvedValue([ + activeInvitation({ email: 'somebody-else@example.com' }), + ]); + + await expect(provider.claimInvitation('inv-1', verifiedIdentity)).rejects.toMatchObject({ + extensions: { code: 'TOKEN_INVALID' }, + }); + + expect(dbClient.query).toHaveBeenNthCalledWith(2, 'ROLLBACK'); + expect(updateInvitationAcceptanceRun).not.toHaveBeenCalled(); + expect(insertAcceptedBusinessUserRun).not.toHaveBeenCalled(); + expect(updateBusinessUserAuth0IdRun).not.toHaveBeenCalled(); + }); + + it('reports accepted, expired and unknown invitations identically', async () => { + // The id is the only input, so distinguishing these would let a caller + // probe for which invitation ids exist. + getInvitationByIdForAcceptanceRun.mockResolvedValue([]); + + await expect(provider.claimInvitation('inv-gone', verifiedIdentity)).rejects.toMatchObject({ + extensions: { code: 'TOKEN_INVALID' }, + }); + + expect(dbClient.query).toHaveBeenNthCalledWith(2, 'ROLLBACK'); + expect(updateInvitationAcceptanceRun).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/packages/server/src/modules/auth/providers/accept-invitations.provider.ts b/packages/server/src/modules/auth/providers/accept-invitations.provider.ts index 4fb5c6fde3..09df89855c 100644 --- a/packages/server/src/modules/auth/providers/accept-invitations.provider.ts +++ b/packages/server/src/modules/auth/providers/accept-invitations.provider.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { Injectable, Scope } from 'graphql-modules'; +import type { PoolClient } from 'pg'; import { sql } from '@pgtyped/runtime'; import { DBProvider } from '../../app-providers/db.provider.js'; import { AuditLogsProvider } from '../../common/providers/audit-logs.provider.js'; @@ -10,8 +11,10 @@ import { mapAuth0Error, } from '../helpers/invitations.helper.js'; import type { + IGetInvitationByIdForAcceptanceQuery, IGetInvitationByTokenQuery, IGetInvitationForAcceptanceQuery, + IGetInvitationForAcceptanceResult, IGetUserIdByAuth0UserIdQuery, IInsertAcceptedBusinessUserQuery, IUpdateBusinessUserAuth0IdQuery, @@ -40,6 +43,15 @@ const getInvitationByToken = sql` WHERE token_hash = $tokenHash; `; +const getInvitationByIdForAcceptance = sql` + SELECT id, user_id, business_id, role_id, email, auth0_user_id, accepted_at, expires_at + FROM accounter_schema.invitations + WHERE id = $id + AND accepted_at IS NULL + AND expires_at > NOW() + FOR UPDATE; +`; + const getUserIdByAuth0UserId = sql` SELECT user_id FROM accounter_schema.business_users @@ -103,120 +115,194 @@ export class AcceptInvitationsProvider { throw invalidTokenError(); } - const invitation = activeInvitationResult[0]; - const effectiveAuth0UserId = auth0UserId ?? invitation.auth0_user_id; + const result = await this.finalizeAcceptance( + client, + activeInvitationResult[0], + auth0UserId, + authenticatedUserEmail, + ); + + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK').catch(() => null); + throw error; + } finally { + client.release(); + } + } + + /** + * Accept an invitation the caller was matched to by verified email, without + * the emailed token. + * + * Invitation tokens are stored hashed, so a listed invitation cannot be turned + * back into its token — this is the lookup for a caller who never had (or lost) + * the link. Where `acceptInvitation` treats possession of the token as the + * proof, here the *only* proof is the verified email, so the caller's identity + * must carry a verified address and it must match the invitation's. The shared + * check in `finalizeAcceptance` enforces the match; requiring verification is + * this method's job. + */ + public async claimInvitation( + invitationId: string, + identity: { auth0UserId: string; email: string | null; emailVerified: boolean }, + ) { + if (!identity.emailVerified || !identity.email) { + throw invalidTokenError(); + } - if (!effectiveAuth0UserId) { + const client = await this.dbProvider.pool.connect(); + + try { + await client.query('BEGIN'); + + const invitationResult = await getInvitationByIdForAcceptance.run( + { id: invitationId }, + client, + ); + + if (invitationResult.length === 0) { + // Pending, unexpired invitations are the only claimable ones. Anything + // else (accepted, expired, unknown id) is reported identically so the + // mutation cannot be used to probe for invitation ids. throw invalidTokenError(); } - // Defense in depth: authenticated users can claim only invitations for their own email. - if (auth0UserId) { - const normalizedInvitationEmail = invitation.email?.trim().toLowerCase(); - let normalizedAuthenticatedEmail = authenticatedUserEmail?.trim().toLowerCase() ?? null; - - // Access tokens for custom APIs often omit `email` claims. - // In that case, resolve the primary email from Auth0 profile for comparison. - if (!normalizedAuthenticatedEmail) { - const identity = await this.auth0ManagementProvider.getUserEmailById(auth0UserId); - if (identity?.emailVerified && identity.email) { - normalizedAuthenticatedEmail = identity.email.trim().toLowerCase(); - } - } + const result = await this.finalizeAcceptance( + client, + invitationResult[0], + identity.auth0UserId, + identity.email, + ); - if ( - !normalizedInvitationEmail || - !normalizedAuthenticatedEmail || - normalizedInvitationEmail !== normalizedAuthenticatedEmail - ) { - throw invalidTokenError(); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK').catch(() => null); + throw error; + } finally { + client.release(); + } + } + + /** + * Shared tail of both acceptance paths: verify the claimant, link the user to + * the business, clean up the pre-registered Auth0 user, and mark the + * invitation accepted. Runs inside the caller's transaction; the caller + * commits. + */ + private async finalizeAcceptance( + client: PoolClient, + invitation: IGetInvitationForAcceptanceResult, + auth0UserId: string | null, + authenticatedUserEmail: string | null, + ) { + const effectiveAuth0UserId = auth0UserId ?? invitation.auth0_user_id; + + if (!effectiveAuth0UserId) { + throw invalidTokenError(); + } + + // Defense in depth: authenticated users can claim only invitations for their own email. + if (auth0UserId) { + const normalizedInvitationEmail = invitation.email?.trim().toLowerCase(); + let normalizedAuthenticatedEmail = authenticatedUserEmail?.trim().toLowerCase() ?? null; + + // Access tokens for custom APIs often omit `email` claims. + // In that case, resolve the primary email from Auth0 profile for comparison. + if (!normalizedAuthenticatedEmail) { + const identity = await this.auth0ManagementProvider.getUserEmailById(auth0UserId); + if (identity?.emailVerified && identity.email) { + normalizedAuthenticatedEmail = identity.email.trim().toLowerCase(); } } - if (!invitation.user_id) { + if ( + !normalizedInvitationEmail || + !normalizedAuthenticatedEmail || + normalizedInvitationEmail !== normalizedAuthenticatedEmail + ) { throw invalidTokenError(); } + } + + if (!invitation.user_id) { + throw invalidTokenError(); + } + + const assignAuth0UserToInvitedUser = async () => { + await updateBusinessUserAuth0Id.run( + { + auth0UserId: effectiveAuth0UserId, + userId: invitation.user_id, + ownerId: invitation.business_id, + }, + client, + ); + }; + + let userId: string; + + if (auth0UserId) { + const existingUserResult = await getUserIdByAuth0UserId.run({ auth0UserId }, client); - const assignAuth0UserToInvitedUser = async () => { - await updateBusinessUserAuth0Id.run( + if (existingUserResult.length > 0) { + userId = existingUserResult[0].user_id; + + await insertAcceptedBusinessUser.run( { + userId, auth0UserId: effectiveAuth0UserId, - userId: invitation.user_id, ownerId: invitation.business_id, + roleId: invitation.role_id, }, client, ); - }; - - let userId: string; - - if (auth0UserId) { - const existingUserResult = await getUserIdByAuth0UserId.run({ auth0UserId }, client); - - if (existingUserResult.length > 0) { - userId = existingUserResult[0].user_id; - - await insertAcceptedBusinessUser.run( - { - userId, - auth0UserId: effectiveAuth0UserId, - ownerId: invitation.business_id, - roleId: invitation.role_id, - }, - client, - ); - } else { - userId = invitation.user_id; - await assignAuth0UserToInvitedUser(); - } } else { userId = invitation.user_id; await assignAuth0UserToInvitedUser(); } + } else { + userId = invitation.user_id; + await assignAuth0UserToInvitedUser(); + } - if (invitation.auth0_user_id) { - try { - if (auth0UserId && auth0UserId !== invitation.auth0_user_id) { - await this.auth0ManagementProvider.deleteUser(invitation.auth0_user_id); - } else { - await this.auth0ManagementProvider.unblockUser(invitation.auth0_user_id); - } - } catch (error) { - throw mapAuth0Error(error); + if (invitation.auth0_user_id) { + try { + if (auth0UserId && auth0UserId !== invitation.auth0_user_id) { + await this.auth0ManagementProvider.deleteUser(invitation.auth0_user_id); + } else { + await this.auth0ManagementProvider.unblockUser(invitation.auth0_user_id); } + } catch (error) { + throw mapAuth0Error(error); } + } - await updateInvitationAcceptance.run({ id: invitation.id }, client); - - await this.auditLogsProvider.log( - { - ownerId: invitation.business_id, - userId, - auth0UserId: effectiveAuth0UserId, - action: 'INVITATION_ACCEPTED', - entity: 'Invitation', - entityId: invitation.id, - details: { - auth0_user_id: effectiveAuth0UserId, - business_id: invitation.business_id, - role_id: invitation.role_id, - }, + await updateInvitationAcceptance.run({ id: invitation.id }, client); + + await this.auditLogsProvider.log( + { + ownerId: invitation.business_id, + userId, + auth0UserId: effectiveAuth0UserId, + action: 'INVITATION_ACCEPTED', + entity: 'Invitation', + entityId: invitation.id, + details: { + auth0_user_id: effectiveAuth0UserId, + business_id: invitation.business_id, + role_id: invitation.role_id, }, - client, - ); - - await client.query('COMMIT'); - - return { - success: true, - businessId: invitation.business_id, - roleId: invitation.role_id, - }; - } catch (error) { - await client.query('ROLLBACK').catch(() => null); - throw error; - } finally { - client.release(); - } + }, + client, + ); + + return { + success: true, + businessId: invitation.business_id, + roleId: invitation.role_id, + }; } } diff --git a/packages/server/src/modules/auth/providers/pending-invitations.provider.ts b/packages/server/src/modules/auth/providers/pending-invitations.provider.ts new file mode 100644 index 0000000000..8dd96f8842 --- /dev/null +++ b/packages/server/src/modules/auth/providers/pending-invitations.provider.ts @@ -0,0 +1,62 @@ +import { Injectable, Scope } from 'graphql-modules'; +import { DBProvider } from '../../app-providers/db.provider.js'; + +export type PendingInvitation = { + id: string; + businessId: string; + businessName: string | null; + role: string; + expiresAt: Date; +}; + +/** + * Lists the invitations waiting for a caller who has no membership yet. + * + * Deliberately uses the raw pool rather than `TenantAwareDBClient`: the callers + * this exists for have no tenant at all, so the tenant-scoped client would throw + * UNAUTHENTICATED. That makes this a privileged read, and the only thing keeping + * it safe is the email filter — so every entry point must pass an address the + * identity provider has verified, never one supplied by the client. + */ +@Injectable({ + scope: Scope.Operation, + global: true, +}) +export class PendingInvitationsProvider { + constructor(private dbProvider: DBProvider) {} + + public async getPendingInvitationsByVerifiedEmail( + verifiedEmail: string, + ): Promise { + const normalizedEmail = verifiedEmail.trim().toLowerCase(); + if (!normalizedEmail) { + return []; + } + + const { rows } = await this.dbProvider.query<{ + id: string; + business_id: string; + business_name: string | null; + role_id: string; + expires_at: Date; + }>( + `SELECT i.id, i.business_id, fe.name AS business_name, i.role_id, i.expires_at + FROM accounter_schema.invitations i + LEFT JOIN accounter_schema.financial_entities fe + ON fe.id = i.business_id + WHERE LOWER(i.email) = $1 + AND i.accepted_at IS NULL + AND i.expires_at > NOW() + ORDER BY i.created_at DESC`, + [normalizedEmail], + ); + + return rows.map(row => ({ + id: row.id, + businessId: row.business_id, + businessName: row.business_name, + role: row.role_id, + expiresAt: row.expires_at, + })); + } +} diff --git a/packages/server/src/modules/auth/resolvers/invitations.resolver.ts b/packages/server/src/modules/auth/resolvers/invitations.resolver.ts index cbeaea5c26..cd6ac19542 100644 --- a/packages/server/src/modules/auth/resolvers/invitations.resolver.ts +++ b/packages/server/src/modules/auth/resolvers/invitations.resolver.ts @@ -118,6 +118,35 @@ export const invitationsResolvers: AuthModule.Resolvers = { }); } }, + claimInvitation: async (_, { invitationId }, { injector }) => { + const authContextProvider = injector.get(AuthContextProvider); + + // Read the identity straight from the verified token rather than from an + // auth context: the callers this exists for have no membership, so they + // have no auth context at all. A caller who already has one still works — + // their token carries the same claims. + const identity = await authContextProvider.getJwtIdentity(); + + if (!identity) { + throw new GraphQLError('Authentication required', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } + + try { + return await injector + .get(AcceptInvitationsProvider) + .claimInvitation(invitationId, identity); + } catch (error) { + if (error instanceof GraphQLError) { + throw error; + } + + throw new GraphQLError('Failed to accept invitation', { + extensions: { code: 'INVITATION_ACCEPT_FAILED' }, + }); + } + }, revokeInvitation: async (_, { id }, { injector }) => { try { return await injector.get(InvitationsProvider).revokeInvitation(id); diff --git a/packages/server/src/modules/auth/typeDefs/auth.graphql.ts b/packages/server/src/modules/auth/typeDefs/auth.graphql.ts index 45f33d86c8..539b7c17cf 100644 --- a/packages/server/src/modules/auth/typeDefs/auth.graphql.ts +++ b/packages/server/src/modules/auth/typeDefs/auth.graphql.ts @@ -30,6 +30,8 @@ export default gql` createInvitation(email: String!, roleId: String!): InvitationPayload! @requiresRole(role: "business_owner") acceptInvitation(token: String!): AcceptInvitationPayload! + " 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! generateApiKey(name: String!, roleId: String!): GenerateApiKeyPayload! @requiresRole(role: "business_owner") revokeApiKey(id: ID!): Boolean! @requiresRole(role: "business_owner") diff --git a/packages/server/src/modules/common/__tests__/viewer.resolver.test.ts b/packages/server/src/modules/common/__tests__/viewer.resolver.test.ts index 6f428cd775..1c0b9122b1 100644 --- a/packages/server/src/modules/common/__tests__/viewer.resolver.test.ts +++ b/packages/server/src/modules/common/__tests__/viewer.resolver.test.ts @@ -1,16 +1,31 @@ import { describe, expect, it, vi } from 'vitest'; import { AuthContextProvider } from '../../auth/providers/auth-context.provider.js'; +import { PendingInvitationsProvider } from '../../auth/providers/pending-invitations.provider.js'; import { viewerResolvers } from '../resolvers/viewer.resolver.js'; type AuthContextValue = Awaited>; type JwtIdentity = Awaited>; +type PendingInvitation = Awaited< + ReturnType +>[number]; -async function runResolver(authContext: AuthContextValue, jwtIdentity: JwtIdentity) { +async function runResolver( + authContext: AuthContextValue, + jwtIdentity: JwtIdentity, + pendingInvitations: PendingInvitation[] = [], +) { const authProvider = { getAuthContext: vi.fn().mockResolvedValue(authContext), getJwtIdentity: vi.fn().mockResolvedValue(jwtIdentity), }; - const injector = { get: vi.fn(() => authProvider) }; + const pendingInvitationsProvider = { + getPendingInvitationsByVerifiedEmail: vi.fn().mockResolvedValue(pendingInvitations), + }; + const injector = { + get: vi.fn((token: unknown) => + token === PendingInvitationsProvider ? pendingInvitationsProvider : authProvider, + ), + }; const resolver = viewerResolvers.Query!.viewer as unknown as ( parent: unknown, args: unknown, @@ -21,6 +36,7 @@ async function runResolver(authContext: AuthContextValue, jwtIdentity: JwtIdenti return { result: await resolver(undefined, undefined, { injector }, undefined), authProvider, + pendingInvitationsProvider, }; } @@ -37,6 +53,7 @@ describe('viewer resolver', () => { email: 'member@example.com', emailVerified: true, status: 'ACTIVE', + pendingInvitations: [], }); // An active member must not pay for a second JWT verification. expect(authProvider.getJwtIdentity).not.toHaveBeenCalled(); @@ -53,6 +70,7 @@ describe('viewer resolver', () => { email: 'new@example.com', emailVerified: true, status: 'NO_WORKSPACE', + pendingInvitations: [], }); }); @@ -67,6 +85,7 @@ describe('viewer resolver', () => { email: 'new@example.com', emailVerified: false, status: 'EMAIL_UNVERIFIED', + pendingInvitations: [], }); }); @@ -85,6 +104,48 @@ describe('viewer resolver', () => { expect(result).toMatchObject({ status: 'ACTIVE', emailVerified: false }); }); + it('lists invitations waiting for a verified email', async () => { + const invitation = { + id: 'inv-1', + businessId: 'biz-1', + businessName: 'Acme Ltd', + role: 'employee', + expiresAt: new Date('2030-01-01T00:00:00Z'), + }; + + const { result, pendingInvitationsProvider } = await runResolver( + null, + { auth0UserId: 'auth0|new-user', email: 'New@Example.com', emailVerified: true }, + [invitation], + ); + + expect(pendingInvitationsProvider.getPendingInvitationsByVerifiedEmail).toHaveBeenCalledWith( + 'New@Example.com', + ); + expect(result).toMatchObject({ status: 'NO_WORKSPACE', pendingInvitations: [invitation] }); + }); + + it('never matches invitations against an unverified email', async () => { + // The address is unproven, so matching on it would hand a victim's pending + // invitation to whoever signed up with their address first. + const { result, pendingInvitationsProvider } = await runResolver( + null, + { auth0UserId: 'auth0|impostor', email: 'victim@example.com', emailVerified: false }, + [ + { + id: 'inv-1', + businessId: 'biz-1', + businessName: 'Acme Ltd', + role: 'employee', + expiresAt: new Date('2030-01-01T00:00:00Z'), + }, + ], + ); + + expect(pendingInvitationsProvider.getPendingInvitationsByVerifiedEmail).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: 'EMAIL_UNVERIFIED', pendingInvitations: [] }); + }); + it('returns null when the request carries no valid credentials', async () => { const { result } = await runResolver(null, null); diff --git a/packages/server/src/modules/common/resolvers/viewer.resolver.ts b/packages/server/src/modules/common/resolvers/viewer.resolver.ts index ac7b3d41ed..d9b56a09d6 100644 --- a/packages/server/src/modules/common/resolvers/viewer.resolver.ts +++ b/packages/server/src/modules/common/resolvers/viewer.resolver.ts @@ -1,4 +1,5 @@ import { AuthContextProvider } from '../../auth/providers/auth-context.provider.js'; +import { PendingInvitationsProvider } from '../../auth/providers/pending-invitations.provider.js'; import type { CommonModule } from '../types.js'; /** @@ -20,6 +21,9 @@ export const viewerResolvers: CommonModule.Resolvers = { email: authContext.user.email || null, emailVerified: authContext.user.emailVerified, status: 'ACTIVE', + // Already inside a workspace: any further invitations are claimed + // through the emailed link, not through this screen. + pendingInvitations: [], }; } @@ -30,10 +34,26 @@ export const viewerResolvers: CommonModule.Resolvers = { return null; } + 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, + emailVerified: identity.emailVerified, + status: 'EMAIL_UNVERIFIED', + pendingInvitations: [], + }; + } + + const pendingInvitations = await injector + .get(PendingInvitationsProvider) + .getPendingInvitationsByVerifiedEmail(identity.email); + return { email: identity.email, - emailVerified: identity.emailVerified, - status: identity.emailVerified ? 'NO_WORKSPACE' : 'EMAIL_UNVERIFIED', + emailVerified: true, + status: 'NO_WORKSPACE', + pendingInvitations, }; }, }, diff --git a/packages/server/src/modules/common/typeDefs/viewer.graphql.ts b/packages/server/src/modules/common/typeDefs/viewer.graphql.ts index 817645defc..deef9710b8 100644 --- a/packages/server/src/modules/common/typeDefs/viewer.graphql.ts +++ b/packages/server/src/modules/common/typeDefs/viewer.graphql.ts @@ -21,5 +21,16 @@ export default gql` email: String emailVerified: Boolean! status: ViewerStatus! + " 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 " + type PendingInvitation { + id: UUID! + businessId: UUID! + businessName: String + role: String! + expiresAt: DateTime! } `; From 8ff304fa980c2228d674ee38ea596e1618d38ae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 10:11:18 +0000 Subject: [PATCH 2/4] fix(auth): address PR review and fix cross-suite integration test damage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01344D5q1uyAHuwRJ3yyDX46 --- .changeset/claim-pending-invitations.md | 24 ++++++++++++++ .../screens/__tests__/welcome.test.ts | 7 ++-- .../client/src/components/screens/welcome.tsx | 10 ++++-- packages/client/src/hooks/use-viewer.ts | 2 +- .../auth/helpers/invitations.helper.ts | 13 ++++++++ .../providers/accept-invitations.provider.ts | 5 +-- .../providers/pending-invitations.provider.ts | 4 +-- .../common/__tests__/viewer.resolver.test.ts | 4 +-- .../modules/common/typeDefs/viewer.graphql.ts | 2 +- .../scraper-ingestion.integration.test.ts | 32 +++++++++++++++---- 10 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 .changeset/claim-pending-invitations.md diff --git a/.changeset/claim-pending-invitations.md b/.changeset/claim-pending-invitations.md new file mode 100644 index 0000000000..c7b534ab79 --- /dev/null +++ b/.changeset/claim-pending-invitations.md @@ -0,0 +1,24 @@ +--- +'@accounter/server': minor +'@accounter/client': minor +--- + +Let a user claim an invitation waiting for their verified email, without the emailed link. + +Signing up before opening the 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 fallback in +`mapAuth0UserToLocal` only matched invitations that had *already* been accepted — so a pending one +never matched. The user landed on `/welcome` with no way forward. + +Server: `viewer.pendingInvitations` lists unaccepted, unexpired invitations addressed to the +caller's verified email, and a new `claimInvitation(invitationId)` mutation accepts one. The +security model is stricter than the token path deliberately: `acceptInvitation` treats possession of +the token as proof, so its email check is defence in depth, whereas here the verified email is the +*only* proof — it is mandatory, checked before any database access, and an unverified address never +matches anything. Missing, expired, already-accepted and wrong-recipient invitations all report the +same error, so the mutation cannot be used to probe for invitation ids. Both entry points now share +one `finalizeAcceptance()` step — claimant check, user linking, Auth0 cleanup, audit log — so the +two paths cannot drift apart. + +Client: `/welcome` lists the waiting invitations with one-click accept in place of the dead-end +copy, and returns to the app once one is claimed. diff --git a/packages/client/src/components/screens/__tests__/welcome.test.ts b/packages/client/src/components/screens/__tests__/welcome.test.ts index cab242c483..4fffae4ec4 100644 --- a/packages/client/src/components/screens/__tests__/welcome.test.ts +++ b/packages/client/src/components/screens/__tests__/welcome.test.ts @@ -170,7 +170,7 @@ describe('WelcomePage', () => { id: 'inv-1', businessId: 'biz-1', businessName: 'Acme Ltd', - role: 'employee', + roleId: 'employee', expiresAt: '2030-01-01T00:00:00.000Z', }, ], @@ -199,7 +199,7 @@ describe('WelcomePage', () => { id: 'inv-1', businessId: 'biz-1', businessName: 'Acme Ltd', - role: 'employee', + roleId: 'employee', expiresAt: '2030-01-01T00:00:00.000Z', }, ], @@ -215,8 +215,7 @@ describe('WelcomePage', () => { }); expect(claimInvitationMock).toHaveBeenCalledWith('inv-1'); - // The membership only exists server-side, so the viewer must be re-read - // before navigating or the guard would bounce us straight back here. + // The stale NO_WORKSPACE answer must not outlive the navigation. expect(refreshViewerMock).toHaveBeenCalled(); expect(router.state.location.pathname).toBe(ROUTES.HOME); await cleanup(); diff --git a/packages/client/src/components/screens/welcome.tsx b/packages/client/src/components/screens/welcome.tsx index 05733bc6cc..4a72e13d63 100644 --- a/packages/client/src/components/screens/welcome.tsx +++ b/packages/client/src/components/screens/welcome.tsx @@ -83,8 +83,12 @@ export function WelcomePage(): ReactElement { const handleClaim = async (invitationId: string) => { const result = await claimInvitation(invitationId); 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. + // The membership now exists server-side, so the stale NO_WORKSPACE answer + // must not outlive this navigation. Today OnboardingGuard re-queries on + // mount anyway — the urql client is built without a cache exchange, so + // nothing is served from cache — but that is a property of the client + // setup, not of this flow, and a cache exchange would silently reintroduce + // the bounce. Invalidating explicitly keeps the flow correct either way. refreshViewer(); navigate(ROUTES.HOME, { replace: true }); } @@ -126,7 +130,7 @@ export function WelcomePage(): ReactElement { >

{invitation.businessName ?? 'Unnamed business'}

-

as {invitation.role}

+

as {invitation.roleId}

} /> + Redirected to login
} /> + + + + + ); +} + +const meta = { + title: 'Screens/Welcome', + component: WelcomePage, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * The dead end: a verified account with no membership and nothing waiting for + * it. Accounter is invitation-only, so this explains the situation instead of + * dropping the user on an empty dashboard. + */ +export const NoWorkspace: Story = { + render: () => + renderWelcome({ + email: 'new.user@example.com', + emailVerified: true, + status: 'NO_WORKSPACE', + pendingInvitations: [], + }), +}; + +/** + * The address is unproven, so it is never matched against invitations — a + * pending invitation for this email stays hidden until the address is verified. + */ +export const EmailUnverified: Story = { + render: () => + renderWelcome({ + email: 'new.user@example.com', + emailVerified: false, + status: 'EMAIL_UNVERIFIED', + pendingInvitations: [], + }), +}; + +/** One invitation waiting — the way out of the dead end above. */ +export const InvitationWaiting: Story = { + render: () => + renderWelcome({ + email: 'new.user@example.com', + emailVerified: true, + status: 'NO_WORKSPACE', + pendingInvitations: [invitation], + }), +}; + +/** An accountant invited by several clients picks which one to join first. */ +export const MultipleInvitations: Story = { + render: () => + renderWelcome({ + email: 'accountant@example.com', + emailVerified: true, + status: 'NO_WORKSPACE', + pendingInvitations: [ + invitation, + { + ...invitation, + id: 'inv-2', + businessId: 'biz-2', + businessName: 'Globex Inc', + roleId: 'accountant', + }, + { + ...invitation, + id: 'inv-3', + businessId: 'biz-3', + businessName: null, + roleId: 'accountant', + }, + ], + }), +}; + +/** While the viewer query is in flight, nothing is asserted about the account. */ +export const Loading: Story = { + render: () => renderWelcome(null, { loading: true }), +}; + +/** + * The provisioning state is unknown, so the screen says so rather than claiming + * the user has no workspace — a network blip is not an entitlement. + */ +export const QueryFailed: Story = { + render: () => + renderWelcome(null, { + error: new CombinedError({ networkError: new Error('Failed to fetch') }), + }), +};