Skip to content
Merged
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
27 changes: 27 additions & 0 deletions .changeset/unprovisioned-user-welcome-screen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@accounter/server': minor
'@accounter/client': minor
---

Route authenticated users who are not linked to any business to a dedicated `/welcome` screen,
instead of an empty dashboard behind failing queries.

An Auth0 identity with no row in `business_users` resolves to a `null` auth context, so every
`@requiresAuth` field threw `UNAUTHENTICATED`. The client read that as a token failure and ran
`refreshAuth` — which succeeds, because the token was never bad — optionally prompting an
interactive re-login, while every failed operation raised its own error toast.

Server: `@requiresAuth` now distinguishes the two cases, throwing `ONBOARDING_REQUIRED` for a
verified JWT identity that maps to no membership and keeping `UNAUTHENTICATED` for missing or
invalid credentials. A new `viewer` query — deliberately unauthenticated-safe, like
`acceptInvitation` — reports the caller's own provisioning state (`ACTIVE`, `EMAIL_UNVERIFIED` or
`NO_WORKSPACE`) from its own token claims, and nothing more. Membership takes precedence over email
verification, so a linked caller stays `ACTIVE` even with an unverified address. `getJwtIdentity()`
is now memoized per operation, since the directive calls it on every guarded field that fails to
resolve a context.

Client: a new `OnboardingGuard` inside `ProtectedRoute` sends a non-`ACTIVE` viewer to `/welcome`,
which explains the invitation-only model (or asks for email verification) and offers *Check again*
and *Sign out*. It fails open on a query error, rendering the app rather than trapping the user, and
says the check failed rather than asserting "No workspace yet" when the viewer state is unknown.
`ONBOARDING_REQUIRED` no longer raises an error toast per failed operation.
12 changes: 12 additions & 0 deletions packages/client/src/__tests__/urql-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ describe('URQL auth exchange hardening', () => {
expect(authConfig.didAuthError(forbiddenError)).toBe(false);
});

it('does not treat ONBOARDING_REQUIRED as an auth error', async () => {
const { authConfig } = await initializeAuth(async () => null);

// The token is valid — the account just has no workspace. Refreshing it
// cannot help, and doing so drags the user through a pointless re-login.
const onboardingError = {
graphQLErrors: [{ extensions: { code: 'ONBOARDING_REQUIRED' } }],
};

expect(authConfig.didAuthError(onboardingError)).toBe(false);
});

it('does not eagerly trigger auth refresh before a server auth error', async () => {
const { authConfig } = await initializeAuth(async () => null);

Expand Down
37 changes: 37 additions & 0 deletions packages/client/src/__tests__/urql-error-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { OperationResult } from 'urql';
import { handleUrqlError } from '../providers/urql-error-handler.js';

const { toastErrorMock } = vi.hoisted(() => ({
toastErrorMock: vi.fn(),
}));

vi.mock('sonner', () => ({
toast: { error: toastErrorMock },
}));

function resultWithCode(code: string): OperationResult {
return {
error: { graphQLErrors: [{ message: 'nope', extensions: { code } }] },
} as unknown as OperationResult;
}

describe('handleUrqlError', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('toasts ordinary GraphQL errors', () => {
handleUrqlError(resultWithCode('FORBIDDEN'));

expect(toastErrorMock).toHaveBeenCalledTimes(1);
});

it('stays silent for ONBOARDING_REQUIRED', () => {
// Every guarded operation fails at once for an unprovisioned account; the
// /welcome screen is the message, so toasts would only pile up behind it.
handleUrqlError(resultWithCode('ONBOARDING_REQUIRED'));

expect(toastErrorMock).not.toHaveBeenCalled();
});
});
81 changes: 79 additions & 2 deletions packages/client/src/components/__tests__/protected-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,58 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProtectedRoute, PublicOnlyGuard } from '../../router/guards/auth-guards.js';
import { ROUTES } from '../../router/routes.js';

const { useAuth0Mock } = vi.hoisted(() => ({
const { useAuth0Mock, useViewerMock } = vi.hoisted(() => ({
useAuth0Mock: vi.fn(),
useViewerMock: vi.fn(),
}));

vi.mock('@auth0/auth0-react', () => ({
useAuth0: useAuth0Mock,
}));

// ProtectedRoute composes OnboardingGuard, which queries `viewer` through urql.
// Stubbing the hook keeps these cases about auth state alone.
vi.mock('../../hooks/use-viewer.js', () => ({
useViewer: useViewerMock,
}));

const ACTIVE_VIEWER = {
fetching: false,
error: undefined,
viewer: { email: 'member@example.com', emailVerified: true, status: 'ACTIVE' },
};

type AuthState = {
isAuthenticated: boolean;
isLoading: boolean;
};

type ViewerState = typeof ACTIVE_VIEWER | Record<string, unknown>;

(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;

async function renderProtectedPath(pathname: string, authState: AuthState) {
async function renderProtectedPath(
pathname: string,
authState: AuthState,
viewerState: ViewerState = ACTIVE_VIEWER,
) {
useAuth0Mock.mockReturnValue(authState);
useViewerMock.mockReturnValue(viewerState);

const router = createMemoryRouter(
[
{
path: ROUTES.LOGIN,
element: React.createElement('div', null, 'Login Page'),
},
{
path: ROUTES.WELCOME,
element: React.createElement('div', null, 'Welcome Page'),
},
{
path: ROUTES.CHARGES.ROOT,
element: React.createElement(
Expand Down Expand Up @@ -167,6 +191,59 @@ describe('ProtectedRoute', () => {
});
});

describe('OnboardingGuard', () => {
beforeEach(() => {
vi.clearAllMocks();
});

const authenticated = { isAuthenticated: true, isLoading: false };

it('redirects an authenticated user with no workspace to /welcome', async () => {
const { router, cleanup } = await renderProtectedPath(ROUTES.CHARGES.ROOT, authenticated, {
fetching: false,
error: undefined,
viewer: { email: 'new@example.com', emailVerified: true, status: 'NO_WORKSPACE' },
});

expect(router.state.location.pathname).toBe(ROUTES.WELCOME);
await cleanup();
});

it('redirects an unverified-email user to /welcome', async () => {
const { router, cleanup } = await renderProtectedPath(ROUTES.CHARGES.ROOT, authenticated, {
fetching: false,
error: undefined,
viewer: { email: 'new@example.com', emailVerified: false, status: 'EMAIL_UNVERIFIED' },
});

expect(router.state.location.pathname).toBe(ROUTES.WELCOME);
await cleanup();
});

it('holds the app shell back while the viewer query is in flight', async () => {
const { html, cleanup } = await renderProtectedPath(ROUTES.CHARGES.ROOT, authenticated, {
fetching: true,
error: undefined,
viewer: null,
});

expect(html).not.toContain('Charges Page');
await cleanup();
});

it('renders the app on a viewer query error rather than trapping the user', async () => {
const { html, router, cleanup } = await renderProtectedPath(ROUTES.CHARGES.ROOT, authenticated, {
fetching: false,
error: new Error('network down'),
viewer: null,
});

expect(router.state.location.pathname).toBe(ROUTES.CHARGES.ROOT);
expect(html).toContain('Charges Page');
await cleanup();
});
});

describe('PublicOnlyGuard', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
149 changes: 149 additions & 0 deletions packages/client/src/components/screens/__tests__/welcome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// @vitest-environment happy-dom

import React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
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(),
}));

vi.mock('@auth0/auth0-react', () => ({
useAuth0: useAuth0Mock,
}));

vi.mock('../../../hooks/use-viewer.js', () => ({
useViewer: useViewerMock,
}));

vi.mock('../../../hooks/use-logout.js', () => ({
useLogout: useLogoutMock,
}));

(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;

async function renderWelcome(
viewerState: Record<string, unknown>,
authState = { isAuthenticated: true, isLoading: false },
) {
useAuth0Mock.mockReturnValue(authState);
useViewerMock.mockReturnValue(viewerState);
useLogoutMock.mockReturnValue(vi.fn());

const router = createMemoryRouter(
[
{ path: ROUTES.WELCOME, element: React.createElement(WelcomePage) },
{ path: ROUTES.HOME, element: React.createElement('div', null, 'Home Page') },
{ path: ROUTES.LOGIN, element: React.createElement('div', null, 'Login Page') },
],
{ initialEntries: [ROUTES.WELCOME] },
);

const container = document.createElement('div');
document.body.append(container);

let root: Root | null = null;
await act(async () => {
root = createRoot(container);
root.render(React.createElement(RouterProvider, { router }));
await Promise.resolve();
});

const html = container.innerHTML;

const cleanup = async () => {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
container.remove();
};

return { html, router, cleanup };
}

describe('WelcomePage', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('explains the invitation-only model when the user has no workspace', async () => {
const { html, cleanup } = await renderWelcome({
fetching: false,
error: undefined,
viewer: { email: 'new@example.com', emailVerified: true, status: 'NO_WORKSPACE' },
});

expect(html).toContain('No workspace yet');
expect(html).toContain('invitation-only');
await cleanup();
});

it('asks an unverified user to verify their email', async () => {
const { html, cleanup } = await renderWelcome({
fetching: false,
error: undefined,
viewer: { email: 'new@example.com', emailVerified: false, status: 'EMAIL_UNVERIFIED' },
});

expect(html).toContain('Verify your email');
await cleanup();
});

it('reports an unknown state instead of "no workspace" when the query fails', async () => {
const { html, cleanup } = await renderWelcome({
fetching: false,
error: new Error('network down'),
viewer: null,
});

expect(html).toContain('Could not check your account');
expect(html).not.toContain('No workspace yet');
await cleanup();
});

it('sends an unauthenticated visitor to login without querying the viewer', async () => {
// /welcome is a public route, so it can be opened with no session at all.
const { router, cleanup } = await renderWelcome(
{ fetching: false, error: undefined, viewer: null },
{ isAuthenticated: false, isLoading: false },
);

expect(useViewerMock).toHaveBeenCalledWith({ pause: true });
expect(router.state.location.pathname).toBe(ROUTES.LOGIN);
await cleanup();
});

it('holds the viewer query until Auth0 has resolved', async () => {
// Asking before the token is attached would answer "no workspace" for a
// perfectly good account.
const { cleanup } = await renderWelcome(
{ fetching: false, error: undefined, viewer: null },
{ isAuthenticated: false, isLoading: true },
);

expect(useViewerMock).toHaveBeenCalledWith({ pause: true });
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' },
});

expect(router.state.location.pathname).toBe(ROUTES.HOME);
await cleanup();
});
});
Loading
Loading