diff --git a/.changeset/@accounter_client-4189-dependencies.md b/.changeset/@accounter_client-4189-dependencies.md new file mode 100644 index 0000000000..b3ea6afbdc --- /dev/null +++ b/.changeset/@accounter_client-4189-dependencies.md @@ -0,0 +1,6 @@ +--- +"@accounter/client": patch +--- +dependencies updates: + - Updated dependency [`@auth0/auth0-react@2.24.0` ↗︎](https://www.npmjs.com/package/@auth0/auth0-react/v/2.24.0) (from `2.23.0`, in `dependencies`) + - Updated dependency [`lucide-react@1.31.0` ↗︎](https://www.npmjs.com/package/lucide-react/v/1.31.0) (from `1.30.0`, in `dependencies`) diff --git a/.changeset/@accounter_israeli-vat-scraper-4189-dependencies.md b/.changeset/@accounter_israeli-vat-scraper-4189-dependencies.md new file mode 100644 index 0000000000..76d2966bb5 --- /dev/null +++ b/.changeset/@accounter_israeli-vat-scraper-4189-dependencies.md @@ -0,0 +1,5 @@ +--- +"@accounter/israeli-vat-scraper": patch +--- +dependencies updates: + - Updated dependency [`puppeteer@25.6.0` ↗︎](https://www.npmjs.com/package/puppeteer/v/25.6.0) (from `25.5.0`, in `dependencies`) diff --git a/.changeset/@accounter_modern-poalim-scraper-4189-dependencies.md b/.changeset/@accounter_modern-poalim-scraper-4189-dependencies.md new file mode 100644 index 0000000000..b2cfbc49e8 --- /dev/null +++ b/.changeset/@accounter_modern-poalim-scraper-4189-dependencies.md @@ -0,0 +1,5 @@ +--- +"@accounter/modern-poalim-scraper": patch +--- +dependencies updates: + - Updated dependency [`puppeteer@25.6.0` ↗︎](https://www.npmjs.com/package/puppeteer/v/25.6.0) (from `25.5.0`, in `dependencies`) diff --git a/.changeset/@accounter_server-4189-dependencies.md b/.changeset/@accounter_server-4189-dependencies.md new file mode 100644 index 0000000000..87d9b510b0 --- /dev/null +++ b/.changeset/@accounter_server-4189-dependencies.md @@ -0,0 +1,6 @@ +--- +"@accounter/server": patch +--- +dependencies updates: + - Updated dependency [`ai@7.0.59` ↗︎](https://www.npmjs.com/package/ai/v/7.0.59) (from `7.0.58`, in `dependencies`) + - Updated dependency [`graphql-scalars@1.26.0` ↗︎](https://www.npmjs.com/package/graphql-scalars/v/1.26.0) (from `1.25.0`, in `dependencies`) 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/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..4fffae4ec4 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,80 @@ 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', + roleId: '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', + roleId: '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 stale NO_WORKSPACE answer must not outlive the navigation. + 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.stories.tsx b/packages/client/src/components/screens/welcome.stories.tsx new file mode 100644 index 0000000000..5bfc8a5a15 --- /dev/null +++ b/packages/client/src/components/screens/welcome.stories.tsx @@ -0,0 +1,188 @@ +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { CombinedError, Provider as UrqlProvider, type Client, type OperationResult } from 'urql'; +import { fromValue, merge, never } from 'wonka'; +import { Auth0Context, initialContext, type Auth0ContextInterface } from '@auth0/auth0-react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ROUTES } from '../../router/routes.js'; +import { WelcomePage } from './welcome.js'; + +/** + * `/welcome` is what an authenticated identity sees when it cannot use the app + * yet. Which branch renders depends entirely on the `viewer` query, so each + * story pins that answer with its own urql client rather than talking to a + * server — the states below are otherwise awkward to reach on demand. + */ + +type ViewerState = { + email: string | null; + emailVerified: boolean; + status: 'ACTIVE' | 'EMAIL_UNVERIFIED' | 'NO_WORKSPACE'; + pendingInvitations: Array<{ + id: string; + businessId: string; + businessName: string | null; + roleId: string; + expiresAt: string; + }>; +}; + +/** + * A urql client that answers the `viewer` query with a fixed result. Nested + * inside the global Provider from `.storybook/preview.tsx`, so it wins for the + * component under test without needing `yarn mock:server` running. + */ +function makeViewerClient( + viewer: ViewerState | null, + options: { loading?: boolean; error?: CombinedError } = {}, +): Client { + // A failed request carries no data, so the error case must not also emit a + // payload — otherwise the screen has an answer and never reaches its error branch. + const result = (options.error + ? { data: undefined, error: options.error, stale: false, hasNext: false } + : { data: { viewer }, stale: false, hasNext: false }) as unknown as OperationResult; + + return { + // `never` emits nothing, which keeps the hook in its fetching state. + // Otherwise: emit the result and stay open, the way a real query source + // does. A source that *completes* makes urql push a trailing + // `{ fetching: false }`, and its state reducer keeps `data` across that but + // resets `error` — which would make the failure story unreachable. + executeQuery: () => (options.loading ? never : merge([fromValue(result), never])), + executeMutation: () => + fromValue({ + data: { claimInvitation: { success: true, businessId: 'biz-1', roleId: 'employee' } }, + stale: false, + hasNext: false, + } as unknown as OperationResult), + executeSubscription: () => never, + } as unknown as Client; +} + +const authenticated = { + ...initialContext, + isAuthenticated: true, + isLoading: false, + logout: async () => void 0, +} as unknown as Auth0ContextInterface; + +const invitation = { + id: 'inv-1', + businessId: 'biz-1', + businessName: 'Acme Ltd', + roleId: 'employee', + expiresAt: '2030-01-01T00:00:00.000Z', +}; + +function renderWelcome( + viewer: ViewerState | null, + options?: { loading?: boolean; error?: CombinedError }, +) { + return ( + + + + + } /> + {/* An ACTIVE viewer is redirected away, so give the target a home. */} + Redirected to the app} /> + 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') }), + }), +}; diff --git a/packages/client/src/components/screens/welcome.tsx b/packages/client/src/components/screens/welcome.tsx index 13d6507fd2..4a72e13d63 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,32 @@ 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 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 }); + } + }; 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 +115,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.roleId}

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

Accounter is invitation-only. Your account is not linked to any business yet — ask an @@ -98,7 +152,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..cf423bcd2e 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 + roleId + 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/helpers/invitations.helper.ts b/packages/server/src/modules/auth/helpers/invitations.helper.ts index 892a8b8b9f..0d6fe35711 100644 --- a/packages/server/src/modules/auth/helpers/invitations.helper.ts +++ b/packages/server/src/modules/auth/helpers/invitations.helper.ts @@ -93,6 +93,19 @@ export function invalidTokenError(): GraphQLError { }); } +/** + * The claim path's rejection: no token is involved there, so the message must not + * mention one. It deliberately keeps the TOKEN_INVALID code and stays vague about + * which precondition failed — an unverified email, a wrong recipient, an expired + * or already-accepted invitation, or an id that never existed all report the same + * thing, so the mutation cannot be used to probe for invitation ids. + */ +export function unavailableInvitationError(): GraphQLError { + return new GraphQLError('This invitation is not available for your account', { + extensions: { code: 'TOKEN_INVALID' }, + }); +} + export function expiredTokenError(): GraphQLError { return new GraphQLError('Invitation token expired', { extensions: { code: 'TOKEN_EXPIRED' }, 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..99e235ff5d 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'; @@ -8,10 +9,13 @@ import { expiredTokenError, invalidTokenError, mapAuth0Error, + unavailableInvitationError, } from '../helpers/invitations.helper.js'; import type { + IGetInvitationByIdForAcceptanceQuery, IGetInvitationByTokenQuery, IGetInvitationForAcceptanceQuery, + IGetInvitationForAcceptanceResult, IGetUserIdByAuth0UserIdQuery, IInsertAcceptedBusinessUserQuery, IUpdateBusinessUserAuth0IdQuery, @@ -40,6 +44,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 +116,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, + ); - if (!effectiveAuth0UserId) { - throw invalidTokenError(); + 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 unavailableInvitationError(); + } + + 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 unavailableInvitationError(); } - // 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..4d669df407 --- /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; + roleId: 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, + roleId: 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..6c4d6cbdfd 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', + roleId: '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', + roleId: '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..6d083a17dd 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 + roleId: String! + expiresAt: DateTime! } `; diff --git a/packages/server/src/modules/scraper-ingestion/providers/__tests__/scraper-ingestion.integration.test.ts b/packages/server/src/modules/scraper-ingestion/providers/__tests__/scraper-ingestion.integration.test.ts index d3bbc0f496..98f6a4f596 100644 --- a/packages/server/src/modules/scraper-ingestion/providers/__tests__/scraper-ingestion.integration.test.ts +++ b/packages/server/src/modules/scraper-ingestion/providers/__tests__/scraper-ingestion.integration.test.ts @@ -76,14 +76,34 @@ afterAll(async () => { // ── Helpers ──────────────────────────────────────────────────────────────────── -async function truncate(table: string) { - await pool.query(`TRUNCATE TABLE accounter_schema.${table} CASCADE`); +/** + * Clear a table this suite owns, without disturbing anything else running + * against the shared test database. + * + * This used to be `TRUNCATE ... CASCADE`, which was destructive in two ways for + * a suite that runs in parallel with every other integration file: + * + * - CASCADE empties the *whole* referencing table, not just referencing rows. + * `max_creditcard_transactions` is referenced by `transactions_raw_list`, + * which cascades on to `transactions` — so this `beforeEach` silently wiped + * the rows other suites (e.g. the ledger scenarios) had just inserted, + * surfacing there as an unbalanced ledger rather than as an error here. + * - TRUNCATE takes an ACCESS EXCLUSIVE lock on every table it touches. Two + * files reaching the same tables in different orders deadlock. + * + * DELETE takes row-level locks and honours foreign keys instead of bulldozing + * them. The rows removed are the ones this suite inserted: it disables the + * triggers on these tables, so its inserts never propagate to + * `transactions_raw_list` in the first place. + */ +async function clearTable(table: string) { + await pool.query(`DELETE FROM accounter_schema.${table}`); } // ── Cal ─────────────────────────────────────────────────────────────────────── describe('uploadCalTransactions', () => { - beforeEach(() => truncate('cal_creditcard_transactions')); + beforeEach(() => clearTable('cal_creditcard_transactions')); const baseTx: CalTransactionInput = { trnIntId: 'CAL-TXN-001', @@ -118,7 +138,7 @@ describe('uploadCalTransactions', () => { // ── Discount ────────────────────────────────────────────────────────────────── describe('uploadDiscountTransactions', () => { - beforeEach(() => truncate('bank_discount_transactions')); + beforeEach(() => clearTable('bank_discount_transactions')); const baseTx: DiscountTransactionInput = { urn: 'DISCOUNT-URN-001', @@ -152,7 +172,7 @@ describe('uploadDiscountTransactions', () => { // ── Max ─────────────────────────────────────────────────────────────────────── describe('uploadMaxTransactions', () => { - beforeEach(() => truncate('max_creditcard_transactions')); + beforeEach(() => clearTable('max_creditcard_transactions')); const baseTx: MaxTransactionInput = { uid: 'MAX-UID-001', @@ -231,7 +251,7 @@ describe('uploadMaxTransactions', () => { // ── Currency Rates ──────────────────────────────────────────────────────────── describe('uploadCurrencyRates', () => { - beforeEach(() => truncate('exchange_rates')); + beforeEach(() => clearTable('exchange_rates')); const baseRate = { exchangeDate: '2024-01-15' as const,