@@ -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,