Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/@accounter_client-4189-dependencies.md
Original file line number Diff line number Diff line change
@@ -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`)
Original file line number Diff line number Diff line change
@@ -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`)
Original file line number Diff line number Diff line change
@@ -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`)
6 changes: 6 additions & 0 deletions .changeset/@accounter_server-4189-dependencies.md
Original file line number Diff line number Diff line change
@@ -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`)
24 changes: 24 additions & 0 deletions .changeset/claim-pending-invitations.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
110 changes: 100 additions & 10 deletions packages/client/src/components/screens/__tests__/welcome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -69,7 +80,7 @@ async function renderWelcome(
container.remove();
};

return { html, router, cleanup };
return { html, container, router, cleanup };
}

describe('WelcomePage', () => {
Expand All @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading