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
2 changes: 1 addition & 1 deletion .claude/references/auth/oauth-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ id_token verification, profile mapping, and RP-initiated logout via `endsession`
- `src/lib/env.ts` — `getMpBaseUrl()`, `getAuthBaseUrl()`, `getMpClient()` (validated env readers)
- `src/app/signin/sign-in-content.tsx` — calls `authClient.signIn.social({ provider: "ministryplatform", callbackURL })`
- `src/app/api/auth/[...all]/route.ts` — deny-by-default allowlist in front of `toNextJsHandler(auth)`
- `src/components/shared-actions/sign-out.ts` — `handleSignOut()` server action (the only caller is the `/session-error` form)
- `src/components/shared-actions/sign-out.ts` — `handleSignOut()` server action (called by the sign-out forms on `/no-access` and `/session-error`)
- Tests: `src/auth.oidc-discovery.test.ts` (outages, lazy discovery), `src/auth.user-info.test.ts` (`getUserInfo` with signed tokens), `src/auth.id-token-sign-in.test.ts` (F12), `src/auth.shared-instance.test.ts`, `src/auth.test.ts` (config pins), `src/components/shared-actions/sign-out.test.ts`; mock MP in `src/test-utils/mock-oidc.ts`

## Key concepts
Expand Down
4 changes: 2 additions & 2 deletions .claude/references/data-flow/call-graphs.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ last_verified: 2026-04-17

## Flow 6: Sign-out (OIDC logout)

**Trigger:** user submits the "Sign out" form on `/session-error` — the only sign-out control in the app (the dropdown `UserMenu` was removed 2026-10-10).
**Entry point:** `src/app/session-error/page.tsx` — `<form action={handleSignOut}>`.
**Trigger:** user submits a sign-out form — on `/no-access` (signed in, no MP security role) or `/session-error` (session without `userGuid`). These are the app's only sign-out controls (the dropdown `UserMenu` was removed 2026-10-10).
**Entry points:** `src/app/(web)/no-access/page.tsx` and `src/app/session-error/page.tsx` — `<form action={handleSignOut}>`.

**Stack trace:**
1. `src/components/shared-actions/sign-out.ts` — `handleSignOut` (`'use server'`).
Expand Down
15 changes: 8 additions & 7 deletions .claude/references/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ policy. Read this before changing anything under `src/lib/auth.ts`,
Sign-in is deliberately **not** role-gated — no check in `getUserInfo`,
`mapProfileToUser`, `customSession` or `AuthWrapper` — so a role-less user still
gets a session and the app shell. They are redirected from `/tools/*` to
`/no-access`, which renders inside the shell. **There is no sign-out control on
`/no-access`** — the only one in the app is the `/session-error` form (the
dropdown `UserMenu` was removed 2026-10-10). Such a session ends at the 12 h
hard lifetime or when the browser drops the cookie.
`/no-access`, which renders inside the shell and carries a **Sign out** form
(`handleSignOut`) so they can end the session, e.g. to sign in as a different
MP user. That form and the one on `/session-error` are the app's only sign-out
controls (the dropdown `UserMenu` was removed 2026-10-10).

### Why authentication is not enough

Expand Down Expand Up @@ -256,9 +256,10 @@ against a production build before calling it done.

Last walk: **2026-10-10**, enforced policy, production build — sign-in, every
tool, Radix select/popover/command and dialog (scroll-lock `<style>` applied),
zero violations. Sign-out was not walked: its only control is on
`/session-error`, which a healthy session never reaches. `form-action` still
allows the MP origin for that form's redirect chain.
zero violations. Sign-out walked the same day from the `/no-access` form: MP
logout → back to the app → `/signin` → MP login prompt (both sessions ended),
zero violations — the `form-action` MP origin is what lets that redirect chain
through.

### `CSP_REPORT_URI` (opt-in reporting)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ Alert, Alert Dialog, Avatar, Badge, Breadcrumb, Button, Card, Checkbox, Command,
- **address-labels/** — 12 components for address label printing, barcode generation, and mail merge
- **field-management/** — Drag-and-drop editor for MP page field order and flags (`PageSearch`, `FieldOrderEditor`, sortable group/field items, `useFieldOrderState`)
- **dev-panel/** — Unified developer overlay (localhost-only) showing parsed URL params, MP selection data, contact records, and authorized tools
- **shared-actions/** — Cross-feature server actions (`getCurrentUserProfile()` — returns the *calling* user's MP profile; the `User_GUID` comes from the session, never from a parameter); `handleSignOut()` — app sign-out plus MP RP-initiated logout, used by `/session-error`
- **shared-actions/** — Cross-feature server actions (`getCurrentUserProfile()` — returns the *calling* user's MP profile; the `User_GUID` comes from the session, never from a parameter); `handleSignOut()` — app sign-out plus MP RP-initiated logout, used by the sign-out forms on `/no-access` and `/session-error`

All components use kebab-case file naming, PascalCase component names, and named exports with barrel index files.

Expand Down
30 changes: 25 additions & 5 deletions src/app/(web)/no-access/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

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

vi.mock('@/components/shared-actions/sign-out', () => ({
handleSignOut: mockHandleSignOut,
}));

import NoAccessPage from './page';

/**
* This page is the landing spot for a signed-in user with no MP security role.
* It lives inside the (web) route group specifically so the app shell — and
* therefore the sign-out control — stays reachable. These tests pin the copy
* that tells the user what to do, since that copy IS the feature.
* Sign-in is not role-gated, so the user has a session; these tests pin the copy
* that tells them what to do and the sign-out form that lets them end it.
*/

afterEach(cleanup);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe('NoAccessPage', () => {
it('renders a heading explaining the user lacks access', () => {
Expand All @@ -33,4 +43,14 @@ describe('NoAccessPage', () => {

expect(screen.getByText(/ask your ministry platform administrator/i)).toBeInTheDocument();
});

it('renders a sign-out submit button wired to the handleSignOut action', () => {
// Without it a role-less user is stuck in their session until the 12 h
// lifetime ends — there is no other sign-out control on this page.
const { container } = render(<NoAccessPage />);

const button = screen.getByRole('button', { name: /^sign out$/i });
expect(button).toHaveAttribute('type', 'submit');
expect(button.closest('form')).toBe(container.querySelector('form'));
});
});
23 changes: 18 additions & 5 deletions src/app/(web)/no-access/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
import { handleSignOut } from "@/components/shared-actions/sign-out";

/**
* Explaining page for signed-in users who hold no MP security role.
*
* Deliberately inside the `(web)` route group so it renders WITH the app shell:
* the header, user menu and — critically — the sign-out control stay available.
* Refusing these users at sign-in instead would strand them in the app with no
* way out, which is why sign-in is not role-gated (see AuthorizationService).
* Sign-in is deliberately not role-gated (see AuthorizationService), so these
* users DO have a session — and this page must give them a way to end it, e.g.
* to sign in again as a different MP user. The form below is that way out; the
* app has no other sign-out control outside `/session-error`.
*
* It sits inside the `(web)` group, so AuthWrapper renders it per request (it
* reads the session), which keeps the CSP nonce available for the form.
*/
export default function NoAccessPage() {
return (
<div className="flex items-center justify-center min-h-[60vh] px-4">
<div className="max-w-md text-center">
<h1 className="text-2xl font-semibold mb-3">You don&apos;t have access to this tool</h1>
<p className="text-gray-600">
<p className="text-gray-600 mb-6">
Your Ministry Platform account signed in successfully, but it
doesn&apos;t have a security role that grants access to these tools.
Ask your Ministry Platform administrator to assign one.
</p>
<form action={handleSignOut}>
<button
type="submit"
className="inline-flex items-center justify-center rounded-md bg-[#344767] px-5 py-2.5 text-white font-medium hover:bg-[#2d3a5f] focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Sign out
</button>
</form>
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/shared-actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Keep actions with their component folder when:

**Shared Actions (place here):**
- `user.ts` - Current-user profile lookup used by contexts and components
- `sign-out.ts` - `handleSignOut`: app sign-out plus MP RP-initiated logout (used by `/session-error`)
- `sign-out.ts` - `handleSignOut`: app sign-out plus MP RP-initiated logout (used by the sign-out forms on `/no-access` and `/session-error`)
- `auth.ts` - Authentication actions used across the app
- `analytics.ts` - Analytics tracking used by multiple features
- `notifications.ts` - Notification system used app-wide
Expand Down
Loading