From 5686c48f24dc27c7f75f77afdf25bffd3de46625 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 9 Feb 2026 20:55:12 +0000 Subject: [PATCH 01/32] add plans --- PLAN-NEXT.md | 660 ++++++++++++++++++++++++++++++++++++++++++++++++ PLAN.md | 696 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1356 insertions(+) create mode 100644 PLAN-NEXT.md create mode 100644 PLAN.md diff --git a/PLAN-NEXT.md b/PLAN-NEXT.md new file mode 100644 index 00000000000..3c393cf7386 --- /dev/null +++ b/PLAN-NEXT.md @@ -0,0 +1,660 @@ +# Optional MFA: TOTP + Backup Codes + +Add optional multi-factor authentication to HASH. Users can enable TOTP (Google Authenticator, Authy, etc.) in their account settings. Backup codes (lookup secrets) are generated alongside as a fallback. Users with MFA enabled must enter a code after their password at login. + +## Table of Contents + +- [Current State](#current-state) +- [Target State](#target-state) +- [Architecture Context](#architecture-context) +- [Implementation Steps](#implementation-steps) + - [Kratos Configuration](#kratos-configuration) + - [Frontend: Security Settings Page](#frontend-security-settings-page) + - [Frontend: Login Flow for AAL2](#frontend-login-flow-for-aal2) + - [Frontend: Auth Context AAL2 Handling](#frontend-auth-context-aal2-handling) + - [API: Auth Middleware](#api-auth-middleware) +- [Playwright Tests](#playwright-tests) +- [Files Changed Summary](#files-changed-summary) +- [Key Reference Files](#key-reference-files) +- [Notes](#notes) + +--- + +## Current State + +No MFA is implemented, but there is partial scaffolding: + +- **Frontend error handler** (`apps/hash-frontend/src/pages/shared/use-kratos-flow-error-handler.ts` line 31) already handles `session_aal2_required` errors from Kratos -- redirects the user to Kratos's `redirect_browser_to` URL. +- **Signin page** (`apps/hash-frontend/src/pages/signin.page.tsx` lines 57-59, 135) already accepts an `aal` query parameter and passes it to `createBrowserLoginFlow()`. Comment says: "AAL = Authorization Assurance Level. This implies that we want to upgrade the AAL, meaning that we want to perform two-factor authentication/verification." +- **API auth middleware** (`apps/hash-api/src/auth/create-auth-handlers.ts` lines 121-123, 209-211) catches 403 from `toSession()` but has a TODO: `/** @todo: figure out if this should be handled here, or in the next.js app (when implementing 2FA) */` +- **Kratos config** has no TOTP, WebAuthn, or lookup_secret methods enabled. Only `password` and `code` are configured. +- **No security settings page** exists. Password changes are on a standalone `/change-password` page outside the settings layout. + +## Target State + +```mermaid +flowchart TD + subgraph settings [Settings: Security Page] + S1[User opens /settings/security] + S1 --> S2{TOTP enabled?} + S2 -->|No| S3[Show QR code + secret] + S3 --> S4[User scans + enters code] + S4 --> S5[TOTP enabled] + S5 --> S6[Open backup codes modal] + S6 --> S7["User saves codes + confirms"] + S2 -->|Yes| S8[Show TOTP status] + S8 --> S9["Disable TOTP (requires code)"] + S8 --> S10["Regenerate backup codes (privileged session)"] + S10 --> S6 + end + + subgraph login [Login with MFA] + L1[User enters email + password] + L1 --> L2{User has TOTP?} + L2 -->|No| L3[Normal login - AAL1 sufficient] + L2 -->|Yes| L4[Show TOTP code input] + L4 --> L5[User enters code from app] + L5 --> L6[Session upgraded to AAL2] + L4 --> L7[Or: use backup code] + L7 --> L6 + end +``` + +--- + +## Architecture Context + +### How MFA works in Ory Kratos + +Kratos uses **Authentication Assurance Levels (AAL)**: + +- **AAL1**: Single-factor authentication (password only) +- **AAL2**: Multi-factor authentication (password + TOTP/backup code) + +When configured with `session.whoami.required_aal: highest_available`: + +- Users **without** TOTP: AAL1 sessions are sufficient, everything works as before +- Users **with** TOTP: `toSession()` returns 403 if the session is only AAL1, forcing AAL2 completion + +### How TOTP setup works (Kratos settings flow) + +1. Create a settings flow: `createBrowserSettingsFlow()` +2. The flow's UI nodes include a `totp` group with: + - `totp_qr`: A node with `type: "img"` containing a data URI for the QR code + - `totp_secret_key`: A text node with the TOTP secret (for manual entry) + - `totp_code`: An input node for the verification code +3. User scans QR code with authenticator app, enters the 6-digit code +4. Submit: `updateSettingsFlow()` with `method: "totp"` and the entered code +5. Kratos verifies the code and stores the TOTP credential on the identity + +To **unlink** TOTP: The settings flow includes a `totp_unlink` button node. Submitting it removes TOTP from the identity. + +### How lookup secrets (backup codes) work + +1. Create a settings flow +2. The flow's UI nodes include a `lookup_secret` group with: + - `lookup_secret_codes`: A text node containing the generated codes (shown once) + - `lookup_secret_confirm`: A checkbox/hidden input to confirm codes have been saved +3. Submit: `updateSettingsFlow()` with `method: "lookup_secret"` to regenerate, or confirm to save +4. Each backup code is single-use + +### How TOTP login works (AAL2 step) + +1. User logs in with email+password (creates AAL1 session) +2. Frontend calls `toSession()` -- if user has TOTP, Kratos returns 403 with `redirect_browser_to` +3. Frontend redirects to `/signin?aal=aal2&flow={flowId}` (or Kratos provides the URL) +4. `createBrowserLoginFlow({ aal: "aal2" })` returns a login flow with TOTP/lookup_secret UI nodes +5. User enters TOTP code (or backup code) +6. Submit: `updateLoginFlow()` with `method: "totp"` (or `method: "lookup_secret"`) and the code +7. Session is upgraded to AAL2 +8. `toSession()` now succeeds + +### Kratos settings flow UI node groups + +When you create a settings flow, Kratos returns UI nodes organized into groups. The relevant groups for MFA are: + +- **`password`** group: Password change inputs (existing functionality) +- **`totp`** group: TOTP setup QR code, secret, code input, or unlink button +- **`lookup_secret`** group: Backup codes display, confirm/regenerate + +Each group should be rendered as a separate section on the security settings page. + +--- + +## Implementation Steps + +### Kratos Configuration + +#### Step 1: Enable TOTP and lookup_secret methods + +**Files:** `apps/hash-external-services/kratos/kratos.dev.yml` and `apps/hash-external-services/kratos/kratos.prod.yml` + +Add to `selfservice.methods`: + +```yaml +selfservice: + methods: + password: + enabled: true + link: + config: + enabled: false + base_url: http://localhost:3000/api/ory + code: + config: + enabled: true + # NEW: + totp: + config: + # "issuer" is shown in authenticator apps alongside the account name + issuer: HASH + enabled: true + lookup_secret: + enabled: true +``` + +#### Step 2: Configure AAL requirement + +**Files:** `apps/hash-external-services/kratos/kratos.dev.yml` and `apps/hash-external-services/kratos/kratos.prod.yml` + +Add or update the `session` block: + +```yaml +session: + lifespan: 26280h + whoami: + required_aal: highest_available +``` + +`highest_available` means: + +- Users without a second factor: AAL1 is sufficient +- Users with a second factor (TOTP): AAL2 is required -- `toSession()` returns 403 until AAL2 is completed + +#### Step 3: Update settings flow UI URL + +**File:** `apps/hash-external-services/kratos/kratos.dev.yml` + +Currently points to `/change-password`. Update to point to the new security settings page: + +```yaml +selfservice: + flows: + settings: + ui_url: http://localhost:3000/settings/security +``` + +The prod config uses an env var (`SELFSERVICE_FLOWS_SETTINGS_UI_URL`) which should also be updated in the deployment configuration to point to `/settings/security`. + +--- + +### Frontend: Security Settings Page + +#### Step 4: Create security settings page + +**File:** `apps/hash-frontend/src/pages/settings/security.page.tsx` (new file) + +A new page within the settings layout that handles password changes, TOTP setup/teardown, and backup codes. This page replaces the standalone `/change-password` page for most users. + +**Approach:** Create a single Kratos settings flow on mount. The flow's UI nodes contain all the sections (password, totp, lookup_secret). Render each group as a separate card/section. + +**Page structure:** + +``` +/settings/security +├── Change Password section +│ ├── New password input +│ └── Submit button (method: "password") +├── Two-Factor Authentication section +│ ├── IF not enabled: +│ │ ├── QR code image (from totp_qr node) +│ │ ├── Secret key text (from totp_secret_key node, for manual entry) +│ │ ├── Verification code input (totp_code node) +│ │ └── "Enable" submit button (method: "totp") +│ │ └── [On success: open Backup Codes modal] +│ └── IF enabled: +│ ├── Status: "TOTP is enabled" +│ ├── "Disable TOTP" button (requires entering current TOTP code or backup code) +│ └── "Regenerate backup codes" button (requires privileged session) +│ └── [Opens Backup Codes modal with new codes] +└── Backup Codes modal (dialog) + ├── Display generated codes in a grid/list + ├── "Copy codes" button + ├── Warning: "These codes will only be shown once. Save them securely." + └── "I've saved my codes" confirmation button (closes modal) +``` + +**Key implementation details:** + +- Use the settings layout: `SecurityPage.getLayout = getSettingsLayout` +- Create the settings flow on mount using the same pattern as `change-password.page.tsx`: + + ```typescript + oryKratosClient.createBrowserSettingsFlow() + ``` + +- Filter UI nodes by `group` to render each section: + + ```typescript + const totpNodes = flow.ui.nodes.filter((node) => node.group === "totp"); + const lookupNodes = flow.ui.nodes.filter((node) => node.group === "lookup_secret"); + const passwordNodes = flow.ui.nodes.filter((node) => node.group === "password"); + ``` + +- For the QR code: Find the node where `attributes.id === "totp_qr"` or `attributes.node_type === "img"`. The `attributes.src` contains a `data:image/png;base64,...` URI. Render it as an `` tag. +- For the secret key: Find the text node with `attributes.id === "totp_secret_key"`. Display it for manual entry into authenticator apps. +- For TOTP setup submission: + + ```typescript + oryKratosClient.updateSettingsFlow({ + flow: flow.id, + updateSettingsFlowBody: { + method: "totp", + totp_code: enteredCode, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }, + }) + ``` + +- For TOTP unlink: The user must enter a current TOTP code or a backup code to confirm disabling. First verify the code (submit with `method: "totp"` and the entered code to validate it), then submit with `method: "totp"` and `totp_unlink: true` to remove the TOTP credential. This prevents accidental or unauthorized disabling of MFA. +- **Backup codes modal:** After TOTP is successfully enabled, automatically open a modal dialog showing the generated backup codes. The modal should: + - Display the codes from the `lookup_secret_codes` text node in the settings flow response + - Include a "Copy codes" button for convenience + - Show a warning: "These codes will only be shown once. Save them securely." + - Include an "I've saved my codes" button that submits `method: "lookup_secret"` with `lookup_secret_confirm: true` and closes the modal +- For regenerating backup codes: Submit with `method: "lookup_secret"` and `lookup_secret_regenerate: true`. This invalidates all previous backup codes. Display the new codes in the same modal pattern. Regeneration requires a privileged session (within the 15-minute window). +- After each submission, re-fetch the settings flow to update the UI +- **Important:** Kratos requires a privileged session (recently authenticated) to modify security settings. If the session is too old, Kratos returns a `session_refresh_required` error with a `redirect_browser_to` URL. The existing `useKratosErrorHandler` already handles this (line 52-60 in `use-kratos-flow-error-handler.ts`). + +**Style:** Use `SettingsPageContainer` from `apps/hash-frontend/src/pages/settings/shared/settings-page-container.tsx` for consistent styling with other settings pages. + +**Reference:** The existing `change-password.page.tsx` demonstrates the Kratos settings flow pattern (create flow, submit with method, error handling). Extend this pattern to support multiple methods. + +#### Step 5: Add "Security" to settings sidebar + +**File:** `apps/hash-frontend/src/pages/shared/settings-layout.tsx` + +Add a "Security" item to `generateMenuLinks()` at line 46. The commented-out "Personal info" line shows where account-level items should go: + +```typescript +const menuItems: SidebarItemData[] = [ + // { label: "Personal info", href: "/settings/personal" }, + { + label: "Security", + href: "/settings/security", + icon: /* ShieldIcon or LockIcon */, + }, + { + label: "Organizations", + href: "/settings/organizations", + icon: PeopleGroupIcon, + }, + // ...existing items +]; +``` + +Pick an appropriate icon from the existing icon set in `apps/hash-frontend/src/shared/icons/` (e.g., a shield or lock icon). Check what's available before creating a new one. + +#### Step 6: Keep /change-password for recovery, duplicate password UI in /settings/security + +**File:** `apps/hash-frontend/src/pages/change-password.page.tsx` + +Keep `/change-password` as-is for unauthenticated password recovery flows. Kratos redirects recovered users to the settings `ui_url` to set a new password, and the user may not be fully authenticated when they arrive. The `/change-password` page uses `getPlainLayout` (no settings sidebar), which is appropriate for this case. + +The password change UI should also be included in the new `/settings/security` page so that authenticated users can change their password from settings. Password changes work in both contexts because they use the same Kratos settings flow. + +--- + +### Frontend: Login Flow for AAL2 + +#### Step 7: Update signin page to handle AAL2 flows + +**File:** `apps/hash-frontend/src/pages/signin.page.tsx` + +The signin page already passes `aal` to `createBrowserLoginFlow()` (line 135), but the UI only renders email+password inputs. When `aal=aal2`, Kratos returns a login flow with `totp` and `lookup_secret` UI node groups instead of `password`. + +**Changes:** + +a) **Detect AAL2 flow:** Check the flow's `requested_aal` field or check for the presence of `totp` group nodes: + +```typescript +const isAal2Flow = flow?.requested_aal === "aal2" + || flow?.ui.nodes.some((node) => node.group === "totp"); +``` + +b) **Render TOTP input when AAL2:** + +```typescript +{isAal2Flow ? ( + setShowLookupSecretInput(true)} + /> +) : ( + // existing email+password form +)} +``` + +c) **TOTP login submission:** + +```typescript +const handleTotpSubmit = (totpCode: string) => { + oryKratosClient.updateLoginFlow({ + flow: flow.id, + updateLoginFlowBody: { + method: "totp", + totp_code: totpCode, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }, + }) + .then(async () => { + const { authenticatedUser } = await refetch(); + // ...redirect to home + }) + .catch(handleFlowError); +}; +``` + +d) **Backup code fallback:** Show a "Use a backup code" link that switches the input to accept a lookup secret instead: + +```typescript +oryKratosClient.updateLoginFlow({ + flow: flow.id, + updateLoginFlowBody: { + method: "lookup_secret", + lookup_secret: enteredBackupCode, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }, +}) +``` + +e) **UI for AAL2 step:** + +- Heading: "Enter your authentication code" +- Text: "Open your authenticator app and enter the code" +- 6-digit code input field +- Submit button +- "Use a backup code instead" link (toggles to a different input) +- Error messages from flow UI nodes +- Use the same `AuthPaper` / `AuthLayout` styling as the password step + +Use judgment based on complexity: if the AAL2 rendering logic is small, keep it inline in `signin.page.tsx`. If it grows beyond a handful of components (QR code input, backup code toggle, error handling), extract it into a separate `signin-totp-step.tsx` file. + +#### Step 8: Handle post-password-login redirect to AAL2 + +**File:** `apps/hash-frontend/src/pages/signin.page.tsx` + +Currently, after successful password login (line 183-195), the page calls `refetch()` and redirects home. But if the user has TOTP enabled and `required_aal: highest_available`, `refetch()` will fail (because `toSession()` in the auth context returns 403). + +**Fix:** After password login succeeds, check if AAL2 is needed before redirecting: + +```typescript +.then(async ({ data: loginResponse }) => { + // Check if AAL2 is required via continue_with actions + const aal2Action = loginResponse.continue_with?.find( + (action) => action.action === "show_verification_ui" + || (action.action === "redirect_browser_to" + && action.redirect_browser_to?.includes("aal=aal2")), + ); + + if (aal2Action?.redirect_browser_to) { + // Redirect to AAL2 login flow + void router.push(aal2Action.redirect_browser_to); + return; + } + + // No AAL2 needed, proceed normally + const { authenticatedUser } = await refetch(); + // ...redirect +}) +``` + +Alternatively, a simpler approach: after password login, try calling `toSession()`. If it returns 403, extract the `redirect_browser_to` URL and redirect: + +```typescript +.then(async () => { + try { + await oryKratosClient.toSession(); + // Session is valid, no AAL2 needed + const { authenticatedUser } = await refetch(); + void router.push(returnTo ?? "/"); + } catch (err) { + if (err.response?.status === 403) { + // AAL2 required -- Kratos response includes redirect URL + const redirectTo = err.response.data?.redirect_browser_to; + if (redirectTo) { + void router.push(redirectTo); + return; + } + } + throw err; + } +}) +``` + +--- + +### Frontend: Auth Context AAL2 Handling + +#### Step 9: Handle 403 from `toSession()` in `AuthInfoProvider` + +**File:** `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` + +After the email verification plan changes, the auth context calls `oryKratosClient.toSession()` to get verifiable addresses. If the user has TOTP and only AAL1, this call returns 403. + +Currently, the `.catch(() => undefined)` swallows the 403, meaning the user appears unauthenticated after password login (before AAL2). This causes confusing behavior. + +**Fix:** Distinguish between "no session" and "AAL2 required": + +```typescript +oryKratosClient + .toSession() + .then(({ data }) => ({ session: data, aal2Required: false })) + .catch((err: AxiosError) => { + if (err.response?.status === 403) { + return { session: undefined, aal2Required: true }; + } + return { session: undefined, aal2Required: false }; + }); +``` + +Expose `aal2Required` in the auth context value so components can react: + +```typescript +type AuthInfoContextValue = { + authenticatedUser?: User; + isInstanceAdmin: boolean | undefined; + aal2Required: boolean; // NEW + refetch: RefetchAuthInfoFunction; +}; +``` + +Pages can then check `aal2Required` and redirect to `/signin?aal=aal2` if needed. The `_app.page.tsx` or a layout component could handle this globally. + +--- + +### API: Auth Middleware + +#### Step 10: Resolve TODO in auth middleware for 403 handling + +**File:** `apps/hash-api/src/auth/create-auth-handlers.ts` + +The current behavior (catching 403 and returning `undefined` session) is actually correct for the API: + +- Users with AAL1 when AAL2 is required should be treated as unauthenticated for API access +- This prevents access to protected resources until AAL2 is completed +- The frontend handles the UX of redirecting to the AAL2 login flow + +**Change:** Replace the TODO comment with a clear explanation and log at debug level: + +```typescript +.catch((err: AxiosError) => { + if (err.response && err.response.status === 403) { + // User has a session but hasn't completed required AAL2 (2FA). + // Treat as unauthenticated -- the frontend handles redirecting + // to the AAL2 login flow. + logger.debug( + "Session requires AAL2 but only has AAL1. Treating as unauthenticated.", + ); + } + // ...existing debug logging for other errors + return undefined; +}); +``` + +This applies to both occurrences (line 121 and line 209) in the file. + +--- + +## Playwright Tests + +### Step 11: Add MFA test utilities + +**File:** `tests/hash-playwright/tests/shared/totp-utils.ts` (new file) + +Helper for generating TOTP codes in tests. Use the `otplib` or `otpauth` npm package to generate valid TOTP codes from the secret key displayed during setup: + +```typescript +import { authenticator } from "otplib"; + +/** + * Generates a TOTP code from a secret key. + * Used in tests to simulate authenticator app behavior. + */ +export const generateTotpCode = (secret: string): string => { + return authenticator.generate(secret); +}; +``` + +Add `otplib` (or equivalent) as a dev dependency of the Playwright test package. + +### Step 12: Add MFA test cases + +**File:** `tests/hash-playwright/tests/mfa.spec.ts` (new file) + +**a) User can enable TOTP in settings:** + +```typescript +test("user can enable TOTP", async ({ page }) => { + // Log in as existing user + // Navigate to /settings/security + // Extract TOTP secret from the page + // Generate a valid TOTP code using otplib + // Enter the code and submit + // Expect "TOTP enabled" or disable button to appear + // Expect backup codes to be shown +}); +``` + +**b) User with TOTP must enter code at login:** + +```typescript +test("user with TOTP is prompted for code at login", async ({ page }) => { + // Enable TOTP for user (reuse setup from test above or use API) + // Log out + // Log in with email+password + // Expect TOTP code input to appear + // Generate and enter valid TOTP code + // Expect successful login +}); +``` + +**c) User can log in with backup code:** + +```typescript +test("user can use backup code instead of TOTP", async ({ page }) => { + // Enable TOTP + save backup codes + // Log out and log in with password + // Click "Use a backup code" + // Enter one of the backup codes + // Expect successful login +}); +``` + +**d) User can disable TOTP:** + +```typescript +test("user can disable TOTP", async ({ page }) => { + // Enable TOTP for user + // Navigate to /settings/security + // Click "Disable TOTP" + // TOTP should be disabled + // Log out and log in -- should not be prompted for TOTP code +}); +``` + +**e) Wrong TOTP code shows error:** + +```typescript +test("wrong TOTP code shows error at login", async ({ page }) => { + // Enable TOTP, log out, log in with password + // Enter wrong TOTP code + // Expect error message +}); +``` + +--- + +## Files Changed Summary + +### Kratos Config (2 files) + +| File | Change | +|------|--------| +| `apps/hash-external-services/kratos/kratos.dev.yml` | Enable `totp` and `lookup_secret` methods; set `session.whoami.required_aal: highest_available`; update settings `ui_url` to `/settings/security` | +| `apps/hash-external-services/kratos/kratos.prod.yml` | Same method additions and AAL config | + +### Frontend (4-5 files) + +| File | Change | +|------|--------| +| `apps/hash-frontend/src/pages/settings/security.page.tsx` | **New file** -- security settings page with TOTP setup/disable, backup codes, password change | +| `apps/hash-frontend/src/pages/shared/settings-layout.tsx` | Add "Security" item to settings sidebar | +| `apps/hash-frontend/src/pages/signin.page.tsx` | Handle AAL2 flows: show TOTP input + backup code option when `aal=aal2`; handle post-password redirect to AAL2 | +| `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` | Handle 403 from `toSession()`; expose `aal2Required` flag | +| `apps/hash-frontend/src/pages/signin.page/signin-totp-step.tsx` | **New file** (if needed) -- TOTP code input component for the login AAL2 step; extract only if inline logic in `signin.page.tsx` grows too complex | + +### API (1 file) + +| File | Change | +|------|--------| +| `apps/hash-api/src/auth/create-auth-handlers.ts` | Replace TODO with proper comment; keep 403-as-unauthenticated behavior | + +### Tests (2 files) + +| File | Change | +|------|--------| +| `tests/hash-playwright/tests/shared/totp-utils.ts` | **New file** -- TOTP code generation helper using `otplib` | +| `tests/hash-playwright/tests/mfa.spec.ts` | **New file** -- MFA test cases (enable, login, backup codes, disable) | + +--- + +## Key Reference Files + +| File | Why | +|------|-----| +| `apps/hash-frontend/src/pages/change-password.page.tsx` | Existing settings flow implementation -- shows the pattern for creating/submitting Kratos settings flows | +| `apps/hash-frontend/src/pages/signin.page.tsx` | Current login flow -- already has `aal` parameter support, needs AAL2 UI | +| `apps/hash-frontend/src/pages/shared/use-kratos-flow-error-handler.ts` | Already handles `session_aal2_required` and `session_refresh_required` errors | +| `apps/hash-frontend/src/pages/shared/ory-kratos.ts` | Kratos client + helpers (`mustGetCsrfTokenFromFlow`, `gatherUiNodeValuesFromFlow`) | +| `apps/hash-frontend/src/pages/shared/settings-layout.tsx` | Settings sidebar configuration -- add "Security" link here | +| `apps/hash-frontend/src/pages/settings/shared/settings-page-container.tsx` | Container component for settings pages -- use for consistent styling | +| `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` | Auth context -- needs `aal2Required` flag | +| `apps/hash-api/src/auth/create-auth-handlers.ts` | Auth middleware with TODO about 403/2FA handling | +| `apps/hash-external-services/kratos/kratos.dev.yml` | Kratos dev config to update | +| `apps/hash-external-services/kratos/identity.schema.json` | Identity schema -- no changes needed (TOTP credentials are stored separately by Kratos, not in traits) | + +--- + +## Notes + +- **TOTP credentials are not stored in the identity schema.** Kratos manages them internally as credentials alongside the password credential. No changes to `identity.schema.json` are needed. +- **Privileged sessions:** Kratos requires a recently authenticated session to change security settings (TOTP, password). The `privileged_session_max_age` is kept at its current 15-minute default. If the session is too old, Kratos returns `session_refresh_required` with a redirect to re-authenticate. The existing error handler already handles this. +- **Backup codes are single-use.** Once a code is used for login, it's consumed. The user should be warned to regenerate codes when they're running low. +- **The `@ory/client` package** (already a dependency) provides all the types needed: `SettingsFlow`, `LoginFlow`, `UiNode`, etc. +- **Kratos v1.2.0** fully supports TOTP and lookup_secret methods. No version upgrade needed. +- **The `/change-password` page is kept** for account recovery flows (Kratos redirects recovered users to the settings `ui_url` to set a new password). Password change UI is duplicated in `/settings/security` for authenticated users. The Kratos settings `ui_url` is updated to `/settings/security`, but `/change-password` remains as a standalone page with `getPlainLayout` for recovery flows where the user may not be fully authenticated. +- **QR code rendering:** The TOTP QR code is provided by Kratos as a `data:image/png;base64,...` URI in the `totp_qr` UI node. It can be rendered directly as an ``. No QR code generation library is needed on the frontend. +- **TOTP issuer name:** The `issuer: HASH` config in Kratos determines what name appears in the authenticator app (e.g., "HASH: user@example.com"). Choose a clear, recognizable name. +- **Testing TOTP:** The `otplib` package can generate valid TOTP codes from a secret. In Playwright tests, extract the secret from the settings page and use `otplib` to generate the current code. This avoids needing to simulate QR code scanning. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000000..b2b4221af72 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,696 @@ +# Email Verification Step in Signup Flow + +Add an email verification step to the signup flow between registration (email+password) and account setup (username). After registering, users see a "verify your email" screen with a code input and a resend button, and can only proceed once their email is verified. The API also enforces this server-side to prevent bypassing the frontend. + +## Table of Contents + +- [Current State](#current-state) +- [Target State](#target-state) +- [Architecture Context](#architecture-context) +- [Implementation Steps](#implementation-steps) + - [Frontend: Auth Context and User Type](#frontend-auth-context-and-user-type) + - [Frontend: Signup Page Changes](#frontend-signup-page-changes) + - [API: Server-Side Enforcement](#api-server-side-enforcement) +- [Playwright Tests](#playwright-tests) +- [Files Changed Summary](#files-changed-summary) +- [Key Reference Files](#key-reference-files) +- [Notes](#notes) + +--- + +## Current State + +The signup flow is: **Register (email+password) -> Account Setup (username/display name) -> Done**. + +Email verification was partially scaffolded but left disabled: + +- `apps/hash-frontend/src/pages/signup.page.tsx` line 150: `userHasVerifiedEmail` is hardcoded to `true` +- `apps/hash-frontend/src/pages/signup.page/signup-steps.tsx` lines 20-25: `"verify-email"` step is commented out +- `apps/hash-frontend/src/pages/signup.page.tsx` line 188: placeholder `null` where the verification form should render +- `apps/hash-frontend/src/lib/user-and-org.ts` line 555: `verified` is hardcoded to `false` in `constructUser`, with a TODO at line 375 +- `apps/hash-frontend/src/pages/verification.page.tsx`: standalone verification page exists but is disconnected from signup +- `apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts`: no email verification check before allowing signup completion + +Kratos is already configured for code-based verification (`selfservice.flows.verification.use: code`, `enabled: true`, 48h lifespan) and has email templates ready at `apps/hash-external-services/kratos/templates/verification_code/`. + +## Target State + +**Register (email+password) -> Verify Email (enter code) -> Account Setup (username) -> Done** + +```mermaid +flowchart TD + A[User visits /signup] --> B[SignupRegistrationForm] + B -->|"Email + password submitted"| C[Kratos creates identity + session] + C --> D[webhook creates HASH user] + D --> E{Email verified?} + E -->|No| F[VerifyEmailStep] + F -->|"Create verification flow + submit email"| G[Kratos sends code via email] + G --> H[User enters code] + H -->|"Submit code to Kratos"| I{Code valid?} + I -->|Yes| J[Email marked verified] + I -->|No| H + J --> E + E -->|Yes| K[AccountSetupForm] + K --> L[Done - redirect to /] + F -->|"Resend button"| G +``` + +--- + +## Architecture Context + +### How auth works + +- `apps/hash-frontend` is the Next.js client. It communicates with Ory Kratos via `oryKratosClient` (defined in `apps/hash-frontend/src/pages/shared/ory-kratos.ts`), which points to `{apiOrigin}/auth`. +- `apps/hash-api` proxies all `/auth/*` requests to Kratos's public API (see `apps/hash-api/src/index.ts` lines 179-223 for the proxy setup, mounted at line 436). +- Kratos is configured in `apps/hash-external-services/kratos/`. The dev config is `kratos.dev.yml`, prod is `kratos.prod.yml`. Both use the same identity schema at `identity.schema.json`. +- After registration, Kratos fires a webhook to `POST /kratos-after-registration` on the API, which creates the HASH user entity. Then a `session` hook creates a Kratos session (the user is logged in). + +### How the Kratos verification flow works + +The Ory Kratos verification flow is a two-step process using the code method: + +1. **Create a verification flow:** + + ```typescript + const { data: flow } = await oryKratosClient.createBrowserVerificationFlow(); + ``` + +2. **Submit the email to trigger sending the code:** + + ```typescript + await oryKratosClient.updateVerificationFlow({ + flow: flow.id, + updateVerificationFlowBody: { + method: "code", + email: userEmail, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }, + }); + ``` + + Kratos sends a 6-digit code to the email address. The email uses the template at `apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl`. + +3. **Submit the code to verify:** + + ```typescript + await oryKratosClient.updateVerificationFlow({ + flow: flow.id, + updateVerificationFlowBody: { + method: "code", + code: userEnteredCode, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }, + }); + ``` + + On success, Kratos marks the address as verified in `identity.verifiable_addresses`. + +4. **Checking verification status:** The Kratos session (retrieved via `oryKratosClient.toSession()`) includes `identity.verifiable_addresses`, each of which has a `verified: boolean` field. + +The existing standalone `apps/hash-frontend/src/pages/verification.page.tsx` already demonstrates this flow -- use it as a reference. + +### How signup completion works + +- `isAccountSignupComplete` (API-side, `apps/hash-api/src/graph/knowledge/system-types/user.ts` line 162) is defined as `!!shortname && !!displayName`. +- The `userBeforeEntityUpdateHookCallback` (in `apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts` line 146) is the server-side gate. When an incomplete user sets shortname+displayName, it checks `userHasAccessToHash` and then grants web ownership. **This is where the email verification check must be added.** +- Many parts of the API and frontend gate functionality behind `isAccountSignupComplete` / `accountSignupComplete`. + +### Email infrastructure (relevant to tests) + +There are two separate email systems: + +- **API emails** use `DummyEmailTransporter` in dev/test, which writes to `var/api/dummy-email-transporter/email-dumps.yml`. The existing Playwright helper `getDerivedPayloadFromMostRecentEmail` reads from this file. +- **Kratos emails** (including verification codes) are sent via SMTP to **mailslurper** (Docker service). Mailslurper exposes: port 1025 (SMTP), port 4436 (web UI), port 4437 (REST API). See `apps/hash-external-services/docker-compose.dev.yml` lines 98-102. + +These are completely separate. The new verification codes are sent by Kratos, so tests need to read from mailslurper, not the YAML file. + +--- + +## Implementation Steps + +### Frontend: Auth Context and User Type + +#### Step 1: Update `constructUser` to populate `verified` from Kratos + +**File:** `apps/hash-frontend/src/lib/user-and-org.ts` + +Currently `verified` is hardcoded to `false` at line 555, and there's a commented-out TODO at line 375 showing the intended approach. `constructUser` is called from 3 places: + +1. `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` -- authenticated user (has Kratos session available) +2. `apps/hash-frontend/src/pages/@/[shortname].page.tsx` -- other user's profile page (no Kratos session) +3. `apps/hash-frontend/src/components/hooks/use-users-with-links.ts` -- user list (no Kratos session) + +Only call site 1 has access to the Kratos session. + +**Changes:** + +a) Add an optional `verifiableAddresses` parameter to `constructUser`: + +```typescript +import type { VerifiableIdentityAddress } from "@ory/client"; + +export const constructUser = (params: { + orgMembershipLinks?: LinkEntity[]; + subgraph: Subgraph>; + resolvedOrgs?: Org[]; + userEntity: Entity; + verifiableAddresses?: VerifiableIdentityAddress[]; // NEW +}): User => { + // ...existing code... + + // Replace the commented-out TODO at line 375 with: + const isPrimaryEmailAddressVerified = + params.verifiableAddresses?.find( + ({ value }) => value === primaryEmailAddress, + )?.verified === true; + + // ...existing code... + + // At line 555, replace `verified: false` with: + emails: [ + { + address: primaryEmailAddress, + verified: isPrimaryEmailAddressVerified, + primary: true, + }, + ], +``` + +b) The `VerifiableIdentityAddress` type from `@ory/client` has this shape (for reference): + +```typescript +interface VerifiableIdentityAddress { + id: string; + status: string; + value: string; // the email address + verified: boolean; + via: string; // "email" + // ...timestamps etc +} +``` + +#### Step 2: Fetch Kratos session in `AuthInfoProvider` + +**File:** `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` + +The `AuthInfoProvider` fetches the authenticated user via the GraphQL `meQuery`. It needs to also fetch the Kratos session so it can pass `verifiableAddresses` to `constructUser`. + +**Changes:** + +- Import `oryKratosClient` from `../shared/ory-kratos` +- Add state for verifiable addresses: `const [verifiableAddresses, setVerifiableAddresses] = useState([])` +- In `fetchAuthenticatedUser`, fetch the Kratos session in parallel with the user subgraph: + +```typescript +const fetchAuthenticatedUser = useCallback(async () => { + const [subgraph, kratosSession] = await Promise.all([ + apolloClient.query({ + query: meQuery, + fetchPolicy: "network-only", + }) + .then(({ data }) => + mapGqlSubgraphFieldsFragmentToSubgraph>( + data.me.subgraph, + ), + ) + .catch(() => undefined), + + oryKratosClient + .toSession() + .then(({ data }) => data) + .catch(() => undefined), + ]); + + const newVerifiableAddresses = + kratosSession?.identity.verifiable_addresses ?? []; + setVerifiableAddresses(newVerifiableAddresses); + + if (!subgraph) { + setAuthenticatedUserSubgraph(undefined); + return {}; + } + + setAuthenticatedUserSubgraph(subgraph); + + return { authenticatedUser: constructUserValue(subgraph) }; +}, [constructUserValue, apolloClient]); +``` + +- Update the `constructUserValue` memoized callback to pass `verifiableAddresses`: + +```typescript +const constructUserValue = useCallback( + (subgraph: Subgraph> | undefined) => { + if (!subgraph) { + return undefined; + } + const userEntity = getRoots(subgraph)[0]!; + // ...existing entity type check... + return constructUser({ + orgMembershipLinks: userMemberOfLinks, + subgraph, + resolvedOrgs, + userEntity, + verifiableAddresses, // NEW + }); + }, + [resolvedOrgs, userMemberOfLinks, verifiableAddresses], +); +``` + +After this change, `authenticatedUser.emails[0].verified` will reflect the real Kratos verification status everywhere in the app. + +--- + +### Frontend: Signup Page Changes + +#### Step 3: Create the `VerifyEmailStep` component + +**File:** `apps/hash-frontend/src/pages/signup.page/verify-email-step.tsx` (new file) + +This component handles the verification flow inline within the signup page. + +**Props:** + +```typescript +interface VerifyEmailStepProps { + email: string; + onVerified: () => void; +} +``` + +**Behavior:** + +- **On mount:** Create a Kratos verification flow and immediately submit the user's email to trigger sending the code (see [Architecture Context](#how-the-kratos-verification-flow-works) above for the API calls). +- **UI elements:** + - Heading: "Verify your email address" + - Message: "We've sent a verification code to {email}" + - Code input field (text, 6 digits) + - Submit button + - "Resend verification email" button -- creates a new verification flow and resubmits the email + - Error/status messages from Kratos flow UI nodes +- **On code submit:** Call `oryKratosClient.updateVerificationFlow()` with the code +- **On success:** Call `onVerified` callback prop +- **Style:** Use `AuthPaper` and `AuthHeading` for consistency with the existing registration form (see `apps/hash-frontend/src/pages/signup.page/signup-registration-form.tsx`) + +**Reference implementation:** The existing standalone `apps/hash-frontend/src/pages/verification.page.tsx` demonstrates the Kratos verification flow mechanics. Reuse the same pattern but adapted for inline use. Key patterns to borrow: + +- `createBrowserVerificationFlow` / `getVerificationFlow` for initialization +- `updateVerificationFlow` for submission +- `isUiNodeInputAttributes` for extracting form nodes +- `useKratosErrorHandler` for error handling + +#### Step 4: Wire `VerifyEmailStep` into `signup.page.tsx` + +**File:** `apps/hash-frontend/src/pages/signup.page.tsx` + +a) **Un-comment the verification check** at line 147-150. Replace: + +```typescript +/** @todo: un-comment this to actually check whether the email is verified */ +// const userHasVerifiedEmail = +// authenticatedUser?.emails.find(({ verified }) => verified) !== undefined; +const userHasVerifiedEmail = true; +``` + +With: + +```typescript +const userHasVerifiedEmail = + authenticatedUser?.emails.find(({ verified }) => verified) !== undefined; +``` + +b) **Render `VerifyEmailStep`** when the user is authenticated but not verified. Replace the conditional at line 182-189: + +```typescript +// Current (line 182-189): +userHasVerifiedEmail ? ( + +) : /** @todo: add verification form */ +null + +// New: +userHasVerifiedEmail ? ( + +) : ( + refetchAuthenticatedUser()} + /> +) +``` + +The `onVerified` callback calls `refetchAuthenticatedUser()` from the auth context, which re-fetches both the user subgraph and the Kratos session, updating `authenticatedUser.emails[0].verified` and triggering the transition to `AccountSetupForm`. + +c) **Update step tracking.** Update the `currentStep` prop passed to `SignupSteps`: + +```typescript +currentStep={ + invitation && !authenticatedUser + ? "accept-invitation" + : !userHasVerifiedEmail + ? "verify-email" + : "reserve-username" +} +``` + +#### Step 5: Un-comment the "verify-email" step in `signup-steps.tsx` + +**File:** `apps/hash-frontend/src/pages/signup.page/signup-steps.tsx` + +- Un-comment lines 21-25 to add the `"verify-email"` step back to `stepsWithoutInvitation` +- Add `"verify-email"` to the `StepName` union type at line 12 +- The steps will now be 3 (verify email, reserve username, start using HASH). Check if a `Circle4RegularIcon` exists for the invitation flow variant (4 steps). If not, either create one or adjust the icon mapping. + +#### Edge case: user returns with unverified email + +If a user registers but doesn't verify, then later returns to `/signup`: + +- The auth middleware identifies them via session cookie +- `authenticatedUser` is defined, `userHasVerifiedEmail` is false +- The `VerifyEmailStep` is shown, which creates a new verification flow and sends a fresh code +- This is the correct behavior -- no special handling needed + +--- + +### API: Server-Side Enforcement + +#### Step 6: Add `isUserEmailVerified` helper + +**File:** `apps/hash-api/src/auth/ory-kratos.ts` + +Add a helper function that checks Kratos for email verification status: + +```typescript +export const isUserEmailVerified = async ( + kratosIdentityId: string, +): Promise => { + const { data: identity } = await kratosIdentityApi.getIdentity({ + id: kratosIdentityId, + }); + return ( + identity.verifiable_addresses?.some((addr) => addr.verified) ?? false + ); +}; +``` + +This uses the existing `kratosIdentityApi` (admin API, already imported in the file) which has access to identity details including `verifiable_addresses`. + +#### Step 7: Add email verification gate in signup completion + +**File:** `apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts` + +This is the critical server-side enforcement. Currently at line 146, when an incomplete user sets shortname+displayName (completing signup), the hook only checks `userHasAccessToHash`. Add an email verification check: + +```typescript +import { isUserEmailVerified } from "../../../../../auth/ory-kratos"; + +// ... existing code ... + +const isIncompleteUser = !user.isAccountSignupComplete; + +if (isIncompleteUser && updatedShortname && updatedDisplayName) { + if (!(await userHasAccessToHash(context, authentication, user))) { + throw Error.forbidden( + "The user does not have access to the HASH instance, and therefore cannot complete account signup.", + ); + } + + // NEW: Check email is verified before allowing signup completion + if (!(await isUserEmailVerified(user.kratosIdentityId))) { + throw Error.forbidden( + "You must verify your email address before completing account setup.", + ); + } + + // Now that the user has completed signup, we can transfer the ownership of the web + await addActorGroupAdministrator( + context.graphApi, + { actorId: systemAccountId }, + { actorId: user.accountId, actorGroupId: user.accountId }, + ); +} +``` + +This prevents a user from completing signup via a direct GraphQL mutation without first verifying their email. + +--- + +## Playwright Tests + +### Step 8: Add `getKratosVerificationCode` helper + +**File:** `tests/hash-playwright/tests/shared/get-kratos-verification-code.ts` (new file) + +Kratos sends verification emails via SMTP to mailslurper (not the API's `DummyEmailTransporter`). Mailslurper exposes a REST API on port 4437 (see `apps/hash-external-services/docker-compose.dev.yml` lines 98-102). We need a helper to query this API. + +```typescript +/** + * Reads the most recent verification code from Kratos-sent emails + * via the mailslurper API. + * + * Mailslurper exposes its API on port 4437 in the dev docker-compose setup. + * Kratos verification emails use the template at: + * apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl + * which contains: "entering the following code:\n{{ .VerificationCode }}" + * + * Kratos replaces {{ .VerificationCode }} with a 6-digit numeric code. + */ +export const getKratosVerificationCode = async ( + emailAddress: string, + afterTimestamp?: number, +): Promise => { + const maxWaitMs = 10_000; + const pollIntervalMs = 250; + let elapsed = 0; + + while (elapsed < maxWaitMs) { + const response = await fetch("http://localhost:4437/mail"); + const data = await response.json(); + + // mailslurper response shape: { mailItems: [...], totalRecords, totalPages } + const matchingEmail = data.mailItems?.find( + (item: { toAddresses: string[]; subject: string; dateSent: string }) => + item.toAddresses?.includes(emailAddress) && + item.subject === "Please verify your email address" && + (!afterTimestamp || + new Date(item.dateSent).getTime() >= afterTimestamp), + ); + + if (matchingEmail) { + // Extract 6-digit code from email body + const codeMatch = matchingEmail.body.match( + /following code:.*?(\d{6})/s, + ); + if (codeMatch?.[1]) { + return codeMatch[1]; + } + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + elapsed += pollIntervalMs; + } + + throw new Error( + `No verification email found for ${emailAddress} within ${maxWaitMs}ms`, + ); +}; +``` + +**Important:** Verify the exact mailslurper API response shape and the regex against the actual email template. The subject line comes from `apps/hash-external-services/kratos/templates/verification_code/valid/email.subject.gotmpl` (currently "Please verify your email address"). The code pattern comes from `email.body.gotmpl`. + +### Step 9: Update `signup.spec.ts` + +**File:** `tests/hash-playwright/tests/signup.spec.ts` + +The existing "user can sign up" test will **break** because it expects to see "Thanks for confirming your account" immediately after registration. After the changes, the verification step will appear first. + +**Updated test:** + +```typescript +import { getKratosVerificationCode } from "./shared/get-kratos-verification-code"; + +test("user can sign up", async ({ page }) => { + await page.goto("/signup"); + + const randomNumber = Math.floor(Math.random() * 10_000) + .toString() + .padEnd(4, "0"); + const email = `${randomNumber}@example.com`; + + // Step 1: Register + await page.fill('[placeholder="Enter your email address"]', email); + await page.fill('[type="password"]', "some-complex-pw-1ab2"); + + const emailDispatchTimestamp = Date.now(); + await page.click("text=Sign up"); + + // Step 2: Verify email + await expect( + page.locator("text=Verify your email address"), + ).toBeVisible(); + + const verificationCode = await getKratosVerificationCode( + email, + emailDispatchTimestamp, + ); + + await page.fill( + '[placeholder="Enter your verification code"]', + verificationCode, + ); + await page.click("text=Verify"); + + // Step 3: Account setup + await expect( + page.locator("text=Thanks for confirming your account"), + ).toBeVisible(); + + await page.fill('[placeholder="example"]', randomNumber.toString()); + await page.fill('[placeholder="Jonathan Smith"]', "New User"); + await page.click("text=Continue"); + + await page.waitForURL("/"); + await expect(page.locator("text=Get support")).toBeVisible(); +}); +``` + +### Step 10: Add new test cases + +**File:** `tests/hash-playwright/tests/signup.spec.ts` (add to existing file) + +**a) Wrong verification code shows error:** + +```typescript +test("wrong verification code shows error", async ({ page }) => { + // Register a user (same setup as the main test)... + + // On verification step, enter wrong code + await page.fill('[placeholder="Enter your verification code"]', "000000"); + await page.click("text=Verify"); + + // Expect error message from Kratos + await expect( + page.locator("text=/code is invalid|code is expired/i"), + ).toBeVisible(); + + // Should still be on verification step + await expect( + page.locator("text=Verify your email address"), + ).toBeVisible(); +}); +``` + +**b) Resend verification email:** + +```typescript +test("can resend verification email", async ({ page }) => { + // Register a user... + // On verification step, click resend + const resendTimestamp = Date.now(); + await page.click("text=Resend verification email"); + + // Get new code from the resent email + const newCode = await getKratosVerificationCode(email, resendTimestamp); + + // Enter new code and verify + await page.fill('[placeholder="Enter your verification code"]', newCode); + await page.click("text=Verify"); + + // Should proceed to account setup + await expect( + page.locator("text=Thanks for confirming your account"), + ).toBeVisible(); +}); +``` + +**c) Returning user with unverified email sees verification step:** + +```typescript +test("returning user with unverified email sees verification step", async ({ + page, +}) => { + // Register a user... + // Verification step appears + await expect( + page.locator("text=Verify your email address"), + ).toBeVisible(); + + // Navigate away + await page.goto("/"); + // Return to signup -- should still see verification + await page.goto("/signup"); + + await expect( + page.locator("text=Verify your email address"), + ).toBeVisible(); +}); +``` + +**Note:** Consider extracting the registration setup into a helper function to avoid duplication across tests. + +--- + +## Files Changed Summary + +### Frontend (5 files) + +| File | Change | +|------|--------| +| `apps/hash-frontend/src/lib/user-and-org.ts` | Add optional `verifiableAddresses` param to `constructUser`; populate `verified` from it instead of hardcoding `false` | +| `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` | Fetch Kratos session in parallel with user subgraph; pass `verifiableAddresses` to `constructUser` | +| `apps/hash-frontend/src/pages/signup.page.tsx` | Un-comment verification check; render `VerifyEmailStep` when unverified; update step tracking | +| `apps/hash-frontend/src/pages/signup.page/verify-email-step.tsx` | **New file** -- inline verification code form component | +| `apps/hash-frontend/src/pages/signup.page/signup-steps.tsx` | Un-comment `"verify-email"` step; add to `StepName` type | + +### API (2 files) + +| File | Change | +|------|--------| +| `apps/hash-api/src/auth/ory-kratos.ts` | Add `isUserEmailVerified` helper function | +| `apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts` | Add email verification check before allowing signup completion | + +### Tests (2 files) + +| File | Change | +|------|--------| +| `tests/hash-playwright/tests/shared/get-kratos-verification-code.ts` | **New file** -- helper to read Kratos verification codes from mailslurper | +| `tests/hash-playwright/tests/signup.spec.ts` | Update existing test; add 3 new test cases | + +### No Kratos config changes needed + +- Kratos already has verification enabled with the `code` method, 48h lifespan, email templates, and `ui_url: http://localhost:3000/verification` +- The API already proxies all `/auth/*` requests to Kratos, which includes verification flow endpoints +- The frontend's `oryKratosClient` can create and update verification flows through the existing proxy + +--- + +## Key Reference Files + +These files are useful context for the implementer: + +| File | Why | +|------|-----| +| `apps/hash-frontend/src/pages/verification.page.tsx` | Existing standalone verification page -- demonstrates the Kratos verification flow mechanics (create flow, submit email, submit code, error handling) | +| `apps/hash-frontend/src/pages/signup.page/signup-registration-form.tsx` | The registration form -- shows the style/pattern for auth forms (AuthPaper, AuthHeading, TextField, error handling) | +| `apps/hash-frontend/src/pages/shared/ory-kratos.ts` | Kratos client setup and helpers (`oryKratosClient`, `mustGetCsrfTokenFromFlow`, `gatherUiNodeValuesFromFlow`) | +| `apps/hash-frontend/src/pages/shared/use-kratos-flow-error-handler.ts` | Error handler for Kratos flows -- reuse in VerifyEmailStep | +| `apps/hash-external-services/kratos/kratos.dev.yml` | Kratos dev config -- shows verification flow settings (lines 75-80) | +| `apps/hash-external-services/kratos/identity.schema.json` | Identity schema -- shows email verification config (`"verification": { "via": "email" }`) | +| `apps/hash-external-services/kratos/templates/verification_code/` | Email templates for verification codes (valid and invalid) | +| `apps/hash-api/src/auth/ory-kratos.ts` | Kratos API client setup (public + admin APIs) | +| `apps/hash-api/src/auth/create-auth-handlers.ts` | Auth middleware and after-registration webhook handler | +| `apps/hash-api/src/graph/knowledge/system-types/user.ts` | API User type (`emails: string[]`, `kratosIdentityId`, `isAccountSignupComplete`) | +| `apps/hash-external-services/docker-compose.dev.yml` | Mailslurper port mappings (lines 98-102) | +| `tests/hash-playwright/tests/shared/get-derived-payload-from-most-recent-email.ts` | Existing email helper for API emails (pattern to follow for the mailslurper helper) | + +--- + +## Notes + +- The `@ory/client` package (already a dependency) provides the `VerifiableIdentityAddress` type and the `FrontendApi` methods used throughout. +- The Kratos admin API (`kratosIdentityApi`) is used server-side only. The frontend uses the public API (`oryKratosClient`) which is proxied through the Node API. +- The `loginUsingTempForm` Playwright helper (used by many tests) uses email+password login and is unaffected by these changes. The `loginUsingUi` helper uses a code-based login flow via the API's DummyEmailTransporter and is also unaffected. +- The Playwright tests run with `workers: 1` because concurrent tests break login. This remains true after these changes. +- Verification codes have a 48-hour lifespan (configured in Kratos). The "Resend" button on the frontend creates an entirely new verification flow, invalidating any previous code. From 64ac604b2a516bf595ee1cc2e79eeb8a295b61d8 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Tue, 10 Feb 2026 13:13:37 +0000 Subject: [PATCH 02/32] wip: add email verification requirement, optional TOTP MFA --- .env.test | 2 + .../hash-api/src/auth/create-auth-handlers.ts | 47 +- .../create-unverified-email-cleanup-job.ts | 211 +++++ apps/hash-api/src/auth/ory-kratos.ts | 12 + apps/hash-api/src/express.d.ts | 1 + ...user-before-update-entity-hook-callback.ts | 7 + apps/hash-api/src/index.ts | 109 ++- .../docker-compose.prod.yml | 2 +- .../kratos/kratos.dev.yml | 10 +- .../kratos/kratos.prod.yml | 8 + .../src/components/hooks/use-hash-instance.ts | 10 +- .../components/hooks/use-orgs-with-links.ts | 10 +- apps/hash-frontend/src/lib/user-and-org.ts | 13 +- apps/hash-frontend/src/pages/_app.page.tsx | 101 ++- .../src/pages/settings/security.page.tsx | 722 ++++++++++++++++++ .../src/pages/shared/auth-info-context.tsx | 118 ++- .../src/pages/shared/auth-utils.ts | 26 +- .../src/pages/shared/settings-layout.tsx | 6 + apps/hash-frontend/src/pages/signin.page.tsx | 366 ++++++--- apps/hash-frontend/src/pages/signup.page.tsx | 22 +- .../src/pages/signup.page/signup-steps.tsx | 19 +- .../pages/signup.page/verify-email-step.tsx | 199 +++++ .../src/pages/verify-email.page.tsx | 65 ++ .../shared/icons/circle-4-regular-icon.tsx | 14 + .../src/shared/layout/plain-layout.tsx | 11 +- tests/hash-playwright/tests/mfa.spec.ts | 208 +++++ .../shared/get-kratos-verification-code.ts | 99 +++ .../tests/shared/totp-utils.ts | 57 ++ tests/hash-playwright/tests/signup.spec.ts | 123 ++- 29 files changed, 2389 insertions(+), 209 deletions(-) create mode 100644 apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts create mode 100644 apps/hash-frontend/src/pages/settings/security.page.tsx create mode 100644 apps/hash-frontend/src/pages/signup.page/verify-email-step.tsx create mode 100644 apps/hash-frontend/src/pages/verify-email.page.tsx create mode 100644 apps/hash-frontend/src/shared/icons/circle-4-regular-icon.tsx create mode 100644 tests/hash-playwright/tests/mfa.spec.ts create mode 100644 tests/hash-playwright/tests/shared/get-kratos-verification-code.ts create mode 100644 tests/hash-playwright/tests/shared/totp-utils.ts diff --git a/.env.test b/.env.test index 91d1b98b7f3..1ee9e5eaeba 100644 --- a/.env.test +++ b/.env.test @@ -16,3 +16,5 @@ AWS_S3_UPLOADS_ACCESS_KEY_ID="dev-s3-access-key-id" AWS_S3_UPLOADS_SECRET_ACCESS_KEY="dev-s3-secret-access-key" AWS_S3_UPLOADS_FORCE_PATH_STYLE=true FILE_UPLOAD_PROVIDER="AWS_S3" + +USER_EMAIL_ALLOW_LIST='["charlie@example.com"]' diff --git a/apps/hash-api/src/auth/create-auth-handlers.ts b/apps/hash-api/src/auth/create-auth-handlers.ts index 1d5feb5d762..bbcb1e64319 100644 --- a/apps/hash-api/src/auth/create-auth-handlers.ts +++ b/apps/hash-api/src/auth/create-auth-handlers.ts @@ -12,7 +12,7 @@ import { createUser, getUser } from "../graph/knowledge/system-types/user"; import { systemAccountId } from "../graph/system-account"; import { hydraAdmin } from "./ory-hydra"; import type { KratosUserIdentity } from "./ory-kratos"; -import { kratosFrontendApi } from "./ory-kratos"; +import { isUserEmailVerified, kratosFrontendApi } from "./ory-kratos"; const KRATOS_API_KEY = getRequiredEnv("KRATOS_API_KEY"); @@ -106,6 +106,7 @@ export const getUserAndSession = async ({ logger: Logger; sessionToken?: string; }): Promise<{ + primaryEmailVerified?: boolean; session?: Session; user?: User; }> => { @@ -118,9 +119,11 @@ export const getUserAndSession = async ({ }) .then(({ data }) => data) .catch((err: AxiosError) => { - // 403 on toSession means that we need to request 2FA if (err.response && err.response.status === 403) { - /** @todo: figure out if this should be handled here, or in the next.js app (when implementing 2FA) */ + logger.debug( + "Session requires AAL2 but only has AAL1. Treating as unauthenticated.", + ); + return undefined; } logger.debug( `Kratos response error: Could not fetch session, got: [${ @@ -139,6 +142,13 @@ export const getUserAndSession = async ({ const { id: kratosIdentityId, traits } = identity as KratosUserIdentity; + const primaryEmailAddress = traits.emails[0]; + + const primaryEmailVerified = + identity.verifiable_addresses?.find( + ({ value }) => value === primaryEmailAddress, + )?.verified === true; + const user = await getUser(context, authentication, { kratosIdentityId, emails: traits.emails, @@ -150,7 +160,7 @@ export const getUserAndSession = async ({ ); } - return { session: kratosSession, user }; + return { primaryEmailVerified, session: kratosSession, user }; } return {}; @@ -185,6 +195,9 @@ export const createAuthMiddleware = (params: { }, ); if (user) { + req.primaryEmailVerified = await isUserEmailVerified( + user.kratosIdentityId, + ); req.user = user; next(); return; @@ -192,35 +205,15 @@ export const createAuthMiddleware = (params: { } } - const { session, user } = await getUserAndSession({ + const { primaryEmailVerified, session, user } = await getUserAndSession({ context, cookie: req.header("cookie"), logger, sessionToken: accessOrSessionToken, }); - - const kratosSession = await kratosFrontendApi - .toSession({ - cookie: req.header("cookie"), - xSessionToken: accessOrSessionToken, - }) - .then(({ data }) => data) - .catch((err: AxiosError) => { - // 403 on toSession means that we need to request 2FA - if (err.response && err.response.status === 403) { - /** @todo: figure out if this should be handled here, or in the next.js app (when implementing 2FA) */ - } - logger.debug( - `Kratos response error: Could not fetch session, got: [${ - err.response?.status - }] ${JSON.stringify(err.response?.data)}`, - ); - return undefined; - }); - - if (kratosSession) { + if (session) { + req.primaryEmailVerified = primaryEmailVerified; req.session = session; - req.user = user; } diff --git a/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts b/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts new file mode 100644 index 00000000000..4c1cd86d980 --- /dev/null +++ b/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts @@ -0,0 +1,211 @@ +import type { Logger } from "@local/hash-backend-utils/logger"; +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { + currentTimeInstantTemporalAxes, + generateVersionedUrlMatchingFilter, +} from "@local/hash-isomorphic-utils/graph-queries"; +import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; +import type { User as UserEntity } from "@local/hash-isomorphic-utils/system-types/user"; +import type { Identity } from "@ory/kratos-client"; + +import type { ImpureGraphContext } from "../graph/context-types"; +import { getUserFromEntity } from "../graph/knowledge/system-types/user"; +import { systemAccountId } from "../graph/system-account"; +import { deleteKratosIdentity, kratosIdentityApi } from "./ory-kratos"; + +const DEFAULT_ROLLOUT_AT = new Date("2026-02-10T00:00:00.000Z"); +const DEFAULT_RELEASE_TTL_HOURS = 24 * 14; +const DEFAULT_SWEEP_INTERVAL_MINUTES = 60; + +const parsePositiveIntegerEnv = ( + rawValue: string | undefined, + fallback: number, + envVarName: string, +) => { + if (!rawValue) { + return fallback; + } + + const parsedValue = Number.parseInt(rawValue, 10); + if (Number.isNaN(parsedValue) || parsedValue <= 0) { + throw new Error( + `${envVarName} must be a positive integer, got "${rawValue}"`, + ); + } + + return parsedValue; +}; + +const parseRolloutDate = (rawValue: string | undefined): Date => { + if (!rawValue) { + return DEFAULT_ROLLOUT_AT; + } + + const parsedDate = new Date(rawValue); + if (Number.isNaN(parsedDate.getTime())) { + throw new Error( + `HASH_EMAIL_VERIFICATION_ROLLOUT_AT must be an ISO-8601 date, got "${rawValue}"`, + ); + } + + return parsedDate; +}; + +const parseIdentityCreatedAt = (identity: Identity): Date | undefined => { + if (!identity.created_at) { + return undefined; + } + + const createdAt = new Date(identity.created_at); + + if (Number.isNaN(createdAt.getTime())) { + return undefined; + } + + return createdAt; +}; + +const isPrimaryEmailVerified = ( + identity: Identity, + fallbackPrimaryEmailAddress: string, +): boolean => { + const identityTraits = identity.traits as { emails?: string[] }; + const primaryEmailAddress = + identityTraits.emails?.[0] ?? fallbackPrimaryEmailAddress; + + return ( + identity.verifiable_addresses?.find( + ({ value }) => value === primaryEmailAddress, + )?.verified === true + ); +}; + +export const createUnverifiedEmailCleanupJob = ({ + context, + logger, +}: { + context: ImpureGraphContext; + logger: Logger; +}) => { + const rolloutAt = parseRolloutDate( + process.env.HASH_EMAIL_VERIFICATION_ROLLOUT_AT, + ); + + const releaseTtlHours = parsePositiveIntegerEnv( + process.env.HASH_EMAIL_VERIFICATION_RELEASE_TTL_HOURS, + DEFAULT_RELEASE_TTL_HOURS, + "HASH_EMAIL_VERIFICATION_RELEASE_TTL_HOURS", + ); + + const sweepIntervalMinutes = parsePositiveIntegerEnv( + process.env.HASH_EMAIL_VERIFICATION_RELEASE_SWEEP_INTERVAL_MINUTES, + DEFAULT_SWEEP_INTERVAL_MINUTES, + "HASH_EMAIL_VERIFICATION_RELEASE_SWEEP_INTERVAL_MINUTES", + ); + + const releaseTtlMs = releaseTtlHours * 60 * 60 * 1_000; + const sweepIntervalMs = sweepIntervalMinutes * 60 * 1_000; + + const cleanupUnverifiedUsers = async () => { + const now = Date.now(); + const authentication = { actorId: systemAccountId }; + + const { entities: userEntities } = await queryEntities( + context, + authentication, + { + filter: { + all: [ + generateVersionedUrlMatchingFilter( + systemEntityTypes.user.entityTypeId, + { + ignoreParents: true, + }, + ), + { + equal: [{ path: ["archived"] }, { parameter: false }], + }, + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includePermissions: false, + }, + ); + + let releasedEmailCount = 0; + + for (const userEntity of userEntities) { + const user = getUserFromEntity({ entity: userEntity }); + + if (user.isAccountSignupComplete) { + continue; + } + + try { + const { data: identity } = await kratosIdentityApi.getIdentity({ + id: user.kratosIdentityId, + }); + + const createdAt = parseIdentityCreatedAt(identity); + if (!createdAt || createdAt < rolloutAt) { + continue; + } + + if (now - createdAt.getTime() < releaseTtlMs) { + continue; + } + + if (isPrimaryEmailVerified(identity, user.emails[0]!)) { + continue; + } + + await Promise.all([ + user.entity.archive( + context.graphApi, + authentication, + context.provenance, + ), + deleteKratosIdentity({ kratosIdentityId: user.kratosIdentityId }), + ]); + + releasedEmailCount += 1; + } catch (error) { + logger.warn( + `Failed to process unverified user ${user.accountId} (${user.kratosIdentityId}) for email release: ${error}`, + ); + } + } + + if (releasedEmailCount > 0) { + logger.info( + `Released ${releasedEmailCount} unverified email address${releasedEmailCount === 1 ? "" : "es"}.`, + ); + } + }; + + let interval: NodeJS.Timeout | undefined; + + return { + start: async () => { + logger.info( + [ + "Starting unverified-email cleanup job", + `(rolloutAt=${rolloutAt.toISOString()}`, + `ttlHours=${releaseTtlHours}`, + `intervalMinutes=${sweepIntervalMinutes})`, + ].join(" "), + ); + + await cleanupUnverifiedUsers(); + interval = setInterval(() => { + void cleanupUnverifiedUsers(); + }, sweepIntervalMs); + }, + stop: async () => { + if (interval) { + clearInterval(interval); + } + }, + }; +}; diff --git a/apps/hash-api/src/auth/ory-kratos.ts b/apps/hash-api/src/auth/ory-kratos.ts index 5057401c5b7..ed8aef4b7cb 100644 --- a/apps/hash-api/src/auth/ory-kratos.ts +++ b/apps/hash-api/src/auth/ory-kratos.ts @@ -46,3 +46,15 @@ export const deleteKratosIdentity = async (params: { id: params.kratosIdentityId, }); }; + +export const isUserEmailVerified = async ( + kratosIdentityId: string, +): Promise => { + const { data: identity } = await kratosIdentityApi.getIdentity({ + id: kratosIdentityId, + }); + + return ( + identity.verifiable_addresses?.some(({ verified }) => verified) ?? false + ); +}; diff --git a/apps/hash-api/src/express.d.ts b/apps/hash-api/src/express.d.ts index 4c0bb22050e..96bee9b7d84 100644 --- a/apps/hash-api/src/express.d.ts +++ b/apps/hash-api/src/express.d.ts @@ -10,6 +10,7 @@ declare global { context: ImpureGraphContext & { vaultClient?: VaultClient; }; + primaryEmailVerified: boolean | undefined; session: Session | undefined; user: User | undefined; } diff --git a/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts b/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts index 255c26bc7a4..68149c92059 100644 --- a/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts +++ b/apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts @@ -8,6 +8,7 @@ import { isUserHashInstanceAdmin } from "@local/hash-graph-sdk/principal/hash-in import type { UserProperties } from "@local/hash-isomorphic-utils/system-types/user"; import { GraphQLError } from "graphql"; +import { isUserEmailVerified } from "../../../../../auth/ory-kratos"; import * as Error from "../../../../../graphql/error"; import { userHasAccessToHash } from "../../../../../shared/user-has-access-to-hash"; import type { ImpureGraphContext } from "../../../../context-types"; @@ -155,6 +156,12 @@ export const userBeforeEntityUpdateHookCallback: BeforeUpdateEntityHookCallback ); } + if (!(await isUserEmailVerified(user.kratosIdentityId))) { + throw Error.forbidden( + "You must verify your email address before completing account setup.", + ); + } + // Now that the user has completed signup, we can transfer the ownership of the web // allowing them to create entities and types. await addActorGroupAdministrator( diff --git a/apps/hash-api/src/index.ts b/apps/hash-api/src/index.ts index 6622803e38d..f869560d333 100644 --- a/apps/hash-api/src/index.ts +++ b/apps/hash-api/src/index.ts @@ -27,11 +27,22 @@ import bodyParser from "body-parser"; import cors from "cors"; import { Effect, Exit, Layer, Logger, LogLevel, ManagedRuntime } from "effect"; import { RuntimeException } from "effect/Cause"; -import type { ErrorRequestHandler, Request, Response } from "express"; +import type { + ErrorRequestHandler, + Request, + RequestHandler, + Response, +} from "express"; import express, { raw } from "express"; import { create as handlebarsCreate } from "express-handlebars"; import type { Options as RateLimitOptions } from "express-rate-limit"; import { ipKeyGenerator, rateLimit } from "express-rate-limit"; +import { + Kind, + type OperationDefinitionNode, + OperationTypeNode, + parse, +} from "graphql"; import helmet from "helmet"; import { StatsD } from "hot-shots"; import { @@ -52,6 +63,7 @@ import { addKratosAfterRegistrationHandler, createAuthMiddleware, } from "./auth/create-auth-handlers"; +import { createUnverifiedEmailCleanupJob } from "./auth/create-unverified-email-cleanup-job"; import { getActorIdFromRequest } from "./auth/get-actor-id"; import { oauthConsentRequestHandler, @@ -77,7 +89,13 @@ import { GRAPHQL_PATH, LOCAL_FILE_UPLOAD_PATH, } from "./lib/config"; -import { isDevEnv, isProdEnv, isStatsDEnabled, port } from "./lib/env-config"; +import { + isDevEnv, + isProdEnv, + isStatsDEnabled, + isTestEnv, + port, +} from "./lib/env-config"; import { logger } from "./logger"; import { seedOrgsAndUsers } from "./seed-data"; import { @@ -121,6 +139,40 @@ const userIdentifierRateLimiter = rateLimit({ }, }); +const unverifiedUserPermittedGraphQLOperations = new Set([ + "me", + "hasAccessToHash", +]); + +const unverifiedUserErrorMessage = + "You must verify your primary email address before using HASH."; + +const isPermittedGraphQLOperationForUnverifiedUser = (req: Request) => { + const operationName = req.body?.operationName; + const query = req.body?.query; + + if ( + typeof operationName !== "string" || + typeof query !== "string" || + !unverifiedUserPermittedGraphQLOperations.has(operationName) + ) { + return false; + } + + try { + const parsedDocument = parse(query); + const operation = parsedDocument.definitions.find( + (definition): definition is OperationDefinitionNode => + definition.kind === Kind.OPERATION_DEFINITION && + definition.name?.value === operationName, + ); + + return operation?.operation === OperationTypeNode.QUERY; + } catch { + return false; + } +}; + const hydraProxy = createProxyMiddleware({ target: hydraPublicUrl ?? "", pathRewrite: (_, req) => req.originalUrl, @@ -464,6 +516,24 @@ const main = async () => { }); app.use(authMiddleware); + const enforceVerifiedPrimaryEmail: RequestHandler = (req, res, next) => { + if (!req.user || req.primaryEmailVerified !== false) { + next(); + return; + } + + if (req.path === GRAPHQL_PATH || req.path === "/health-check") { + next(); + return; + } + + res.status(403).json({ + message: unverifiedUserErrorMessage, + }); + }; + + app.use(enforceVerifiedPrimaryEmail); + /** * Add scope to Sentry, now the user has been checked. * We could set some of this scope earlier, but it doesn't get picked up for GraphQL requests for some reason @@ -708,10 +778,31 @@ const main = async () => { // Start the Apollo GraphQL server. shutdown.addCleanup("ApolloServer", async () => apolloServer.stop()); + const enforceVerifiedPrimaryEmailForGraphQl: RequestHandler = ( + req, + res, + next, + ) => { + if (!req.user || req.primaryEmailVerified !== false) { + next(); + return; + } + + if (isPermittedGraphQLOperationForUnverifiedUser(req)) { + next(); + return; + } + + res.status(403).json({ + errors: [{ message: unverifiedUserErrorMessage }], + }); + }; + app.use( GRAPHQL_PATH, cors(CORS_CONFIG), express.json(), + enforceVerifiedPrimaryEmailForGraphQl, apolloMiddleware, ); @@ -726,6 +817,20 @@ const main = async () => { }); }); + if (!isTestEnv) { + const unverifiedEmailCleanupJob = createUnverifiedEmailCleanupJob({ + context: machineActorContext, + logger, + }); + + await unverifiedEmailCleanupJob.start(); + + shutdown.addCleanup( + "Unverified email cleanup job", + unverifiedEmailCleanupJob.stop, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (realtimeSyncEnabled && enabledIntegrations.linear) { if (!vaultClient) { diff --git a/apps/hash-external-services/docker-compose.prod.yml b/apps/hash-external-services/docker-compose.prod.yml index aaf3188a71b..b139ff36d7e 100644 --- a/apps/hash-external-services/docker-compose.prod.yml +++ b/apps/hash-external-services/docker-compose.prod.yml @@ -30,7 +30,7 @@ services: SELFSERVICE_METHODS_LINK_CONFIG_BASE_URL: "${FRONTEND_URL}/api/ory" SELFSERVICE_FLOWS_VERIFICATION_UI_URL: "${FRONTEND_URL}/verification" SELFSERVICE_FLOWS_RECOVERY_UI_URL: "${FRONTEND_URL}/recovery" - SELFSERVICE_FLOWS_SETTINGS_UI_URL: "${FRONTEND_URL}/settings" + SELFSERVICE_FLOWS_SETTINGS_UI_URL: "${FRONTEND_URL}/settings/security" LOG_LEAK_SENSITIVE_VALUES: "false" COURIER_SMTP_FROM_ADDRESS: "noreply@hash.ai" COURIER_SMTP_FROM_NAME: "HASH" diff --git a/apps/hash-external-services/kratos/kratos.dev.yml b/apps/hash-external-services/kratos/kratos.dev.yml index 85436137dc4..e99448163de 100644 --- a/apps/hash-external-services/kratos/kratos.dev.yml +++ b/apps/hash-external-services/kratos/kratos.dev.yml @@ -11,6 +11,8 @@ serve: session: # Let sessions live for 3 years lifespan: 26280h # 24 h * 365 days * 3 years + whoami: + required_aal: highest_available selfservice: default_browser_return_url: http://localhost:3000/ @@ -32,6 +34,12 @@ selfservice: config: # and make sure to enable the code method. enabled: true + totp: + config: + issuer: HASH + enabled: true + lookup_secret: + enabled: true flows: error: @@ -87,7 +95,7 @@ selfservice: settings: # Set through SELFSERVICE_FLOWS_SETTINGS_UI_URL - ui_url: http://localhost:3000/change-password + ui_url: http://localhost:3000/settings/security log: level: debug diff --git a/apps/hash-external-services/kratos/kratos.prod.yml b/apps/hash-external-services/kratos/kratos.prod.yml index 0530b5df6d5..20292a2c85a 100644 --- a/apps/hash-external-services/kratos/kratos.prod.yml +++ b/apps/hash-external-services/kratos/kratos.prod.yml @@ -13,6 +13,8 @@ serve: session: # Let sessions live for 3 years lifespan: 26280h # 24 h * 365 days * 3 years + whoami: + required_aal: highest_available selfservice: # Set `default_browser_return_url` through the `SELFSERVICE_DEFAULT_BROWSER_RETURN_URL` environment variable @@ -32,6 +34,12 @@ selfservice: config: # and make sure to enable the code method. enabled: true + totp: + config: + issuer: HASH + enabled: true + lookup_secret: + enabled: true flows: error: diff --git a/apps/hash-frontend/src/components/hooks/use-hash-instance.ts b/apps/hash-frontend/src/components/hooks/use-hash-instance.ts index 6c23f7869a1..f2548fb16fe 100644 --- a/apps/hash-frontend/src/components/hooks/use-hash-instance.ts +++ b/apps/hash-frontend/src/components/hooks/use-hash-instance.ts @@ -12,10 +12,11 @@ import type { } from "../../graphql/api-types.gen"; import { getHashInstanceSettings } from "../../graphql/queries/knowledge/hash-instance.queries"; -/** - * Retrieves the HASH instance. - */ -export const useHashInstance = (): { +export const useHashInstance = ({ + skip = false, +}: { + skip?: boolean; +} = {}): { loading: boolean; hashInstance?: Simplified>; isUserAdmin: boolean; @@ -26,6 +27,7 @@ export const useHashInstance = (): { GetHashInstanceSettingsQueryQueryVariables >(getHashInstanceSettings, { fetchPolicy: "cache-and-network", + skip, }); const { hashInstanceSettings } = data ?? {}; diff --git a/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts b/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts index a289eb77882..5c806559c4b 100644 --- a/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts +++ b/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts @@ -18,6 +18,8 @@ import { queryEntitySubgraphQuery } from "../../graphql/queries/knowledge/entity import type { Org } from "../../lib/user-and-org"; import { constructOrg, isEntityOrgEntity } from "../../lib/user-and-org"; +const EMPTY_ORGS: Org[] = []; + /** * Retrieves a specific set of organizations, with their avatars and members populated */ @@ -98,6 +100,10 @@ export const useOrgsWithLinks = ({ const { queryEntitySubgraph: queryEntitySubgraphResponse } = data ?? {}; const orgs = useMemo(() => { + if (orgAccountGroupIds?.length === 0) { + return EMPTY_ORGS; + } + if (!queryEntitySubgraphResponse) { return undefined; } @@ -114,11 +120,11 @@ export const useOrgsWithLinks = ({ } return constructOrg({ subgraph, orgEntity }); }); - }, [queryEntitySubgraphResponse]); + }, [orgAccountGroupIds, queryEntitySubgraphResponse]); return { loading, - orgs: orgAccountGroupIds && orgAccountGroupIds.length === 0 ? [] : orgs, + orgs, refetch, }; }; diff --git a/apps/hash-frontend/src/lib/user-and-org.ts b/apps/hash-frontend/src/lib/user-and-org.ts index 90f951485ff..b5c48e061be 100644 --- a/apps/hash-frontend/src/lib/user-and-org.ts +++ b/apps/hash-frontend/src/lib/user-and-org.ts @@ -46,6 +46,7 @@ import type { ServiceAccount, } from "@local/hash-isomorphic-utils/system-types/shared"; import type { User as UserEntity } from "@local/hash-isomorphic-utils/system-types/user"; +import type { VerifiableIdentityAddress } from "@ory/client"; import type { UserPreferences } from "../shared/use-user-preferences"; @@ -364,6 +365,7 @@ export const constructUser = (params: { subgraph: Subgraph>; resolvedOrgs?: Org[]; userEntity: Entity; + verifiableAddresses?: VerifiableIdentityAddress[]; }): User => { const { orgMembershipLinks, resolvedOrgs, subgraph, userEntity } = params; @@ -372,11 +374,10 @@ export const constructUser = (params: { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- permissions means this may be undefined. @todo types to account for property-level permissions const primaryEmailAddress = email?.[0] ?? ""; - // @todo implement email verification - // const isPrimaryEmailAddressVerified = - // params.kratosSession.identity.verifiable_addresses?.find( - // ({ value }) => value === primaryEmailAddress, - // )?.verified === true; + const isPrimaryEmailAddressVerified = + params.verifiableAddresses?.find( + ({ value }) => value === primaryEmailAddress, + )?.verified === true; const minimalUser = constructMinimalUser({ userEntity }); @@ -552,7 +553,7 @@ export const constructUser = (params: { emails: [ { address: primaryEmailAddress, - verified: false, + verified: isPrimaryEmailAddressVerified, primary: true, }, ], diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index 312ea81905d..07d7a869978 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -55,6 +55,7 @@ import { redirectInGetInitialProps } from "./shared/_app.util"; import { AuthInfoProvider, useAuthInfo } from "./shared/auth-info-context"; import { DataTypesContextProvider } from "./shared/data-types-context"; import { maintenanceRoute } from "./shared/maintenance"; +import { type IdentityTraits, oryKratosClient } from "./shared/ory-kratos"; import { setSentryUser } from "./shared/sentry"; import { SlideStackProvider } from "./shared/slide-stack"; import { WorkspaceContextProvider } from "./shared/workspace-context"; @@ -72,6 +73,8 @@ type AppProps = { } & AppInitialProps & NextAppProps; +const unverifiedUserPermittedPagePathnames = ["/verify-email", "/verification"]; + const App: FunctionComponent = ({ Component, pageProps, @@ -90,7 +93,29 @@ const App: FunctionComponent = ({ setSsr(false); }, []); - const { authenticatedUser } = useAuthInfo(); + const { aal2Required, authenticatedUser, emailVerificationStatusKnown } = + useAuthInfo(); + + const primaryEmailVerified = + authenticatedUser?.emails.find(({ primary }) => primary)?.verified ?? false; + const userMustVerifyEmail = + !!authenticatedUser && + emailVerificationStatusKnown && + !primaryEmailVerified; + const awaitingEmailVerificationStatus = + !!authenticatedUser && !emailVerificationStatusKnown && !aal2Required; + + useEffect(() => { + if ( + !router.isReady || + !userMustVerifyEmail || + unverifiedUserPermittedPagePathnames.includes(router.pathname) + ) { + return; + } + + void router.replace("/verify-email"); + }, [router, userMustVerifyEmail]); useEffect(() => { setSentryUser({ authenticatedUser }); @@ -100,12 +125,48 @@ const App: FunctionComponent = ({ // router.query is empty during server-side rendering for pages that don’t use // getServerSideProps. By showing app skeleton on the server, we avoid UI // mismatches during rehydration and improve type-safety of param extraction. - if (ssr || !router.isReady) { + if (ssr || !router.isReady || awaitingEmailVerificationStatus) { return ; // Replace with app skeleton } const getLayout = Component.getLayout ?? getPlainLayout; + if ( + userMustVerifyEmail && + !unverifiedUserPermittedPagePathnames.includes(router.pathname) + ) { + return ; + } + + if (userMustVerifyEmail) { + return ( + + + + + + {getLayout()} + + + + + + ); + } + return ( @@ -196,12 +257,38 @@ const publiclyAccessiblePagePathnames = [ "/[shortname]/[page-slug]", "/signin", "/signup", + "/verify-email", "/recovery", "/", ]; const redirectIfAuthenticatedPathnames = ["/signup"]; +const getPrimaryEmailVerificationStatus = async (cookie?: string) => + oryKratosClient + .toSession({ cookie }) + .then(({ data }) => { + const identity = data.identity; + + if (!identity) { + return undefined; + } + + const identityTraits = identity.traits as IdentityTraits; + const primaryEmailAddress = identityTraits.emails[0]; + + if (!primaryEmailAddress) { + return false; + } + + return ( + identity.verifiable_addresses?.find( + ({ value }) => value === primaryEmailAddress, + )?.verified === true + ); + }) + .catch(() => undefined); + /** * A map from a feature flag, to the list of pages which should not be accessible * if that feature flag is not enabled for the user. @@ -268,6 +355,16 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { const user = constructMinimalUser({ userEntity }); + const primaryEmailVerified = await getPrimaryEmailVerificationStatus(cookie); + + if (primaryEmailVerified === false && pathname !== "/verify-email") { + redirectInGetInitialProps({ appContext, location: "/verify-email" }); + return { initialAuthenticatedUserSubgraph, user }; + } else if (primaryEmailVerified === true && pathname === "/verify-email") { + redirectInGetInitialProps({ appContext, location: "/" }); + return { initialAuthenticatedUserSubgraph, user }; + } + // If the user is logged in but hasn't completed signup... if (!user.accountSignupComplete) { const hasAccessToHash = await apolloClient diff --git a/apps/hash-frontend/src/pages/settings/security.page.tsx b/apps/hash-frontend/src/pages/settings/security.page.tsx new file mode 100644 index 00000000000..aaecbe1d3b0 --- /dev/null +++ b/apps/hash-frontend/src/pages/settings/security.page.tsx @@ -0,0 +1,722 @@ +import { Modal, TextField } from "@hashintel/design-system"; +import { Box, Divider, Grid, Typography } from "@mui/material"; +import type { SettingsFlow, UpdateSettingsFlowBody } from "@ory/client"; +import { + isUiNodeImageAttributes, + isUiNodeInputAttributes, + isUiNodeTextAttributes, +} from "@ory/integrations/ui"; +import type { AxiosError } from "axios"; +import { useRouter } from "next/router"; +import type { FormEventHandler } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import type { NextPageWithLayout } from "../../shared/layout"; +import { Button } from "../../shared/ui"; +import { useAuthInfo } from "../shared/auth-info-context"; +import { + mustGetCsrfTokenFromFlow, + oryKratosClient, +} from "../shared/ory-kratos"; +import { getSettingsLayout } from "../shared/settings-layout"; +import { useKratosErrorHandler } from "../shared/use-kratos-flow-error-handler"; +import { SettingsPageContainer } from "./shared/settings-page-container"; + +const getUiTextValue = (text: unknown): string | undefined => { + if (typeof text === "string") { + return text; + } + + if ( + typeof text === "object" && + text !== null && + "text" in text && + typeof (text as { text?: unknown }).text === "string" + ) { + return (text as { text: string }).text; + } + + return undefined; +}; + +const extractBackupCodesFromFlow = (flow: SettingsFlow): string[] => { + let codesText: string | undefined; + + for (const { group, attributes } of flow.ui.nodes) { + if ( + group === "lookup_secret" && + isUiNodeTextAttributes(attributes) && + attributes.id === "lookup_secret_codes" + ) { + codesText = getUiTextValue(attributes.text); + break; + } + } + + if (!codesText) { + return []; + } + + const normalizedText = codesText + .replace(//gi, "\n") + .replace(/<\/?[^>]+>/g, ""); + + const regexMatches = normalizedText.match(/[A-Z0-9]{4}(?:-[A-Z0-9]{4})+/gi); + if (regexMatches?.length) { + return regexMatches; + } + + return normalizedText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +}; + +const SecurityPage: NextPageWithLayout = () => { + const router = useRouter(); + const { flow: flowId } = router.query; + const { authenticatedUser } = useAuthInfo(); + const usernameForPasswordManagers = + authenticatedUser?.emails[0]?.address ?? ""; + + const [flow, setFlow] = useState(); + const [password, setPassword] = useState(""); + const [totpCode, setTotpCode] = useState(""); + const [disableTotpCode, setDisableTotpCode] = useState(""); + const [showTotpSetupForm, setShowTotpSetupForm] = useState(false); + const [showTotpDisableForm, setShowTotpDisableForm] = useState(false); + const [backupCodes, setBackupCodes] = useState([]); + const [showBackupCodesModal, setShowBackupCodesModal] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + const [updatingPassword, setUpdatingPassword] = useState(false); + const [enablingTotp, setEnablingTotp] = useState(false); + const [disablingTotp, setDisablingTotp] = useState(false); + const [regeneratingBackupCodes, setRegeneratingBackupCodes] = useState(false); + const [confirmingBackupCodes, setConfirmingBackupCodes] = useState(false); + + const { handleFlowError } = useKratosErrorHandler({ + flowType: "settings", + setFlow, + setErrorMessage, + }); + + const persistFlowIdInUrl = useCallback( + (settingsFlow: SettingsFlow) => { + void router.push( + { + pathname: "/settings/security", + query: { flow: settingsFlow.id }, + }, + undefined, + { shallow: true }, + ); + }, + [router], + ); + + const submitSettingsUpdate = useCallback( + async ( + currentFlow: SettingsFlow, + updateSettingsFlowBody: UpdateSettingsFlowBody, + ): Promise => + oryKratosClient + .updateSettingsFlow({ + flow: String(currentFlow.id), + updateSettingsFlowBody, + }) + .then(({ data }) => { + setFlow(data); + return data; + }) + .catch(handleFlowError) + .catch((error: AxiosError) => { + if (error.response?.status === 400) { + setFlow(error.response.data); + return undefined; + } + + return Promise.reject(error); + }), + [handleFlowError], + ); + + useEffect(() => { + if (!router.isReady || flow) { + return; + } + + if (flowId) { + oryKratosClient + .getSettingsFlow({ id: String(flowId) }) + .then(({ data }) => setFlow(data)) + .catch(handleFlowError); + return; + } + + oryKratosClient + .createBrowserSettingsFlow() + .then(({ data }) => setFlow(data)) + .catch(handleFlowError); + }, [flow, flowId, handleFlowError, router.isReady]); + + const passwordInputUiNode = useMemo( + () => + flow?.ui.nodes.find( + ({ group, attributes }) => + group === "password" && + isUiNodeInputAttributes(attributes) && + attributes.name === "password", + ), + [flow], + ); + + const totpNodes = useMemo( + () => flow?.ui.nodes.filter(({ group }) => group === "totp") ?? [], + [flow], + ); + + const totpCodeUiNode = useMemo( + () => + totpNodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && + attributes.name === "totp_code", + ), + [totpNodes], + ); + + const isTotpEnabled = useMemo( + () => + totpNodes.some( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && + attributes.name === "totp_unlink", + ), + [totpNodes], + ); + + useEffect(() => { + if (isTotpEnabled) { + setShowTotpSetupForm(false); + return; + } + + setShowTotpDisableForm(false); + }, [isTotpEnabled]); + + const totpQrCodeDataUri = useMemo(() => { + for (const { attributes } of totpNodes) { + if ( + isUiNodeImageAttributes(attributes) && + typeof attributes.src === "string" + ) { + return attributes.src; + } + } + + return undefined; + }, [totpNodes]); + + const totpSecretKey = useMemo(() => { + for (const { attributes } of totpNodes) { + if ( + isUiNodeTextAttributes(attributes) && + attributes.id === "totp_secret_key" + ) { + const text = getUiTextValue(attributes.text); + + if (text) { + return text; + } + } + } + + return undefined; + }, [totpNodes]); + + const handlePasswordSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + if (!flow || !password) { + return; + } + + setUpdatingPassword(true); + persistFlowIdInUrl(flow); + + void submitSettingsUpdate(flow, { + method: "password", + password, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }) + .then((nextFlow) => { + if (nextFlow) { + setPassword(""); + } + }) + .finally(() => setUpdatingPassword(false)); + }; + + const handleEnableTotpSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + if (!flow || !totpCode) { + return; + } + + setEnablingTotp(true); + persistFlowIdInUrl(flow); + + void submitSettingsUpdate(flow, { + method: "totp", + totp_code: totpCode, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }) + .then(async (totpEnabledFlow) => { + if (!totpEnabledFlow) { + return; + } + + setShowTotpSetupForm(false); + setTotpCode(""); + + const flowWithBackupCodes = await submitSettingsUpdate( + totpEnabledFlow, + { + method: "lookup_secret", + lookup_secret_regenerate: true, + csrf_token: mustGetCsrfTokenFromFlow(totpEnabledFlow), + }, + ); + + if (!flowWithBackupCodes) { + return; + } + + const regeneratedCodes = + extractBackupCodesFromFlow(flowWithBackupCodes); + + if (regeneratedCodes.length > 0) { + setBackupCodes(regeneratedCodes); + setShowBackupCodesModal(true); + } + }) + .finally(() => setEnablingTotp(false)); + }; + + const handleDisableTotpSubmit: FormEventHandler = ( + event, + ) => { + event.preventDefault(); + + if (!flow || !disableTotpCode) { + return; + } + + setDisablingTotp(true); + persistFlowIdInUrl(flow); + + void submitSettingsUpdate(flow, { + method: "totp", + totp_unlink: true, + totp_code: disableTotpCode, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }) + .then((nextFlow) => { + if (nextFlow) { + setDisableTotpCode(""); + setShowTotpDisableForm(false); + } + }) + .finally(() => setDisablingTotp(false)); + }; + + const handleRegenerateBackupCodes = () => { + if (!flow) { + return; + } + + setRegeneratingBackupCodes(true); + persistFlowIdInUrl(flow); + + void submitSettingsUpdate(flow, { + method: "lookup_secret", + lookup_secret_regenerate: true, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }) + .then((nextFlow) => { + if (!nextFlow) { + return; + } + + const regeneratedCodes = extractBackupCodesFromFlow(nextFlow); + + if (regeneratedCodes.length > 0) { + setBackupCodes(regeneratedCodes); + setShowBackupCodesModal(true); + } + }) + .finally(() => setRegeneratingBackupCodes(false)); + }; + + const handleConfirmBackupCodesSaved = () => { + if (!flow) { + setShowBackupCodesModal(false); + return; + } + + setConfirmingBackupCodes(true); + + void submitSettingsUpdate(flow, { + method: "lookup_secret", + lookup_secret_confirm: true, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }) + .then((nextFlow) => { + if (nextFlow) { + setShowBackupCodesModal(false); + } + }) + .finally(() => setConfirmingBackupCodes(false)); + }; + + return ( + <> + + + + + ({ color: palette.gray[70], mb: 1.5 })} + > + Password + + setPassword(target.value)} + error={ + !!passwordInputUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={passwordInputUiNode?.messages.map(({ id, text }) => ( + {text} + ))} + required + /> + + + + + + + + + ({ color: palette.gray[70] })} + > + Two-factor authentication + + + {isTotpEnabled ? ( + + palette.gray[80] }}> + TOTP is enabled for your account. + + {showTotpDisableForm ? ( + + + setDisableTotpCode(target.value) + } + error={ + !!totpCodeUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={totpCodeUiNode?.messages.map( + ({ id, text }) => ( + {text} + ), + )} + required + inputProps={{ inputMode: "numeric" }} + /> + + + + + + ) : ( + + + + + )} + + ) : showTotpSetupForm ? ( + + palette.gray[80] }}> + Scan the QR code with your authenticator app, then enter the + 6-digit code to enable TOTP. + + {totpQrCodeDataUri ? ( + `1px solid ${palette.gray[30]}`, + }} + /> + ) : ( + palette.gray[70] }}> + QR code unavailable. Use the secret key below for manual + setup. + + )} + {totpSecretKey ? ( + + ({ + color: palette.gray[70], + mb: 0.75, + })} + > + Secret key (manual setup) + + palette.gray[20], + fontFamily: "monospace", + }} + > + {totpSecretKey} + + + ) : null} + setTotpCode(target.value)} + error={ + !!totpCodeUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={totpCodeUiNode?.messages.map(({ id, text }) => ( + {text} + ))} + required + inputProps={{ inputMode: "numeric" }} + /> + + + + + + ) : ( + + palette.gray[80] }}> + TOTP is currently disabled for your account. + + + + + + )} + + + {flow?.ui.messages?.map(({ id, text }) => ( + {text} + ))} + {errorMessage ? {errorMessage} : null} + + + + setShowBackupCodesModal(false)} + > + + + Backup codes + + + These codes will only be shown once. Save them securely. + + + {backupCodes.map((backupCode) => ( + + ({ + border: `1px solid ${palette.gray[30]}`, + borderRadius: 1, + px: 1.5, + py: 1, + background: palette.gray[20], + fontFamily: "monospace", + })} + > + {backupCode} + + + ))} + + + + + + + + + ); +}; + +SecurityPage.getLayout = (page) => getSettingsLayout(page); + +export default SecurityPage; diff --git a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx index ae541a3229e..852b0381793 100644 --- a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx +++ b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx @@ -14,11 +14,14 @@ import { type HashEntity, HashLinkEntity } from "@local/hash-graph-sdk/entity"; import { mapGqlSubgraphFieldsFragmentToSubgraph } from "@local/hash-isomorphic-utils/graph-queries"; import { systemLinkEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import type { IsMemberOf } from "@local/hash-isomorphic-utils/system-types/shared"; +import type { VerifiableIdentityAddress } from "@ory/client"; +import type { AxiosError } from "axios"; import type { FunctionComponent, ReactElement } from "react"; import { createContext, useCallback, useContext, + useEffect, useMemo, useState, } from "react"; @@ -29,13 +32,16 @@ import type { MeQuery } from "../../graphql/api-types.gen"; import { meQuery } from "../../graphql/queries/user.queries"; import type { User } from "../../lib/user-and-org"; import { constructUser, isEntityUserEntity } from "../../lib/user-and-org"; +import { oryKratosClient } from "./ory-kratos"; type RefetchAuthInfoFunction = () => Promise<{ authenticatedUser?: User; }>; type AuthInfoContextValue = { + aal2Required: boolean; authenticatedUser?: User; + emailVerificationStatusKnown: boolean; isInstanceAdmin: boolean | undefined; refetch: RefetchAuthInfoFunction; }; @@ -56,6 +62,12 @@ export const AuthInfoProvider: FunctionComponent = ({ const [authenticatedUserSubgraph, setAuthenticatedUserSubgraph] = useState( initialAuthenticatedUserSubgraph, ); // use the initial server-sent data to start – after that, the client controls the value + const [verifiableAddresses, setVerifiableAddresses] = useState< + VerifiableIdentityAddress[] + >([]); + const [aal2Required, setAal2Required] = useState(false); + const [emailVerificationStatusKnown, setEmailVerificationStatusKnown] = + useState(false); const userMemberOfLinks = useMemo(() => { if (!authenticatedUserSubgraph) { @@ -83,18 +95,30 @@ export const AuthInfoProvider: FunctionComponent = ({ .map((linkEntity) => new HashLinkEntity(linkEntity)); }, [authenticatedUserSubgraph]); + const isAuthenticatedUserPrimaryEmailVerified = verifiableAddresses.some( + ({ verified }) => verified, + ); + + const skipExtraAuthenticatedUserQueries = + !!authenticatedUserSubgraph && + (!emailVerificationStatusKnown || !isAuthenticatedUserPrimaryEmailVerified); + const { orgs: resolvedOrgs, refetch: refetchOrgs } = useOrgsWithLinks({ - orgAccountGroupIds: - userMemberOfLinks?.map( - (link) => - extractEntityUuidFromEntityId( - link.linkData.rightEntityId, - ) as string as ActorGroupEntityUuid, - ) ?? [], + orgAccountGroupIds: skipExtraAuthenticatedUserQueries + ? [] + : (userMemberOfLinks?.map( + (link) => + extractEntityUuidFromEntityId( + link.linkData.rightEntityId, + ) as string as ActorGroupEntityUuid, + ) ?? []), }); const constructUserValue = useCallback( - (subgraph: Subgraph> | undefined) => { + ( + subgraph: Subgraph> | undefined, + suppliedVerifiableAddresses: VerifiableIdentityAddress[], + ) => { if (!subgraph) { return undefined; } @@ -112,6 +136,7 @@ export const AuthInfoProvider: FunctionComponent = ({ subgraph, resolvedOrgs, userEntity, + verifiableAddresses: suppliedVerifiableAddresses, }); }, [resolvedOrgs, userMemberOfLinks], @@ -119,7 +144,9 @@ export const AuthInfoProvider: FunctionComponent = ({ const apolloClient = useApolloClient(); - const { isUserAdmin: isInstanceAdmin } = useHashInstance(); + const { isUserAdmin: isInstanceAdmin } = useHashInstance({ + skip: skipExtraAuthenticatedUserQueries, + }); const fetchAuthenticatedUser = useCallback(async () => { @@ -132,17 +159,42 @@ export const AuthInfoProvider: FunctionComponent = ({ * @see https://linear.app/hash/issue/H-2182/upgrade-apolloclient-to-latest-version-to-fix-uselazyquery-behaviour * @see https://github.com/apollographql/apollo-client/issues/6086 */ - const subgraph = await apolloClient - .query({ - query: meQuery, - fetchPolicy: "network-only", - }) - .then(({ data }) => - mapGqlSubgraphFieldsFragmentToSubgraph>( - data.me.subgraph, - ), - ) - .catch(() => undefined); + const [subgraph, kratosSessionResult] = await Promise.all([ + apolloClient + .query({ + query: meQuery, + fetchPolicy: "network-only", + }) + .then(({ data }) => + mapGqlSubgraphFieldsFragmentToSubgraph>( + data.me.subgraph, + ), + ) + .catch(() => undefined), + oryKratosClient + .toSession() + .then(({ data }) => ({ + aal2Required: false, + emailVerificationStatusKnown: true, + session: data, + })) + .catch((error: AxiosError) => ({ + aal2Required: error.response?.status === 403, + emailVerificationStatusKnown: error.response?.status !== 403, + session: undefined, + })), + ]); + + if (kratosSessionResult.emailVerificationStatusKnown) { + setVerifiableAddresses( + kratosSessionResult.session?.identity?.verifiable_addresses ?? [], + ); + } + + setAal2Required(kratosSessionResult.aal2Required); + setEmailVerificationStatusKnown( + kratosSessionResult.emailVerificationStatusKnown, + ); if (!subgraph) { setAuthenticatedUserSubgraph(undefined); @@ -151,17 +203,28 @@ export const AuthInfoProvider: FunctionComponent = ({ setAuthenticatedUserSubgraph(subgraph); - return { authenticatedUser: constructUserValue(subgraph) }; + const newVerifiableAddresses = + kratosSessionResult.session?.identity?.verifiable_addresses ?? []; + + return { + authenticatedUser: constructUserValue(subgraph, newVerifiableAddresses), + }; }, [constructUserValue, apolloClient]); + useEffect(() => { + void fetchAuthenticatedUser(); + }, [fetchAuthenticatedUser]); + const authenticatedUser = useMemo( - () => constructUserValue(authenticatedUserSubgraph), - [authenticatedUserSubgraph, constructUserValue], + () => constructUserValue(authenticatedUserSubgraph, verifiableAddresses), + [authenticatedUserSubgraph, constructUserValue, verifiableAddresses], ); const value = useMemo( () => ({ + aal2Required, authenticatedUser, + emailVerificationStatusKnown, isInstanceAdmin, refetch: async () => { // Refetch the detail on orgs in case this refetch is following them being modified @@ -169,7 +232,14 @@ export const AuthInfoProvider: FunctionComponent = ({ return fetchAuthenticatedUser(); }, }), - [authenticatedUser, isInstanceAdmin, fetchAuthenticatedUser, refetchOrgs], + [ + aal2Required, + authenticatedUser, + emailVerificationStatusKnown, + isInstanceAdmin, + fetchAuthenticatedUser, + refetchOrgs, + ], ); return ( diff --git a/apps/hash-frontend/src/pages/shared/auth-utils.ts b/apps/hash-frontend/src/pages/shared/auth-utils.ts index 237f195672a..44fdc6b6dc7 100644 --- a/apps/hash-frontend/src/pages/shared/auth-utils.ts +++ b/apps/hash-frontend/src/pages/shared/auth-utils.ts @@ -23,19 +23,33 @@ export const parseGraphQLError = ( errors: GraphQLError[], priorityErrorCode?: string, ): { errorCode: string; message: string } => { - const priorityError = errors.find( - ({ extensions }) => extensions.code === priorityErrorCode, - ); + const extractErrorCode = (error: GraphQLError) => + typeof error.extensions.code === "string" + ? error.extensions.code + : "unknown"; + + if (errors.length === 0) { + return { + errorCode: "unknown", + message: "An unexpected error occurred.", + }; + } + + const priorityError = priorityErrorCode + ? errors.find(({ extensions }) => extensions.code === priorityErrorCode) + : undefined; if (priorityError) { return { - errorCode: priorityError.extensions.code as string, + errorCode: extractErrorCode(priorityError), message: priorityError.message, }; } + const firstError = errors[0]!; + return { - errorCode: errors[0]!.extensions.code as string, - message: errors[0]!.message, + errorCode: extractErrorCode(firstError), + message: firstError.message, }; }; diff --git a/apps/hash-frontend/src/pages/shared/settings-layout.tsx b/apps/hash-frontend/src/pages/shared/settings-layout.tsx index 7011d68f30c..0178b6700da 100644 --- a/apps/hash-frontend/src/pages/shared/settings-layout.tsx +++ b/apps/hash-frontend/src/pages/shared/settings-layout.tsx @@ -5,6 +5,7 @@ import { useMemo } from "react"; import type { Org } from "../../lib/user-and-org"; import { HouseSolidIcon } from "../../shared/icons/house-solid-icon"; +import { LockRegularIcon } from "../../shared/icons/lock-regular-icon"; import { PeopleGroupIcon } from "../../shared/icons/people-group-icon"; import { PlugSolidIcon } from "../../shared/icons/plug-solid-icon"; import { LayoutWithSidebar } from "../../shared/layout/layout-with-sidebar"; @@ -45,6 +46,11 @@ const generateMenuLinks = ( const menuItems: SidebarItemData[] = [ // { label: "Personal info", href: "/settings/personal" }, + { + label: "Security", + href: "/settings/security", + icon: LockRegularIcon, + }, { label: "Organizations", href: "/settings/organizations", diff --git a/apps/hash-frontend/src/pages/signin.page.tsx b/apps/hash-frontend/src/pages/signin.page.tsx index 85ff2556c83..26b6403a587 100644 --- a/apps/hash-frontend/src/pages/signin.page.tsx +++ b/apps/hash-frontend/src/pages/signin.page.tsx @@ -44,7 +44,7 @@ const SignupButton = styled((props: ButtonProps) => ( const SigninPage: NextPageWithLayout = () => { // Get ?flow=... from the URL const router = useRouter(); - const { refetch } = useAuthInfo(); + const { aal2Required, refetch } = useAuthInfo(); const { updateActiveWorkspaceWebId } = useContext(WorkspaceContext); const { hashInstance } = useHashInstance(); @@ -105,6 +105,9 @@ const SigninPage: NextPageWithLayout = () => { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [totpCode, setTotpCode] = useState(""); + const [lookupSecret, setLookupSecret] = useState(""); + const [useLookupSecretInput, setUseLookupSecretInput] = useState(false); const [errorMessage, setErrorMessage] = useState(); const { handleFlowError } = useKratosErrorHandler({ @@ -149,6 +152,67 @@ const SigninPage: NextPageWithLayout = () => { handleFlowError, ]); + const isAal2Flow = useMemo( + () => + flow?.requested_aal === "aal2" || + flow?.ui.nodes.some(({ group }) => + ["totp", "lookup_secret"].includes(group), + ) === true, + [flow], + ); + + const emailInputUiNode = flow?.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && + attributes.name === "traits.emails", + ); + + const passwordInputUiNode = flow?.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && attributes.name === "password", + ); + + const totpInputUiNode = flow?.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && attributes.name === "totp_code", + ); + + const lookupSecretInputUiNode = flow?.ui.nodes.find( + ({ attributes }) => + isUiNodeInputAttributes(attributes) && + attributes.name === "lookup_secret", + ); + + const handleValidationError = (err: AxiosError) => { + if (err.response?.status === 400) { + setFlow(err.response.data); + return; + } + + if (err.response?.status === 429) { + setErrorMessage("Too many attempts, please try again shortly."); + return; + } + + return Promise.reject(err); + }; + + const completeSignin = async (activeFlow: LoginFlow) => { + const { authenticatedUser } = await refetch(); + + if (!authenticatedUser) { + if (aal2Required) { + void router.push("/signin?aal=aal2"); + return; + } + + throw new Error("Could not fetch authenticated user after logging in."); + } + + updateActiveWorkspaceWebId(authenticatedUser.accountId as WebId); + void router.push(returnTo ?? activeFlow.return_to ?? "/"); + }; + const handleSubmit: FormEventHandler = (event) => { event.preventDefault(); @@ -158,12 +222,41 @@ const SigninPage: NextPageWithLayout = () => { ); } - if (!email || !password) { + if (!isAal2Flow && (!email || !password)) { + return; + } + + if (isAal2Flow && !useLookupSecretInput && !totpCode) { + return; + } + + if (isAal2Flow && useLookupSecretInput && !lookupSecret) { return; } const csrf_token = mustGetCsrfTokenFromFlow(flow); + const updateLoginFlowBody = isAal2Flow + ? useLookupSecretInput + ? { + csrf_token, + method: "lookup_secret" as const, + lookup_secret: lookupSecret, + } + : { + csrf_token, + method: "totp" as const, + totp_code: totpCode, + } + : { + csrf_token, + method: "password" as const, + identifier: email, + password, + }; + + setErrorMessage(undefined); + void router // On submission, add the flow ID to the URL but do not navigate. This prevents the user losing // their data when they reload the page. @@ -172,60 +265,55 @@ const SigninPage: NextPageWithLayout = () => { oryKratosClient .updateLoginFlow({ flow: String(flow.id), - updateLoginFlowBody: { - csrf_token, - method: "password", - identifier: email, - password, - }, + updateLoginFlowBody, }) // We logged in successfully! Let's redirect the user. - .then(async () => { - // Otherwise, redirect the user to their workspace. - const { authenticatedUser } = await refetch(); - - if (!authenticatedUser) { - throw new Error( - "Could not fetch authenticated user after logging in.", + .then(async ({ data: loginResponse }) => { + if (!isAal2Flow) { + const redirectAction = loginResponse.continue_with?.find( + ( + action, + ): action is { + action: "redirect_browser_to"; + redirect_browser_to: string; + } => + action.action === "redirect_browser_to" && + "redirect_browser_to" in action && + typeof action.redirect_browser_to === "string", ); - } - updateActiveWorkspaceWebId(authenticatedUser.accountId as WebId); + if (redirectAction?.redirect_browser_to) { + void router.push(redirectAction.redirect_browser_to); + return; + } - void router.push(returnTo ?? flow.return_to ?? "/"); - }) - .catch(handleFlowError) - .catch((err: AxiosError) => { - // If the previous handler did not catch the error it's most likely a form validation error - if (err.response?.status === 400) { - // Yup, it is! - setFlow(err.response.data); - return; - } + try { + await oryKratosClient.toSession(); + } catch (error) { + const maybeAal2Error = error as AxiosError<{ + redirect_browser_to?: string; + }>; + + if (maybeAal2Error.response?.status === 403) { + const redirectTo = + maybeAal2Error.response.data.redirect_browser_to ?? + "/signin?aal=aal2"; - if (err.response?.status === 429) { - // This is a rate limiting error - setErrorMessage("Too many attempts, please try again shortly."); - return; + void router.push(redirectTo); + return; + } + + throw error; + } } - // This is an unexpected error, throw it so that it's reported - return Promise.reject(err); - }), + await completeSignin(flow); + }) + .catch(handleFlowError) + .catch(handleValidationError), ); }; - const emailInputUiNode = flow?.ui.nodes.find( - ({ attributes }) => - isUiNodeInputAttributes(attributes) && - attributes.name === "traits.emails", - ); - - const passwordInputUiNode = flow?.ui.nodes.find( - ({ attributes }) => - isUiNodeInputAttributes(attributes) && attributes.name === "password", - ); - const { userSelfRegistrationIsEnabled } = hashInstance?.properties ?? {}; return ( @@ -259,7 +347,11 @@ const SigninPage: NextPageWithLayout = () => { maxWidth: 600, }} > - Sign in to your account + + {isAal2Flow + ? "Enter your authentication code" + : "Sign in to your account"} + { gap: 1, }} > - setEmail(target.value)} - error={ - !!emailInputUiNode?.messages.find( - ({ type }) => type === "error", - ) - } - helperText={emailInputUiNode?.messages.map(({ id, text }) => ( - {text} - ))} - required - inputProps={{ "data-1p-ignore": false }} - /> - setPassword(target.value)} - error={ - !!passwordInputUiNode?.messages.find( - ({ type }) => type === "error", - ) - } - helperText={passwordInputUiNode?.messages.map(({ id, text }) => ( - {text} - ))} - required - inputProps={{ "data-1p-ignore": false }} - // eslint-disable-next-line react/jsx-no-duplicate-props - InputProps={{ - endAdornment: ( + {isAal2Flow ? ( + <> + palette.gray[70] }}> + Open your authenticator app and enter the code to continue. + + { + if (useLookupSecretInput) { + setLookupSecret(target.value); + } else { + setTotpCode(target.value); + } + }} + error={ + !!(useLookupSecretInput + ? lookupSecretInputUiNode?.messages.find( + ({ type }) => type === "error", + ) + : totpInputUiNode?.messages.find( + ({ type }) => type === "error", + )) + } + helperText={ + useLookupSecretInput + ? lookupSecretInputUiNode?.messages.map( + ({ id, text }) => ( + {text} + ), + ) + : totpInputUiNode?.messages.map(({ id, text }) => ( + {text} + )) + } + required + /> + + - ), - }} - /> + + + ) : ( + <> + setEmail(target.value)} + error={ + !!emailInputUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={emailInputUiNode?.messages.map(({ id, text }) => ( + {text} + ))} + required + inputProps={{ "data-1p-ignore": false }} + /> + setPassword(target.value)} + error={ + !!passwordInputUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={passwordInputUiNode?.messages.map( + ({ id, text }) => {text}, + )} + required + inputProps={{ "data-1p-ignore": false }} + // eslint-disable-next-line react/jsx-no-duplicate-props + InputProps={{ + endAdornment: ( + + ), + }} + /> + + )} {errorMessage ? {errorMessage} : null} {flow?.ui.messages?.map(({ text, id }) => ( {text} diff --git a/apps/hash-frontend/src/pages/signup.page.tsx b/apps/hash-frontend/src/pages/signup.page.tsx index e85151e1dea..a2065cf4914 100644 --- a/apps/hash-frontend/src/pages/signup.page.tsx +++ b/apps/hash-frontend/src/pages/signup.page.tsx @@ -31,6 +31,7 @@ import { AccountSetupForm } from "./signup.page/account-setup-form"; import { SignupRegistrationForm } from "./signup.page/signup-registration-form"; import { SignupRegistrationRightInfo } from "./signup.page/signup-registration-right-info"; import { SignupSteps } from "./signup.page/signup-steps"; +import { VerifyEmailStep } from "./signup.page/verify-email-step"; const LoginButton = styled((props: ButtonProps) => ( + + {flow?.ui.messages?.map(({ id, text }) => ( + {text} + ))} + {errorMessage ? {errorMessage} : null} + + + ); +}; diff --git a/apps/hash-frontend/src/pages/verify-email.page.tsx b/apps/hash-frontend/src/pages/verify-email.page.tsx new file mode 100644 index 00000000000..f3dafedd421 --- /dev/null +++ b/apps/hash-frontend/src/pages/verify-email.page.tsx @@ -0,0 +1,65 @@ +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +import { useLogoutFlow } from "../components/hooks/use-logout-flow"; +import type { NextPageWithLayout } from "../shared/layout"; +import { getPlainLayout } from "../shared/layout"; +import { Button } from "../shared/ui"; +import { useAuthInfo } from "./shared/auth-info-context"; +import { AuthLayout } from "./shared/auth-layout"; +import { VerifyEmailStep } from "./signup.page/verify-email-step"; + +const VerifyEmailPage: NextPageWithLayout = () => { + const router = useRouter(); + const { logout } = useLogoutFlow(); + const { authenticatedUser, emailVerificationStatusKnown, refetch } = + useAuthInfo(); + + const primaryEmailVerified = + authenticatedUser?.emails.find(({ primary }) => primary)?.verified ?? false; + + useEffect(() => { + if (emailVerificationStatusKnown && !authenticatedUser) { + void router.replace("/signin"); + } + }, [authenticatedUser, emailVerificationStatusKnown, router]); + + useEffect(() => { + if (authenticatedUser && primaryEmailVerified) { + void router.replace("/"); + } + }, [authenticatedUser, primaryEmailVerified, router]); + + if (!authenticatedUser || primaryEmailVerified) { + return null; + } + + return ( + + { + await refetch(); + void router.push("/"); + }} + /> + + + ); +}; + +VerifyEmailPage.getLayout = getPlainLayout; + +export default VerifyEmailPage; diff --git a/apps/hash-frontend/src/shared/icons/circle-4-regular-icon.tsx b/apps/hash-frontend/src/shared/icons/circle-4-regular-icon.tsx new file mode 100644 index 00000000000..3c8cdb53df1 --- /dev/null +++ b/apps/hash-frontend/src/shared/icons/circle-4-regular-icon.tsx @@ -0,0 +1,14 @@ +import type { SvgIconProps } from "@mui/material"; +import { SvgIcon } from "@mui/material"; +import type { FunctionComponent } from "react"; + +export const Circle4RegularIcon: FunctionComponent = (props) => { + return ( + + + + + + + ); +}; diff --git a/apps/hash-frontend/src/shared/layout/plain-layout.tsx b/apps/hash-frontend/src/shared/layout/plain-layout.tsx index 051b4f8c56f..6d67ec0e225 100644 --- a/apps/hash-frontend/src/shared/layout/plain-layout.tsx +++ b/apps/hash-frontend/src/shared/layout/plain-layout.tsx @@ -33,7 +33,10 @@ export const PlainLayout: FunctionComponent<{ const router = useRouter(); - const { authenticatedUser } = useAuthInfo(); + const { authenticatedUser, emailVerificationStatusKnown } = useAuthInfo(); + + const primaryEmailVerified = + authenticatedUser?.emails.find(({ primary }) => primary)?.verified ?? false; return ( <> @@ -53,7 +56,11 @@ export const PlainLayout: FunctionComponent<{ options={{ showSpinner: false }} showOnShallow /> - {authenticatedUser?.accountSignupComplete ? : null} + {authenticatedUser?.accountSignupComplete && + emailVerificationStatusKnown && + primaryEmailVerified ? ( + + ) : null} {children} ); diff --git a/tests/hash-playwright/tests/mfa.spec.ts b/tests/hash-playwright/tests/mfa.spec.ts new file mode 100644 index 00000000000..fc8766b0b8c --- /dev/null +++ b/tests/hash-playwright/tests/mfa.spec.ts @@ -0,0 +1,208 @@ +import { getKratosVerificationCode } from "./shared/get-kratos-verification-code"; +import { resetDb } from "./shared/reset-db"; +import { expect, type Page, test } from "./shared/runtime"; +import { generateTotpCode } from "./shared/totp-utils"; + +const createUserAndCompleteSignup = async (page: Page) => { + const randomSuffix = `${Date.now()}${Math.floor(Math.random() * 1_000)}`; + const email = `mfa-${randomSuffix}@example.com`; + const password = "some-complex-pw-1ab2"; + const shortname = `mfa${randomSuffix}`.slice(0, 24); + + const registrationFlowReady = page.waitForResponse( + (response) => + response.request().method() === "GET" && + response.url().includes("/auth/self-service/registration/browser"), + { timeout: 15_000 }, + ); + + await page.goto("/signup"); + await registrationFlowReady; + await page.fill('[placeholder="Enter your email address"]', email); + await page.fill('[type="password"]', password); + + const emailDispatchTimestamp = Date.now(); + const registrationSubmitComplete = page.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().includes("/auth/self-service/registration"), + { timeout: 15_000 }, + ); + await page.getByRole("button", { name: "Sign up" }).click(); + await registrationSubmitComplete; + + await expect(page.locator("text=Verify your email address")).toBeVisible({ + timeout: 15_000, + }); + + const verificationCode = await getKratosVerificationCode( + email, + emailDispatchTimestamp, + ); + + await page.fill( + '[placeholder="Enter your verification code"]', + verificationCode, + ); + await page.getByRole("button", { name: "Verify" }).click(); + + await expect( + page.locator("text=Thanks for confirming your account"), + ).toBeVisible(); + + await page.fill('[placeholder="example"]', shortname); + await page.fill('[placeholder="Jonathan Smith"]', "MFA User"); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.waitForURL("/"); + await expect(page.locator("text=Get support")).toBeVisible(); + + return { email, password }; +}; + +const enableTotpForCurrentUser = async (page: Page) => { + await page.goto("/settings/security"); + await page.click('[data-testid="show-enable-totp-form-button"]'); + + const secretKeyLocator = page.locator('[data-testid="totp-secret-key"]'); + await expect(secretKeyLocator).toBeVisible(); + + const secret = + (await secretKeyLocator.textContent())?.replace(/\s/g, "") ?? ""; + if (!secret) { + throw new Error("Could not read TOTP secret key from settings page."); + } + + await page.fill( + '[placeholder="Enter your 6-digit code"]', + generateTotpCode(secret), + ); + await page.click('[data-testid="enable-totp-button"]'); + + const backupCodesModal = page.locator('[data-testid="backup-codes-modal"]'); + await expect(backupCodesModal).toBeVisible(); + + const backupCodes = ( + await page.locator('[data-testid="backup-code-item"]').allTextContents() + ) + .map((code) => code.trim()) + .filter((code) => code.length > 0); + + await page.click('[data-testid="confirm-backup-codes-button"]'); + await expect(backupCodesModal).not.toBeVisible(); + + await expect( + page.locator('[data-testid="disable-totp-button"]'), + ).toBeVisible(); + + return { backupCodes, secret }; +}; + +const signInWithPassword = async ( + page: Page, + { email, password }: { email: string; password: string }, +) => { + await page.goto("/signin"); + await page.fill('[placeholder="Enter your email address"]', email); + await page.fill('[placeholder="Enter your password"]', password); + await page.click("text=Submit"); +}; + +test.beforeEach(async () => { + await resetDb(); +}); + +test("user can enable TOTP", async ({ page }) => { + await createUserAndCompleteSignup(page); + + const { backupCodes } = await enableTotpForCurrentUser(page); + + expect(backupCodes.length).toBeGreaterThan(0); +}); + +test("user with TOTP is prompted for code at login", async ({ page }) => { + const credentials = await createUserAndCompleteSignup(page); + const { secret } = await enableTotpForCurrentUser(page); + + await page.context().clearCookies(); + + await signInWithPassword(page, credentials); + + await expect( + page.locator("text=Enter your authentication code"), + ).toBeVisible(); + + await page.fill( + '[data-testid="signin-aal2-code-input"]', + generateTotpCode(secret), + ); + await page.click('[data-testid="signin-aal2-submit-button"]'); + + await expect(page.locator("text=Get support")).toBeVisible(); +}); + +test("user can use backup code instead of TOTP", async ({ page }) => { + const credentials = await createUserAndCompleteSignup(page); + const { backupCodes } = await enableTotpForCurrentUser(page); + + expect(backupCodes.length).toBeGreaterThan(0); + + await page.context().clearCookies(); + + await signInWithPassword(page, credentials); + await expect( + page.locator("text=Enter your authentication code"), + ).toBeVisible(); + + await page.click('[data-testid="signin-aal2-toggle-method-button"]'); + await page.fill('[data-testid="signin-aal2-code-input"]', backupCodes[0]!); + await page.click('[data-testid="signin-aal2-submit-button"]'); + + await expect(page.locator("text=Get support")).toBeVisible(); +}); + +test("user can disable TOTP", async ({ page }) => { + const credentials = await createUserAndCompleteSignup(page); + const { secret } = await enableTotpForCurrentUser(page); + + await page.goto("/settings/security"); + await page.click('[data-testid="disable-totp-button"]'); + await page.fill( + '[placeholder="Enter a current code to disable"]', + generateTotpCode(secret), + ); + await page.click('[data-testid="confirm-disable-totp-button"]'); + + await expect( + page.locator('[data-testid="show-enable-totp-form-button"]'), + ).toBeVisible(); + + await page.context().clearCookies(); + + await signInWithPassword(page, credentials); + + await expect(page.locator("text=Get support")).toBeVisible(); + await expect( + page.locator("text=Enter your authentication code"), + ).not.toBeVisible(); +}); + +test("wrong TOTP code shows error at login", async ({ page }) => { + const credentials = await createUserAndCompleteSignup(page); + await enableTotpForCurrentUser(page); + + await page.context().clearCookies(); + + await signInWithPassword(page, credentials); + await expect( + page.locator("text=Enter your authentication code"), + ).toBeVisible(); + + await page.fill('[data-testid="signin-aal2-code-input"]', "000000"); + await page.click('[data-testid="signin-aal2-submit-button"]'); + + await expect(page.locator("text=/invalid|expired|used/i")).toBeVisible(); + await expect( + page.locator("text=Enter your authentication code"), + ).toBeVisible(); +}); diff --git a/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts b/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts new file mode 100644 index 00000000000..383bb924010 --- /dev/null +++ b/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts @@ -0,0 +1,99 @@ +import { sleep } from "@local/hash-isomorphic-utils/sleep"; + +type MailslurperMailAddress = { + address?: string; +}; + +type MailslurperMailItem = { + body?: string; + dateSent?: string; + subject?: string; + toAddresses?: Array; +}; + +const extractToAddresses = ( + toAddresses: MailslurperMailItem["toAddresses"], +): string[] => + (toAddresses ?? []) + .map((toAddress) => { + if (typeof toAddress === "string") { + return toAddress; + } + + return toAddress.address; + }) + .filter((toAddress): toAddress is string => typeof toAddress === "string"); + +const extractVerificationCode = (emailBody: string): string | undefined => + emailBody.match(/following code:\s*(?:\s*)?(\d{6})/is)?.[1] ?? + emailBody.match(/\b(\d{6})\b/)?.[1]; + +export const getKratosVerificationCode = async ( + emailAddress: string, + afterTimestamp?: number, +): Promise => { + const maxWaitMs = 10_000; + const pollIntervalMs = 250; + let elapsed = 0; + let lastError: unknown; + + while (elapsed < maxWaitMs) { + try { + const response = await fetch("http://localhost:4437/mail"); + + if (!response.ok) { + throw new Error( + `Unable to fetch emails from mailslurper: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + mailItems?: MailslurperMailItem[]; + }; + + const matchingMailItems = + data.mailItems + ?.filter((mailItem) => { + const sentTimestamp = mailItem.dateSent + ? new Date(mailItem.dateSent).getTime() + : undefined; + + return ( + mailItem.subject === "Please verify your email address" && + extractToAddresses(mailItem.toAddresses).includes(emailAddress) && + (!afterTimestamp || + (typeof sentTimestamp === "number" && + sentTimestamp >= afterTimestamp)) + ); + }) + .sort((a, b) => { + const aTimestamp = a.dateSent ? new Date(a.dateSent).getTime() : 0; + const bTimestamp = b.dateSent ? new Date(b.dateSent).getTime() : 0; + + return bTimestamp - aTimestamp; + }) ?? []; + + for (const mailItem of matchingMailItems) { + const code = mailItem.body + ? extractVerificationCode(mailItem.body) + : undefined; + + if (code) { + return code; + } + } + } catch (error) { + lastError = error; + } + + await sleep(pollIntervalMs); + elapsed += pollIntervalMs; + } + + const lastErrorMessage = + lastError instanceof Error ? ` Last error: ${lastError.message}` : ""; + + throw new Error( + `No verification email found for ${emailAddress} within ${maxWaitMs}ms.${lastErrorMessage}`, + ); +}; diff --git a/tests/hash-playwright/tests/shared/totp-utils.ts b/tests/hash-playwright/tests/shared/totp-utils.ts new file mode 100644 index 00000000000..f57a835e80f --- /dev/null +++ b/tests/hash-playwright/tests/shared/totp-utils.ts @@ -0,0 +1,57 @@ +import { createHmac } from "node:crypto"; + +const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +const decodeBase32 = (encodedSecret: string): Buffer => { + const normalizedSecret = encodedSecret + .replace(/\s/g, "") + .replace(/=+$/, "") + .toUpperCase(); + + let bits = 0; + let value = 0; + const bytes: number[] = []; + + for (const character of normalizedSecret) { + const index = base32Alphabet.indexOf(character); + + if (index === -1) { + continue; + } + + value = value * 32 + index; + bits += 5; + + if (bits >= 8) { + bytes.push(Math.floor(value / 2 ** (bits - 8)) % 256); + bits -= 8; + } + } + + return Buffer.from(bytes); +}; + +export const generateTotpCode = ( + secret: string, + timestamp: number = Date.now(), +): string => { + const key = decodeBase32(secret); + const counter = Math.floor(timestamp / 1_000 / 30); + const counterBuffer = Buffer.alloc(8); + const highCounter = Math.floor(counter / 2 ** 32); + const lowCounter = counter % 2 ** 32; + + counterBuffer.writeUInt32BE(highCounter, 0); + counterBuffer.writeUInt32BE(lowCounter, 4); + + const hmac = createHmac("sha1", key).update(counterBuffer).digest(); + const offset = hmac[hmac.length - 1]! % 16; + + const binaryCode = + (hmac[offset]! % 128) * 16_777_216 + + hmac[offset + 1]! * 65_536 + + hmac[offset + 2]! * 256 + + hmac[offset + 3]!; + + return (binaryCode % 1_000_000).toString().padStart(6, "0"); +}; diff --git a/tests/hash-playwright/tests/signup.spec.ts b/tests/hash-playwright/tests/signup.spec.ts index b90d803e100..8fd9e85af7e 100644 --- a/tests/hash-playwright/tests/signup.spec.ts +++ b/tests/hash-playwright/tests/signup.spec.ts @@ -1,48 +1,135 @@ +import { getKratosVerificationCode } from "./shared/get-kratos-verification-code"; import { resetDb } from "./shared/reset-db"; -import { expect, test } from "./shared/runtime"; +import { expect, type Page, test } from "./shared/runtime"; test.beforeEach(async () => { await resetDb(); }); -test("user can sign up", async ({ page }) => { - await page.goto("/"); +const allowlistedEmail = "charlie@example.com"; +const password = "some-complex-pw-1ab2"; - await page.click("text=Sign in"); +const registerUser = async (page: Page, { email }: { email: string }) => { + const registrationFlowReady = page.waitForResponse( + (response) => + response.request().method() === "GET" && + response.url().includes("/auth/self-service/registration/browser"), + { timeout: 15_000 }, + ); - await page.waitForURL("**/signin"); + await page.goto("/signup"); + await registrationFlowReady; - await expect(page.locator("text=SIGN IN TO YOUR ACCOUNT")).toBeVisible(); - await expect(page.locator("text=Create a free account")).toBeVisible(); + await page.fill('[placeholder="Enter your email address"]', email); - await page.click("text=Create a free account"); + await page.fill('[type="password"]', password); - await page.waitForURL("**/signup"); + const emailDispatchTimestamp = Date.now(); + const registrationSubmitComplete = page.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().includes("/auth/self-service/registration"), + { timeout: 15_000 }, + ); - const randomNumber = Math.floor(Math.random() * 10_000) - .toString() - .padEnd(4, "0"); // shortnames must be at least 4 characters + await page.getByRole("button", { name: "Sign up" }).click(); + await registrationSubmitComplete; + + return { email, emailDispatchTimestamp }; +}; + +const verifyEmail = async ({ + page, + email, + emailDispatchTimestamp, +}: { + page: Page; + email: string; + emailDispatchTimestamp: number; +}) => { + const verificationCode = await getKratosVerificationCode( + email, + emailDispatchTimestamp, + ); await page.fill( - '[placeholder="Enter your email address"]', - `${randomNumber}@example.com`, + '[placeholder="Enter your verification code"]', + verificationCode, ); + await page.getByRole("button", { name: "Verify" }).click(); +}; + +test("allowlisted user can verify email and complete signup", async ({ + page, +}) => { + const { email, emailDispatchTimestamp } = await registerUser(page, { + email: allowlistedEmail, + }); - await page.fill('[type="password"]', "some-complex-pw-1ab2"); + await expect(page.locator("text=Verify your email address")).toBeVisible({ + timeout: 15_000, + }); - await page.click("text=Sign up"); + await page.fill('[placeholder="Enter your verification code"]', "000000"); + await page.getByRole("button", { name: "Verify" }).click(); + + await expect( + page.locator( + "text=/verification code.*(invalid|expired|used)|code is invalid|code is expired/i", + ), + ).toBeVisible(); + await expect(page.locator("text=Verify your email address")).toBeVisible(); + + const resendTimestamp = Date.now(); + await page.getByRole("button", { name: "Resend verification email" }).click(); + + await verifyEmail({ + page, + email, + emailDispatchTimestamp: Math.max(emailDispatchTimestamp, resendTimestamp), + }); await expect( page.locator("text=Thanks for confirming your account"), ).toBeVisible(); - await page.fill('[placeholder="example"]', randomNumber.toString()); + const uniqueSuffix = `${Date.now()}${Math.floor(Math.random() * 1_000)}`; + const shortname = `signup${uniqueSuffix}`.slice(0, 24); + + await page.fill('[placeholder="example"]', shortname); await page.fill('[placeholder="Jonathan Smith"]', "New User"); - await page.click("text=Continue"); + await page.getByRole("button", { name: "Continue" }).click(); await page.waitForURL("/"); await expect(page.locator("text=Get support")).toBeVisible(); }); + +test("waitlisted user is redirected to waitlist after signup", async ({ + page, +}) => { + const uniqueSuffix = `${Date.now()}${Math.floor(Math.random() * 1_000)}`; + const waitlistedEmail = `signup-${uniqueSuffix}@example.com`; + + await registerUser(page, { email: waitlistedEmail }); + + await page.waitForURL("/"); + await expect( + page.getByRole("heading", { name: "on the waitlist", exact: false }), + ).toBeVisible(); + + await page.goto("/settings/security"); + await page.waitForURL("/"); + await expect( + page.getByRole("heading", { name: "on the waitlist", exact: false }), + ).toBeVisible(); + + await page.goto("/signup"); + await page.waitForURL("/"); + + await expect( + page.getByRole("heading", { name: "on the waitlist", exact: false }), + ).toBeVisible(); +}); From 6e3cc17664d0ae388fd393f2c4b91dca407d15f3 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 12:52:45 +0000 Subject: [PATCH 03/32] improve unverified email cleanup logic --- .../create-unverified-email-cleanup-job.ts | 58 +++++++++++-------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts b/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts index 4c1cd86d980..3ba6976d152 100644 --- a/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts +++ b/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts @@ -13,8 +13,13 @@ import { getUserFromEntity } from "../graph/knowledge/system-types/user"; import { systemAccountId } from "../graph/system-account"; import { deleteKratosIdentity, kratosIdentityApi } from "./ory-kratos"; -const DEFAULT_ROLLOUT_AT = new Date("2026-02-10T00:00:00.000Z"); -const DEFAULT_RELEASE_TTL_HOURS = 24 * 14; +/** + * Identities created before this date are excluded from cleanup, preventing + * retroactive deletion of accounts that existed before email verification + * was introduced. + */ +const DEFAULT_ROLLOUT_AT = new Date("2026-02-13T00:00:00.000Z"); +const DEFAULT_RELEASE_TTL_HOURS = 24 * 7; const DEFAULT_SWEEP_INTERVAL_MINUTES = 60; const parsePositiveIntegerEnv = ( @@ -65,13 +70,13 @@ const parseIdentityCreatedAt = (identity: Identity): Date | undefined => { return createdAt; }; -const isPrimaryEmailVerified = ( - identity: Identity, - fallbackPrimaryEmailAddress: string, -): boolean => { +const isPrimaryEmailVerified = (identity: Identity): boolean => { const identityTraits = identity.traits as { emails?: string[] }; - const primaryEmailAddress = - identityTraits.emails?.[0] ?? fallbackPrimaryEmailAddress; + const primaryEmailAddress = identityTraits.emails?.[0]; + + if (!primaryEmailAddress) { + return false; + } return ( identity.verifiable_addresses?.find( @@ -156,18 +161,26 @@ export const createUnverifiedEmailCleanupJob = ({ continue; } - if (isPrimaryEmailVerified(identity, user.emails[0]!)) { + const primaryEmail = user.emails[0]; + if (!primaryEmail) { + logger.warn( + `User ${user.accountId} (${user.kratosIdentityId}) has no email addresses, skipping`, + ); continue; } - await Promise.all([ - user.entity.archive( - context.graphApi, - authentication, - context.provenance, - ), - deleteKratosIdentity({ kratosIdentityId: user.kratosIdentityId }), - ]); + if (isPrimaryEmailVerified(identity)) { + continue; + } + + await user.entity.archive( + context.graphApi, + authentication, + context.provenance, + ); + await deleteKratosIdentity({ + kratosIdentityId: user.kratosIdentityId, + }); releasedEmailCount += 1; } catch (error) { @@ -185,27 +198,24 @@ export const createUnverifiedEmailCleanupJob = ({ }; let interval: NodeJS.Timeout | undefined; + let inFlightCleanup: Promise | undefined; return { start: async () => { logger.info( - [ - "Starting unverified-email cleanup job", - `(rolloutAt=${rolloutAt.toISOString()}`, - `ttlHours=${releaseTtlHours}`, - `intervalMinutes=${sweepIntervalMinutes})`, - ].join(" "), + `Starting unverified-email cleanup job (rolloutAt=${rolloutAt.toISOString()}, ttlHours=${releaseTtlHours}, intervalMinutes=${sweepIntervalMinutes})`, ); await cleanupUnverifiedUsers(); interval = setInterval(() => { - void cleanupUnverifiedUsers(); + inFlightCleanup = cleanupUnverifiedUsers(); }, sweepIntervalMs); }, stop: async () => { if (interval) { clearInterval(interval); } + await inFlightCleanup; }, }; }; From a5b862a4695cda8d2a764bcb64bbab60282d5bc4 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 12:53:03 +0000 Subject: [PATCH 04/32] tidy up resolver list --- apps/hash-api/src/graphql/resolvers/index.ts | 75 ++++++++++++-------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/apps/hash-api/src/graphql/resolvers/index.ts b/apps/hash-api/src/graphql/resolvers/index.ts index 2d88cc9b6e4..b99f00ef5af 100644 --- a/apps/hash-api/src/graphql/resolvers/index.ts +++ b/apps/hash-api/src/graphql/resolvers/index.ts @@ -119,17 +119,12 @@ export const resolvers: Omit & { Mutation: Required; } = { Query: { - // Logged in and signed up users only, - getBlockProtocolBlocks: getBlockProtocolBlocksResolver, - // Logged in users only - me: loggedInMiddleware(meResolver), - getWaitlistPosition: loggedInMiddleware(getWaitlistPositionResolver), - // Admins - getUsageRecords: loggedInMiddleware(getUsageRecordsResolver), - // Any user + /** Any user, including anonymous */ isShortnameTaken: isShortnameTakenResolver, embedCode, - // Ontology + hashInstanceSettings: hashInstanceSettingsResolver, + + /** Any user – type fetching */ queryDataTypes: queryDataTypesResolver, queryDataTypeSubgraph: queryDataTypeSubgraphResolver, findDataTypeConversionTargets: findDataTypeConversionTargetsResolver, @@ -138,10 +133,20 @@ export const resolvers: Omit & { queryEntityTypes: queryEntityTypesResolver, queryEntityTypeSubgraph: queryEntityTypeSubgraphResolver, getClosedMultiEntityTypes: getClosedMultiEntityTypesResolver, - // Knowledge + + /** Logged in users (who may not have completed signup) */ + me: loggedInMiddleware(meResolver), + getWaitlistPosition: loggedInMiddleware(getWaitlistPositionResolver), + hasAccessToHash: loggedInMiddleware(hasAccessToHashResolver), + + /** Logged in and signed up users */ + getBlockProtocolBlocks: loggedInAndSignedUpMiddleware( + getBlockProtocolBlocksResolver, + ), + getUsageRecords: loggedInAndSignedUpMiddleware(getUsageRecordsResolver), pageComments: loggedInAndSignedUpMiddleware(pageCommentsResolver), blocks: loggedInAndSignedUpMiddleware(blocksResolver), - getEntityDiffs: getEntityDiffsResolver, + getEntityDiffs: loggedInAndSignedUpMiddleware(getEntityDiffsResolver), getFlowRuns: loggedInAndSignedUpMiddleware(getFlowRunsResolver), getFlowRunById: loggedInAndSignedUpMiddleware(getFlowRunByIdResolver), isEntityPublic: loggedInAndSignedUpMiddleware(isEntityPublicResolver), @@ -150,37 +155,46 @@ export const resolvers: Omit & { "`getEntityAuthorizationRelationships` is not implemented", ); }), - countEntities: countEntitiesResolver, - queryEntities: queryEntitiesResolver, - queryEntitySubgraph: queryEntitySubgraphResolver, - hashInstanceSettings: hashInstanceSettingsResolver, + countEntities: loggedInAndSignedUpMiddleware(countEntitiesResolver), + queryEntities: loggedInAndSignedUpMiddleware(queryEntitiesResolver), + queryEntitySubgraph: loggedInAndSignedUpMiddleware( + queryEntitySubgraphResolver, + ), getMyPendingInvitations: loggedInAndSignedUpMiddleware( getMyPendingInvitationsResolver, ), - getPendingInvitationByEntityId: getPendingInvitationByEntityIdResolver, - // Integration + getPendingInvitationByEntityId: loggedInAndSignedUpMiddleware( + getPendingInvitationByEntityIdResolver, + ), getLinearOrganization: loggedInAndSignedUpMiddleware( getLinearOrganizationResolver, ), checkUserPermissionsOnEntity: (_, { metadata }, context, info) => checkUserPermissionsOnEntity({ metadata }, _, context, info), - checkUserPermissionsOnEntityType: checkUserPermissionsOnEntityTypeResolver, - checkUserPermissionsOnDataType: checkUserPermissionsOnDataTypeResolver, - hasAccessToHash: loggedInMiddleware(hasAccessToHashResolver), - // Generation - generateInverse: loggedInMiddleware(generateInverseResolver), - generatePlural: loggedInMiddleware(generatePluralResolver), + checkUserPermissionsOnEntityType: loggedInAndSignedUpMiddleware( + checkUserPermissionsOnEntityTypeResolver, + ), + checkUserPermissionsOnDataType: loggedInAndSignedUpMiddleware( + checkUserPermissionsOnDataTypeResolver, + ), + + generateInverse: loggedInAndSignedUpMiddleware(generateInverseResolver), + generatePlural: loggedInAndSignedUpMiddleware(generatePluralResolver), isGenerationAvailable: isGenerationAvailableResolver, - validateEntity: validateEntityResolver, + validateEntity: loggedInAndSignedUpMiddleware(validateEntityResolver), }, Mutation: { - // Logged in and signed up users only + /** Logged in users (who may not have completed signup) */ + submitEarlyAccessForm: loggedInMiddleware(submitEarlyAccessFormResolver), + + /** Logged in and signed up users */ updateBlockCollectionContents: loggedInAndSignedUpMiddleware( updateBlockCollectionContents, ), requestFileUpload: loggedInAndSignedUpMiddleware(requestFileUpload), createFileFromUrl: loggedInAndSignedUpMiddleware(createFileFromUrl), + // Ontology createPropertyType: loggedInAndSignedUpMiddleware( createPropertyTypeResolver, @@ -205,12 +219,13 @@ export const resolvers: Omit & { unarchiveEntityType: loggedInAndSignedUpMiddleware( unarchiveEntityTypeResolver, ), + // Knowledge createEntity: loggedInAndSignedUpMiddleware(createEntityResolver), - updateEntity: loggedInMiddleware(updateEntityResolver), - updateEntities: loggedInMiddleware(updateEntitiesResolver), - archiveEntity: loggedInMiddleware(archiveEntityResolver), - archiveEntities: loggedInMiddleware(archiveEntitiesResolver), + updateEntity: loggedInAndSignedUpMiddleware(updateEntityResolver), + updateEntities: loggedInAndSignedUpMiddleware(updateEntitiesResolver), + archiveEntity: loggedInAndSignedUpMiddleware(archiveEntityResolver), + archiveEntities: loggedInAndSignedUpMiddleware(archiveEntitiesResolver), createPage: loggedInAndSignedUpMiddleware(createPageResolver), setParentPage: loggedInAndSignedUpMiddleware(setParentPageResolver), updatePage: loggedInAndSignedUpMiddleware(updatePageResolver), @@ -229,8 +244,6 @@ export const resolvers: Omit & { ), removeUserFromOrg: loggedInAndSignedUpMiddleware(removeUserFromOrgResolver), - submitEarlyAccessForm: loggedInMiddleware(submitEarlyAccessFormResolver), - addEntityOwner: loggedInAndSignedUpMiddleware(() => { throw new Error("`addEntityOwner` is not implemented"); }), From ba493607eda6e5171902aacc433eb74212ab3242 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 12:53:31 +0000 Subject: [PATCH 05/32] remove unnecessary code --- apps/hash-api/src/index.ts | 86 +------------------ .../src/components/hooks/use-hash-instance.ts | 7 +- .../components/hooks/use-orgs-with-links.ts | 4 +- apps/hash-frontend/src/pages/_app.page.tsx | 2 + .../src/pages/shared/auth-info-context.tsx | 27 ++---- 5 files changed, 13 insertions(+), 113 deletions(-) diff --git a/apps/hash-api/src/index.ts b/apps/hash-api/src/index.ts index 3d0ec8db462..a4535a3cd2c 100644 --- a/apps/hash-api/src/index.ts +++ b/apps/hash-api/src/index.ts @@ -28,22 +28,11 @@ import bodyParser from "body-parser"; import cors from "cors"; import { Effect, Exit, Layer, Logger, LogLevel, ManagedRuntime } from "effect"; import { RuntimeException } from "effect/Cause"; -import type { - ErrorRequestHandler, - Request, - RequestHandler, - Response, -} from "express"; +import type { ErrorRequestHandler, Request, Response } from "express"; import express, { raw } from "express"; import { create as handlebarsCreate } from "express-handlebars"; import type { Options as RateLimitOptions } from "express-rate-limit"; import { ipKeyGenerator, rateLimit } from "express-rate-limit"; -import { - Kind, - type OperationDefinitionNode, - OperationTypeNode, - parse, -} from "graphql"; import helmet from "helmet"; import { StatsD } from "hot-shots"; import { @@ -172,40 +161,6 @@ const userIdentifierRateLimiter = rateLimit({ }, }); -const unverifiedUserPermittedGraphQLOperations = new Set([ - "me", - "hasAccessToHash", -]); - -const unverifiedUserErrorMessage = - "You must verify your primary email address before using HASH."; - -const isPermittedGraphQLOperationForUnverifiedUser = (req: Request) => { - const operationName = req.body?.operationName; - const query = req.body?.query; - - if ( - typeof operationName !== "string" || - typeof query !== "string" || - !unverifiedUserPermittedGraphQLOperations.has(operationName) - ) { - return false; - } - - try { - const parsedDocument = parse(query); - const operation = parsedDocument.definitions.find( - (definition): definition is OperationDefinitionNode => - definition.kind === Kind.OPERATION_DEFINITION && - definition.name?.value === operationName, - ); - - return operation?.operation === OperationTypeNode.QUERY; - } catch { - return false; - } -}; - /** * A rate limiter for the GPT endpoints. These are OAuth-protected but * should still be limited to prevent abuse by a compromised or misbehaving client. @@ -593,24 +548,6 @@ const main = async () => { }); app.use(authMiddleware); - const enforceVerifiedPrimaryEmail: RequestHandler = (req, res, next) => { - if (!req.user || req.primaryEmailVerified !== false) { - next(); - return; - } - - if (req.path === GRAPHQL_PATH || req.path === "/health-check") { - next(); - return; - } - - res.status(403).json({ - message: unverifiedUserErrorMessage, - }); - }; - - app.use(enforceVerifiedPrimaryEmail); - /** * Add scope to Sentry, now the user has been checked. * We could set some of this scope earlier, but it doesn't get picked up for GraphQL requests for some reason @@ -885,32 +822,11 @@ const main = async () => { // Start the Apollo GraphQL server. shutdown.addCleanup("ApolloServer", async () => apolloServer.stop()); - const enforceVerifiedPrimaryEmailForGraphQl: RequestHandler = ( - req, - res, - next, - ) => { - if (!req.user || req.primaryEmailVerified !== false) { - next(); - return; - } - - if (isPermittedGraphQLOperationForUnverifiedUser(req)) { - next(); - return; - } - - res.status(403).json({ - errors: [{ message: unverifiedUserErrorMessage }], - }); - }; - app.use( GRAPHQL_PATH, cors(CORS_CONFIG), graphqlRateLimiter, express.json(), - enforceVerifiedPrimaryEmailForGraphQl, apolloMiddleware, ); diff --git a/apps/hash-frontend/src/components/hooks/use-hash-instance.ts b/apps/hash-frontend/src/components/hooks/use-hash-instance.ts index f2548fb16fe..9103abfcb0f 100644 --- a/apps/hash-frontend/src/components/hooks/use-hash-instance.ts +++ b/apps/hash-frontend/src/components/hooks/use-hash-instance.ts @@ -12,11 +12,7 @@ import type { } from "../../graphql/api-types.gen"; import { getHashInstanceSettings } from "../../graphql/queries/knowledge/hash-instance.queries"; -export const useHashInstance = ({ - skip = false, -}: { - skip?: boolean; -} = {}): { +export const useHashInstance = (): { loading: boolean; hashInstance?: Simplified>; isUserAdmin: boolean; @@ -27,7 +23,6 @@ export const useHashInstance = ({ GetHashInstanceSettingsQueryQueryVariables >(getHashInstanceSettings, { fetchPolicy: "cache-and-network", - skip, }); const { hashInstanceSettings } = data ?? {}; diff --git a/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts b/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts index 5c806559c4b..5f1c506ca0b 100644 --- a/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts +++ b/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts @@ -18,8 +18,6 @@ import { queryEntitySubgraphQuery } from "../../graphql/queries/knowledge/entity import type { Org } from "../../lib/user-and-org"; import { constructOrg, isEntityOrgEntity } from "../../lib/user-and-org"; -const EMPTY_ORGS: Org[] = []; - /** * Retrieves a specific set of organizations, with their avatars and members populated */ @@ -101,7 +99,7 @@ export const useOrgsWithLinks = ({ const orgs = useMemo(() => { if (orgAccountGroupIds?.length === 0) { - return EMPTY_ORGS; + return []; } if (!queryEntitySubgraphResponse) { diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index 07d7a869978..3885d12827b 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -98,10 +98,12 @@ const App: FunctionComponent = ({ const primaryEmailVerified = authenticatedUser?.emails.find(({ primary }) => primary)?.verified ?? false; + const userMustVerifyEmail = !!authenticatedUser && emailVerificationStatusKnown && !primaryEmailVerified; + const awaitingEmailVerificationStatus = !!authenticatedUser && !emailVerificationStatusKnown && !aal2Required; diff --git a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx index 852b0381793..1b6b2606394 100644 --- a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx +++ b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx @@ -95,23 +95,14 @@ export const AuthInfoProvider: FunctionComponent = ({ .map((linkEntity) => new HashLinkEntity(linkEntity)); }, [authenticatedUserSubgraph]); - const isAuthenticatedUserPrimaryEmailVerified = verifiableAddresses.some( - ({ verified }) => verified, - ); - - const skipExtraAuthenticatedUserQueries = - !!authenticatedUserSubgraph && - (!emailVerificationStatusKnown || !isAuthenticatedUserPrimaryEmailVerified); - const { orgs: resolvedOrgs, refetch: refetchOrgs } = useOrgsWithLinks({ - orgAccountGroupIds: skipExtraAuthenticatedUserQueries - ? [] - : (userMemberOfLinks?.map( - (link) => - extractEntityUuidFromEntityId( - link.linkData.rightEntityId, - ) as string as ActorGroupEntityUuid, - ) ?? []), + orgAccountGroupIds: + userMemberOfLinks?.map( + (link) => + extractEntityUuidFromEntityId( + link.linkData.rightEntityId, + ) as string as ActorGroupEntityUuid, + ) ?? [], }); const constructUserValue = useCallback( @@ -144,9 +135,7 @@ export const AuthInfoProvider: FunctionComponent = ({ const apolloClient = useApolloClient(); - const { isUserAdmin: isInstanceAdmin } = useHashInstance({ - skip: skipExtraAuthenticatedUserQueries, - }); + const { isUserAdmin: isInstanceAdmin } = useHashInstance(); const fetchAuthenticatedUser = useCallback(async () => { From ae63dde0081197799d0b5e21595b1c68e9e48934 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 12:53:42 +0000 Subject: [PATCH 06/32] add HASH account name to TOTP identifier --- apps/hash-external-services/kratos/identity.schema.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/hash-external-services/kratos/identity.schema.json b/apps/hash-external-services/kratos/identity.schema.json index 3e6560ee0c8..dbc1e1f0734 100644 --- a/apps/hash-external-services/kratos/identity.schema.json +++ b/apps/hash-external-services/kratos/identity.schema.json @@ -34,6 +34,9 @@ "credentials": { "password": { "identifier": true + }, + "totp": { + "account_name": true } } } From e17a95d5c958c655a4add685c2d5da9cc43194f5 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 14:32:47 +0000 Subject: [PATCH 07/32] shorten rate limiter window --- apps/hash-api/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hash-api/src/index.ts b/apps/hash-api/src/index.ts index a4535a3cd2c..1ff368f3628 100644 --- a/apps/hash-api/src/index.ts +++ b/apps/hash-api/src/index.ts @@ -101,8 +101,8 @@ const httpServer = http.createServer(app); const shutdown = new GracefulShutdown(logger, "SIGINT", "SIGTERM"); const baseRateLimitOptions: Partial = { - windowMs: process.env.NODE_ENV === "test" ? 10 : 1000 * 30, // 30 seconds - limit: 10, // Limit each IP to 10 requests every 30 seconds + windowMs: process.env.NODE_ENV === "test" ? 10 : 1000 * 10, // 10 seconds + limit: 10, // Limit each IP to 10 requests every 10 seconds standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }; From b9f69ac02649a290f447fe68c210b499286a354e Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 14:33:06 +0000 Subject: [PATCH 08/32] validate password / TOTP before allowing relevant settings change --- .../src/pages/settings/security.page.tsx | 126 +++++++++++++----- 1 file changed, 96 insertions(+), 30 deletions(-) diff --git a/apps/hash-frontend/src/pages/settings/security.page.tsx b/apps/hash-frontend/src/pages/settings/security.page.tsx index aaecbe1d3b0..63d6e160dde 100644 --- a/apps/hash-frontend/src/pages/settings/security.page.tsx +++ b/apps/hash-frontend/src/pages/settings/security.page.tsx @@ -80,7 +80,9 @@ const SecurityPage: NextPageWithLayout = () => { authenticatedUser?.emails[0]?.address ?? ""; const [flow, setFlow] = useState(); + const [currentPassword, setCurrentPassword] = useState(""); const [password, setPassword] = useState(""); + const [currentPasswordError, setCurrentPasswordError] = useState(); const [totpCode, setTotpCode] = useState(""); const [disableTotpCode, setDisableTotpCode] = useState(""); const [showTotpSetupForm, setShowTotpSetupForm] = useState(false); @@ -238,22 +240,52 @@ const SecurityPage: NextPageWithLayout = () => { const handlePasswordSubmit: FormEventHandler = (event) => { event.preventDefault(); - if (!flow || !password) { + if (!flow || !currentPassword || !password) { return; } setUpdatingPassword(true); + setCurrentPasswordError(undefined); persistFlowIdInUrl(flow); - void submitSettingsUpdate(flow, { - method: "password", - password, - csrf_token: mustGetCsrfTokenFromFlow(flow), - }) - .then((nextFlow) => { - if (nextFlow) { - setPassword(""); + // Step 1: Verify the current password by creating and submitting a + // refresh login flow. This also refreshes the session to + // "privileged", ensuring the settings update won't be rejected. + void oryKratosClient + .createBrowserLoginFlow({ refresh: true }) + .then(({ data: loginFlow }) => + oryKratosClient.updateLoginFlow({ + flow: loginFlow.id, + updateLoginFlowBody: { + method: "password", + identifier: usernameForPasswordManagers, + password: currentPassword, + csrf_token: mustGetCsrfTokenFromFlow(loginFlow), + }, + }), + ) + .then( + // Step 2: Current password verified, now update to the new password + async () => { + const nextFlow = await submitSettingsUpdate(flow, { + method: "password", + password, + csrf_token: mustGetCsrfTokenFromFlow(flow), + }); + + if (nextFlow) { + setCurrentPassword(""); + setPassword(""); + } + }, + ) + .catch((error: AxiosError) => { + if (error.response?.status === 400) { + setCurrentPasswordError("Current password is incorrect."); + return; } + + void handleFlowError(error); }) .finally(() => setUpdatingPassword(false)); }; @@ -317,14 +349,25 @@ const SecurityPage: NextPageWithLayout = () => { setDisablingTotp(true); persistFlowIdInUrl(flow); + // Step 1: Validate the TOTP code to prove the user has authenticator access void submitSettingsUpdate(flow, { method: "totp", - totp_unlink: true, totp_code: disableTotpCode, csrf_token: mustGetCsrfTokenFromFlow(flow), }) - .then((nextFlow) => { - if (nextFlow) { + .then(async (verifiedFlow) => { + if (!verifiedFlow) { + return; + } + + // Step 2: Code was valid, now unlink TOTP + const unlinkedFlow = await submitSettingsUpdate(verifiedFlow, { + method: "totp", + totp_unlink: true, + csrf_token: mustGetCsrfTokenFromFlow(verifiedFlow), + }); + + if (unlinkedFlow) { setDisableTotpCode(""); setShowTotpDisableForm(false); } @@ -419,25 +462,48 @@ const SecurityPage: NextPageWithLayout = () => { > Password - setPassword(target.value)} - error={ - !!passwordInputUiNode?.messages.find( - ({ type }) => type === "error", - ) - } - helperText={passwordInputUiNode?.messages.map(({ id, text }) => ( - {text} - ))} - required - /> + + { + setCurrentPassword(target.value); + setCurrentPasswordError(undefined); + }} + error={!!currentPasswordError} + helperText={ + currentPasswordError ? ( + {currentPasswordError} + ) : undefined + } + required + /> + setPassword(target.value)} + error={ + !!passwordInputUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={passwordInputUiNode?.messages.map( + ({ id, text }) => {text}, + )} + required + /> + - From 9997510558f0f72b3eb89379b19994ea06a65a2c Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 14:33:23 +0000 Subject: [PATCH 09/32] prevent unnecessary re-renders --- .../src/components/hooks/use-orgs-with-links.ts | 4 +++- .../src/pages/shared/auth-info-context.tsx | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts b/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts index 5f1c506ca0b..58f0a7e35e3 100644 --- a/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts +++ b/apps/hash-frontend/src/components/hooks/use-orgs-with-links.ts @@ -18,6 +18,8 @@ import { queryEntitySubgraphQuery } from "../../graphql/queries/knowledge/entity import type { Org } from "../../lib/user-and-org"; import { constructOrg, isEntityOrgEntity } from "../../lib/user-and-org"; +const emptyOrgsArray: Org[] = []; + /** * Retrieves a specific set of organizations, with their avatars and members populated */ @@ -99,7 +101,7 @@ export const useOrgsWithLinks = ({ const orgs = useMemo(() => { if (orgAccountGroupIds?.length === 0) { - return []; + return emptyOrgsArray; } if (!queryEntitySubgraphResponse) { diff --git a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx index 1b6b2606394..6ef18e90fae 100644 --- a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx +++ b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx @@ -23,6 +23,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; @@ -137,6 +138,13 @@ export const AuthInfoProvider: FunctionComponent = ({ const { isUserAdmin: isInstanceAdmin } = useHashInstance(); + /** + * Use a ref to avoid `fetchAuthenticatedUser` depending on the identity of `constructUserValue`, + * which changes whenever `resolvedOrgs` or `userMemberOfLinks` change. + */ + const constructUserValueRef = useRef(constructUserValue); + constructUserValueRef.current = constructUserValue; + const fetchAuthenticatedUser = useCallback(async () => { /** @@ -196,9 +204,12 @@ export const AuthInfoProvider: FunctionComponent = ({ kratosSessionResult.session?.identity?.verifiable_addresses ?? []; return { - authenticatedUser: constructUserValue(subgraph, newVerifiableAddresses), + authenticatedUser: constructUserValueRef.current( + subgraph, + newVerifiableAddresses, + ), }; - }, [constructUserValue, apolloClient]); + }, [apolloClient]); useEffect(() => { void fetchAuthenticatedUser(); From e9b076d7969aa5419b97d0d8110ade04631e2c79 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 14:33:50 +0000 Subject: [PATCH 10/32] move / clean up files --- .../src/pages/shared/auth-layout.tsx | 1 + .../src/pages/shared/auth-utils.ts | 17 ---------------- .../verify-email-step.tsx | 11 ++++------ apps/hash-frontend/src/pages/signup.page.tsx | 3 ++- .../src/pages/verify-email.page.tsx | 20 +++++++++++-------- 5 files changed, 19 insertions(+), 33 deletions(-) rename apps/hash-frontend/src/pages/{signup.page => shared}/verify-email-step.tsx (95%) diff --git a/apps/hash-frontend/src/pages/shared/auth-layout.tsx b/apps/hash-frontend/src/pages/shared/auth-layout.tsx index 31561e1f756..e09866e4270 100644 --- a/apps/hash-frontend/src/pages/shared/auth-layout.tsx +++ b/apps/hash-frontend/src/pages/shared/auth-layout.tsx @@ -38,6 +38,7 @@ export const AuthLayout: FunctionComponent< flexGrow: 1, display: "flex", alignItems: "center", + justifyContent: "center", position: "relative", }} > diff --git a/apps/hash-frontend/src/pages/shared/auth-utils.ts b/apps/hash-frontend/src/pages/shared/auth-utils.ts index 44fdc6b6dc7..08343104c93 100644 --- a/apps/hash-frontend/src/pages/shared/auth-utils.ts +++ b/apps/hash-frontend/src/pages/shared/auth-utils.ts @@ -1,24 +1,7 @@ -/** - * @todo H-2421: Check this file for redundancy after implementing email verification. - */ - -import type { ParsedUrlQueryInput } from "node:querystring"; - import type { GraphQLError } from "graphql"; export const SYNTHETIC_LOADING_TIME_MS = 700; -type ParsedAuthQuery = { - verificationId: string; - verificationCode: string; -}; - -export const isParsedAuthQuery = ( - query: ParsedUrlQueryInput, -): query is ParsedAuthQuery => - typeof query.verificationId === "string" && - typeof query.verificationCode === "string"; - export const parseGraphQLError = ( errors: GraphQLError[], priorityErrorCode?: string, diff --git a/apps/hash-frontend/src/pages/signup.page/verify-email-step.tsx b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx similarity index 95% rename from apps/hash-frontend/src/pages/signup.page/verify-email-step.tsx rename to apps/hash-frontend/src/pages/shared/verify-email-step.tsx index abecfd5a602..24155da9585 100644 --- a/apps/hash-frontend/src/pages/signup.page/verify-email-step.tsx +++ b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx @@ -7,13 +7,10 @@ import type { FormEventHandler, FunctionComponent } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "../../shared/ui"; -import { AuthHeading } from "../shared/auth-heading"; -import { AuthPaper } from "../shared/auth-paper"; -import { - mustGetCsrfTokenFromFlow, - oryKratosClient, -} from "../shared/ory-kratos"; -import { useKratosErrorHandler } from "../shared/use-kratos-flow-error-handler"; +import { AuthHeading } from "./auth-heading"; +import { AuthPaper } from "./auth-paper"; +import { mustGetCsrfTokenFromFlow, oryKratosClient } from "./ory-kratos"; +import { useKratosErrorHandler } from "./use-kratos-flow-error-handler"; type VerifyEmailStepProps = { email: string; diff --git a/apps/hash-frontend/src/pages/signup.page.tsx b/apps/hash-frontend/src/pages/signup.page.tsx index a2065cf4914..b3f4cc462b9 100644 --- a/apps/hash-frontend/src/pages/signup.page.tsx +++ b/apps/hash-frontend/src/pages/signup.page.tsx @@ -25,13 +25,13 @@ import { Button } from "../shared/ui"; import { useAuthInfo } from "./shared/auth-info-context"; import { AuthLayout } from "./shared/auth-layout"; import { parseGraphQLError } from "./shared/auth-utils"; +import { VerifyEmailStep } from "./shared/verify-email-step"; import { AcceptOrgInvitation } from "./signup.page/accept-org-invitation"; import type { AccountSetupFormData } from "./signup.page/account-setup-form"; import { AccountSetupForm } from "./signup.page/account-setup-form"; import { SignupRegistrationForm } from "./signup.page/signup-registration-form"; import { SignupRegistrationRightInfo } from "./signup.page/signup-registration-right-info"; import { SignupSteps } from "./signup.page/signup-steps"; -import { VerifyEmailStep } from "./signup.page/verify-email-step"; const LoginButton = styled((props: ButtonProps) => ( } - heading={<>Organizations} + heading="Organizations" ref={topRef} > {authenticatedUser.memberOf.length > 0 ? ( diff --git a/apps/hash-frontend/src/pages/settings/security.page.tsx b/apps/hash-frontend/src/pages/settings/security.page.tsx index 63d6e160dde..bdb5b51e562 100644 --- a/apps/hash-frontend/src/pages/settings/security.page.tsx +++ b/apps/hash-frontend/src/pages/settings/security.page.tsx @@ -457,8 +457,8 @@ const SecurityPage: NextPageWithLayout = () => { }} /> ({ color: palette.gray[70], mb: 1.5 })} + variant="regularTextLabels" + sx={{ mb: 1.5, display: "block" }} > Password @@ -512,10 +512,7 @@ const SecurityPage: NextPageWithLayout = () => { - ({ color: palette.gray[70] })} - > + Two-factor authentication @@ -607,7 +604,10 @@ const SecurityPage: NextPageWithLayout = () => { onSubmit={handleEnableTotpSubmit} sx={{ display: "flex", flexDirection: "column", gap: 2 }} > - palette.gray[80] }}> + palette.gray[80] }} + > Scan the QR code with your authenticator app, then enter the 6-digit code to enable TOTP. @@ -618,35 +618,34 @@ const SecurityPage: NextPageWithLayout = () => { alt="TOTP QR code" data-testid="totp-qr-code" sx={{ - width: 220, - height: 220, + width: 180, + height: 180, borderRadius: 1, border: ({ palette }) => `1px solid ${palette.gray[30]}`, }} /> - ) : ( - palette.gray[70] }}> - QR code unavailable. Use the secret key below for manual - setup. - - )} + ) : null} {totpSecretKey ? ( ({ - color: palette.gray[70], + color: palette.gray[80], mb: 0.75, + display: "block", })} > - Secret key (manual setup) + {totpQrCodeDataUri + ? "Alternatively, use the secret key below for manual setup." + : "QR code unavailable. Use the secret key below for manual setup."} palette.gray[20], fontFamily: "monospace", diff --git a/apps/hash-frontend/src/pages/shared/settings-layout.tsx b/apps/hash-frontend/src/pages/shared/settings-layout.tsx index 0178b6700da..e2344404291 100644 --- a/apps/hash-frontend/src/pages/shared/settings-layout.tsx +++ b/apps/hash-frontend/src/pages/shared/settings-layout.tsx @@ -5,7 +5,7 @@ import { useMemo } from "react"; import type { Org } from "../../lib/user-and-org"; import { HouseSolidIcon } from "../../shared/icons/house-solid-icon"; -import { LockRegularIcon } from "../../shared/icons/lock-regular-icon"; +import { LockSolidIcon } from "../../shared/icons/lock-solid-icon"; import { PeopleGroupIcon } from "../../shared/icons/people-group-icon"; import { PlugSolidIcon } from "../../shared/icons/plug-solid-icon"; import { LayoutWithSidebar } from "../../shared/layout/layout-with-sidebar"; @@ -49,7 +49,7 @@ const generateMenuLinks = ( { label: "Security", href: "/settings/security", - icon: LockRegularIcon, + icon: LockSolidIcon, }, { label: "Organizations", diff --git a/apps/hash-frontend/src/pages/shared/verify-email-step.tsx b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx index 24155da9585..a087e51176a 100644 --- a/apps/hash-frontend/src/pages/shared/verify-email-step.tsx +++ b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx @@ -4,7 +4,7 @@ import type { VerificationFlow } from "@ory/client"; import { isUiNodeInputAttributes } from "@ory/integrations/ui"; import type { AxiosError } from "axios"; import type { FormEventHandler, FunctionComponent } from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Button } from "../../shared/ui"; import { AuthHeading } from "./auth-heading"; @@ -14,16 +14,21 @@ import { useKratosErrorHandler } from "./use-kratos-flow-error-handler"; type VerifyEmailStepProps = { email: string; + /** An error message to display initially (e.g. from a failed auto-verify attempt). */ + initialError?: string; onVerified: () => void | Promise; }; export const VerifyEmailStep: FunctionComponent = ({ email, + initialError, onVerified, }) => { const [flow, setFlow] = useState(); const [code, setCode] = useState(""); - const [errorMessage, setErrorMessage] = useState(); + const [errorMessage, setErrorMessage] = useState( + initialError, + ); const [sendingCode, setSendingCode] = useState(false); const [verifyingCode, setVerifyingCode] = useState(false); @@ -33,6 +38,14 @@ export const VerifyEmailStep: FunctionComponent = ({ setErrorMessage, }); + /** + * Use a ref so that callbacks don't depend on the identity of + * `handleFlowError`, which changes when `authenticatedUser` updates in the + * auth context. + */ + const handleFlowErrorRef = useRef(handleFlowError); + handleFlowErrorRef.current = handleFlowError; + const extractCodeValue = useCallback((nextFlow: VerificationFlow) => { const codeInputNode = nextFlow.ui.nodes.find( ({ attributes }) => @@ -75,7 +88,7 @@ export const VerifyEmailStep: FunctionComponent = ({ setFlow(data); extractCodeValue(data); }) - .catch(handleFlowError) + .catch((error: AxiosError) => handleFlowErrorRef.current(error)) .catch((error: AxiosError) => { if (error.response?.status === 400) { setFlow(error.response.data); @@ -85,11 +98,7 @@ export const VerifyEmailStep: FunctionComponent = ({ return Promise.reject(error); }) .finally(() => setSendingCode(false)); - }, [email, extractCodeValue, handleFlowError]); - - useEffect(() => { - createAndSendVerificationCode(); - }, [createAndSendVerificationCode]); + }, [email, extractCodeValue]); const codeInputUiNode = useMemo( () => @@ -109,6 +118,8 @@ export const VerifyEmailStep: FunctionComponent = ({ setVerifyingCode(true); + let succeeded = false; + void oryKratosClient .updateVerificationFlow({ flow: flow.id, @@ -120,8 +131,11 @@ export const VerifyEmailStep: FunctionComponent = ({ }) .then(async () => { await onVerified(); + succeeded = true; + }) + .catch(async (error: AxiosError) => { + await handleFlowErrorRef.current(error); }) - .catch(handleFlowError) .catch((error: AxiosError) => { if (error.response?.status === 400) { setFlow(error.response.data); @@ -130,9 +144,16 @@ export const VerifyEmailStep: FunctionComponent = ({ return Promise.reject(error); }) - .finally(() => setVerifyingCode(false)); + .finally(() => { + // Only reset on failure – on success, onVerified triggers navigation. + if (!succeeded) { + setVerifyingCode(false); + } + }); }; + const codeSentInSession = !!flow; + return ( Verify your email address @@ -143,7 +164,9 @@ export const VerifyEmailStep: FunctionComponent = ({ mb: 3, }} > - We've sent a verification code to {email} + {codeSentInSession + ? `Enter the verification code sent to ${email}` + : `We've sent a verification code to ${email}. Click the link in the email to verify, or request a new code below to enter manually.`} = ({ width: "100%", }} > - setCode(target.value)} - error={ - !!codeInputUiNode?.messages.find(({ type }) => type === "error") - } - helperText={codeInputUiNode?.messages.map(({ id, text }) => ( - {text} - ))} - required - inputProps={{ - maxLength: 6, - inputMode: "numeric", - pattern: "[0-9]{6}", - }} - /> - - + {codeSentInSession ? ( + <> + setCode(target.value)} + error={ + !!codeInputUiNode?.messages.find(({ type }) => type === "error") + } + helperText={codeInputUiNode?.messages.map(({ id, text }) => ( + {text} + ))} + required + inputProps={{ + maxLength: 6, + inputMode: "numeric", + pattern: "[0-9]{6}", + }} + /> + + + + ) : ( + + )} {flow?.ui.messages?.map(({ id, text }) => ( {text} ))} diff --git a/apps/hash-frontend/src/pages/verification.page.tsx b/apps/hash-frontend/src/pages/verification.page.tsx index ebcd4d356c3..65103a01fb4 100644 --- a/apps/hash-frontend/src/pages/verification.page.tsx +++ b/apps/hash-frontend/src/pages/verification.page.tsx @@ -1,180 +1,170 @@ -import { TextField } from "@hashintel/design-system"; -import { Box, Container, Typography } from "@mui/material"; +import { Box, CircularProgress, Typography } from "@mui/material"; import type { VerificationFlow } from "@ory/client"; -import { isUiNodeInputAttributes } from "@ory/integrations/ui"; +import type { AxiosError } from "axios"; import { useRouter } from "next/router"; -import type { FormEventHandler } from "react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useLogoutFlow } from "../components/hooks/use-logout-flow"; import type { NextPageWithLayout } from "../shared/layout"; import { getPlainLayout } from "../shared/layout"; import { Button } from "../shared/ui"; -import { - gatherUiNodeValuesFromFlow, - oryKratosClient, -} from "./shared/ory-kratos"; -import { useKratosErrorHandler } from "./shared/use-kratos-flow-error-handler"; - -const VerificationPage: NextPageWithLayout = () => { - // Get ?flow=... from the URL - const router = useRouter(); +import { useAuthInfo } from "./shared/auth-info-context"; +import { AuthLayout } from "./shared/auth-layout"; +import { mustGetCsrfTokenFromFlow, oryKratosClient } from "./shared/ory-kratos"; +import { VerifyEmailStep } from "./shared/verify-email-step"; - const { - return_to: returnTo, - flow: flowId, - // Refresh means we want to refresh the session. This is needed, for example, when we want to update the password - // of a user. - refresh, - // AAL = Authorization Assurance Level. This implies that we want to upgrade the AAL, meaning that we want - // to perform two-factor authentication/verification. - aal, - } = router.query; - - const [flow, setFlow] = useState(); - const [code, setCode] = useState(""); - const [errorMessage, setErrorMessage] = useState(); - - const { handleFlowError } = useKratosErrorHandler({ - flowType: "verification", - setFlow, - setErrorMessage, - }); - - // This might be confusing, but we want to show the user an option - // to sign out if they are performing two-factor authentication! +const VerifyEmailPage: NextPageWithLayout = () => { + const router = useRouter(); const { logout } = useLogoutFlow(); + const { authenticatedUser, emailVerificationStatusKnown, refetch } = + useAuthInfo(); - const extractFlowCodeValue = (flowToSearch: VerificationFlow | undefined) => { - const uiCode = flowToSearch?.ui.nodes.find( - ({ attributes }) => - isUiNodeInputAttributes(attributes) && attributes.name === "code", - ); - if (uiCode?.attributes && "value" in uiCode.attributes) { - setCode(String(uiCode.attributes.value)); + const primaryEmailVerified = + authenticatedUser?.emails.find(({ primary }) => primary)?.verified ?? false; + + const urlCode = + typeof router.query.code === "string" ? router.query.code : undefined; + const urlFlowId = + typeof router.query.flow === "string" ? router.query.flow : undefined; + + const [autoVerifying, setAutoVerifying] = useState(false); + const [autoVerifyError, setAutoVerifyError] = useState(); + const autoVerifyAttempted = useRef(false); + + useEffect(() => { + if (emailVerificationStatusKnown && !authenticatedUser) { + void router.replace("/signin"); } - }; + }, [authenticatedUser, emailVerificationStatusKnown, router]); useEffect(() => { - // If the router is not ready yet, or we already have a flow, do nothing. - if (!router.isReady || flow) { - return; + if (authenticatedUser && primaryEmailVerified) { + void router.replace("/"); } + }, [authenticatedUser, primaryEmailVerified, router]); - // If ?flow=.. was in the URL, we fetch it - if (flowId) { - oryKratosClient - .getVerificationFlow({ id: String(flowId) }) - .then(({ data }) => { - setFlow(data); - extractFlowCodeValue(data); - }) - .catch(handleFlowError); + /** + * When the page is loaded with both `code` and `flow` query params (e.g. + * from clicking the verification link in an email), attempt to verify the + * email automatically without requiring the user to enter the code. + */ + useEffect(() => { + if ( + !urlCode || + !urlFlowId || + !authenticatedUser || + primaryEmailVerified || + autoVerifyAttempted.current + ) { return; } - // Otherwise we initialize it - oryKratosClient - .createBrowserVerificationFlow({ - returnTo: returnTo ? String(returnTo) : undefined, - }) - .then(({ data }) => { - setFlow(data); - extractFlowCodeValue(data); + autoVerifyAttempted.current = true; + setAutoVerifying(true); + + void oryKratosClient + .getVerificationFlow({ id: urlFlowId }) + .then(({ data: existingFlow }) => + oryKratosClient.updateVerificationFlow({ + flow: existingFlow.id, + updateVerificationFlowBody: { + method: "code", + code: urlCode, + csrf_token: mustGetCsrfTokenFromFlow(existingFlow), + }, + }), + ) + .then(async () => { + await refetch(); + void router.replace("/"); }) - .catch(handleFlowError); + .catch((error: AxiosError) => { + const errorMessages = + error.response?.data.ui.messages + ?.filter(({ type }) => type === "error") + .map(({ text }) => text) ?? []; + + setAutoVerifyError( + errorMessages.length > 0 + ? errorMessages.join(" ") + : "The verification link may have expired. A new code has been sent to your email.", + ); + setAutoVerifying(false); + + // Strip the code and flow params from the URL so we don't retry + void router.replace("/verification", undefined, { shallow: true }); + }); }, [ - flowId, + urlCode, + urlFlowId, + authenticatedUser, + primaryEmailVerified, + refetch, router, - router.isReady, - aal, - refresh, - returnTo, - flow, - handleFlowError, ]); - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); - event.stopPropagation(); + if (!authenticatedUser || primaryEmailVerified) { + return null; + } - if (!flow) { - return; - } - - void router - // On submission, add the flow ID to the URL but do not navigate. This prevents the user loosing - // their data when they reload the page. - .push(`/verification`, { query: { flow: flow.id } }, { shallow: true }); - - oryKratosClient - .updateVerificationFlow({ - flow: String(flow.id), - updateVerificationFlowBody: { - ...gatherUiNodeValuesFromFlow<"verification">(flow), - method: "code", - code, - }, - }) - .then(({ data }) => { - // Form submission was successful, show the message to the user! - setFlow(data); - void router.push("/"); - }) - .catch(handleFlowError); - }; - - const codeInputUiNode = flow?.ui.nodes.find( - ({ attributes }) => - isUiNodeInputAttributes(attributes) && attributes.name === "code", - ); - - return ( - - - Account verification - - *": { - marginTop: 1, - }, + justifyContent: "center", + alignItems: "center", }} > - setCode(target.value)} - error={ - !!codeInputUiNode?.messages.find(({ type }) => type === "error") - } - helperText={codeInputUiNode?.messages.map(({ id, text }) => ( - {text} - ))} - required + + + palette.gray[70] }} + > + Verifying your email... + + + + ); + } + + return ( + + + { + await refetch(); + void router.push("/"); + }} /> - - {flow?.ui.messages?.map(({ text, id }) => ( - {text} - ))} - {errorMessage ? {errorMessage} : null} - - - + + ); }; -VerificationPage.getLayout = getPlainLayout; +VerifyEmailPage.getLayout = getPlainLayout; -export default VerificationPage; +export default VerifyEmailPage; diff --git a/apps/hash-frontend/src/pages/verify-email.page.tsx b/apps/hash-frontend/src/pages/verify-email.page.tsx deleted file mode 100644 index 8d58cd21dff..00000000000 --- a/apps/hash-frontend/src/pages/verify-email.page.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Box } from "@mui/material"; -import { useRouter } from "next/router"; -import { useEffect } from "react"; - -import { useLogoutFlow } from "../components/hooks/use-logout-flow"; -import type { NextPageWithLayout } from "../shared/layout"; -import { getPlainLayout } from "../shared/layout"; -import { Button } from "../shared/ui"; -import { useAuthInfo } from "./shared/auth-info-context"; -import { AuthLayout } from "./shared/auth-layout"; -import { VerifyEmailStep } from "./shared/verify-email-step"; - -const VerifyEmailPage: NextPageWithLayout = () => { - const router = useRouter(); - const { logout } = useLogoutFlow(); - const { authenticatedUser, emailVerificationStatusKnown, refetch } = - useAuthInfo(); - - const primaryEmailVerified = - authenticatedUser?.emails.find(({ primary }) => primary)?.verified ?? false; - - useEffect(() => { - if (emailVerificationStatusKnown && !authenticatedUser) { - void router.replace("/signin"); - } - }, [authenticatedUser, emailVerificationStatusKnown, router]); - - useEffect(() => { - if (authenticatedUser && primaryEmailVerified) { - void router.replace("/"); - } - }, [authenticatedUser, primaryEmailVerified, router]); - - if (!authenticatedUser || primaryEmailVerified) { - return null; - } - - return ( - - - { - await refetch(); - void router.push("/"); - }} - /> - - - - ); -}; - -VerifyEmailPage.getLayout = getPlainLayout; - -export default VerifyEmailPage; diff --git a/apps/hash-frontend/src/shared/icons/lock-solid-icon.tsx b/apps/hash-frontend/src/shared/icons/lock-solid-icon.tsx new file mode 100644 index 00000000000..82bc5f796b1 --- /dev/null +++ b/apps/hash-frontend/src/shared/icons/lock-solid-icon.tsx @@ -0,0 +1,11 @@ +import type { SvgIconProps } from "@mui/material"; +import { SvgIcon } from "@mui/material"; +import type { FunctionComponent } from "react"; + +export const LockSolidIcon: FunctionComponent = (props) => { + return ( + + + + ); +}; diff --git a/tests/hash-backend-integration/src/tests/util.ts b/tests/hash-backend-integration/src/tests/util.ts index 300ee42de0a..a610f3fd727 100644 --- a/tests/hash-backend-integration/src/tests/util.ts +++ b/tests/hash-backend-integration/src/tests/util.ts @@ -102,7 +102,6 @@ export const createTestUser = async ( const identity = await createKratosIdentity({ traits: { - shortname, emails: [`${shortname}@example.com`], }, }).catch((err) => { From 7a1c4e17785122f9fe4177c55b616d47ea661829 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 17:08:38 +0000 Subject: [PATCH 13/32] improve kratos email formatting --- .../recovery_code/invalid/email.body.gotmpl | 4 ++-- .../recovery_code/valid/email.body.gotmpl | 12 ++++++------ .../verification_code/invalid/email.body.gotmpl | 4 ++-- .../verification_code/valid/email.body.gotmpl | 14 +++++++------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl index c96b2b9dc8e..c31acea9dae 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl @@ -8,9 +8,9 @@
- +
-
+

Recovery attempted

diff --git a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl index a69e31dd8af..a2c40c53328 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl @@ -8,24 +8,24 @@
- +
- -
+

Recover your account

-

+

Enter the code below in HASH to recover access to your account.

+ - @@ -34,7 +34,7 @@ -
+ {{ .RecoveryCode }}
+

Enter this code in HASH directly. Do not share this code with anyone.

diff --git a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl index b1dd74d840d..0b84aee9440 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl @@ -8,9 +8,9 @@
- +
-
+

Verification attempted

diff --git a/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl index 3e477f6f112..2b064ec257b 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl @@ -11,24 +11,24 @@
- +
- -
+

Verify your email address

-

+

Enter the code below in HASH to verify your email address.

+ - @@ -37,7 +37,7 @@ - -
+ {{ .VerificationCode }}
+

Or click the button below to verify automatically:

@@ -54,7 +54,7 @@
+

This code expires in 48 hours. If you didn't create a HASH account, you can safely ignore this email.

From 313a2e13111ba461477dd69a6ead5358c89cb9f3 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Wed, 11 Feb 2026 17:33:30 +0000 Subject: [PATCH 14/32] bug / ui fixes --- .../hash-api/src/auth/create-auth-handlers.ts | 15 +--- apps/hash-api/src/auth/ory-kratos.ts | 16 ---- apps/hash-frontend/src/pages/_app.page.tsx | 76 +++++++++++++++---- .../src/pages/shared/_app.util.ts | 14 ++-- .../src/pages/shared/auth-info-context.tsx | 13 +++- .../src/pages/shared/verify-email-step.tsx | 30 ++++++-- .../src/pages/verification.page.tsx | 42 +++++----- 7 files changed, 130 insertions(+), 76 deletions(-) diff --git a/apps/hash-api/src/auth/create-auth-handlers.ts b/apps/hash-api/src/auth/create-auth-handlers.ts index b32bddc2ad3..bbcb1e64319 100644 --- a/apps/hash-api/src/auth/create-auth-handlers.ts +++ b/apps/hash-api/src/auth/create-auth-handlers.ts @@ -3,7 +3,6 @@ import { getHashInstance } from "@local/hash-backend-utils/hash-instance"; import type { Logger } from "@local/hash-backend-utils/logger"; import { publicUserAccountId } from "@local/hash-backend-utils/public-user-account-id"; import type { Session } from "@ory/kratos-client"; -import * as Sentry from "@sentry/node"; import type { AxiosError } from "axios"; import type { Express, Request, RequestHandler } from "express"; @@ -13,11 +12,7 @@ import { createUser, getUser } from "../graph/knowledge/system-types/user"; import { systemAccountId } from "../graph/system-account"; import { hydraAdmin } from "./ory-hydra"; import type { KratosUserIdentity } from "./ory-kratos"; -import { - isUserEmailVerified, - kratosFrontendApi, - sendVerificationEmail, -} from "./ory-kratos"; +import { isUserEmailVerified, kratosFrontendApi } from "./ory-kratos"; const KRATOS_API_KEY = getRequiredEnv("KRATOS_API_KEY"); @@ -67,14 +62,6 @@ const kratosAfterRegistrationHookHandler = kratosIdentityId, }); - const primaryEmail = emails[0]; - if (primaryEmail) { - sendVerificationEmail(primaryEmail).catch((error) => { - Sentry.captureException(error); - // Don't block signup completion if email sending fails – users can re-request from frontend. - }); - } - res.status(200).end(); } catch (error) { // The kratos hook can interrupt creation on 4xx and 5xx responses. diff --git a/apps/hash-api/src/auth/ory-kratos.ts b/apps/hash-api/src/auth/ory-kratos.ts index f2e215fc672..ed8aef4b7cb 100644 --- a/apps/hash-api/src/auth/ory-kratos.ts +++ b/apps/hash-api/src/auth/ory-kratos.ts @@ -58,19 +58,3 @@ export const isUserEmailVerified = async ( identity.verifiable_addresses?.some(({ verified }) => verified) ?? false ); }; - -/** - * Send a verification email using the Kratos self-service verification flow. - * Uses the native (non-browser) flow to avoid CSRF token requirements. - */ -export const sendVerificationEmail = async (email: string): Promise => { - const { data: flow } = await kratosFrontendApi.createNativeVerificationFlow(); - - await kratosFrontendApi.updateVerificationFlow({ - flow: flow.id, - updateVerificationFlowBody: { - method: "code", - email, - }, - }); -}; diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index 31545c525bb..6469a3657cd 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -64,6 +64,8 @@ const clientSideEmotionCache = createEmotionCache(); type AppInitialProps = { initialAuthenticatedUserSubgraph?: Subgraph>; + /** Set when getInitialProps determines a client-side redirect is needed. */ + redirectTo?: string; user?: MinimalUser; }; @@ -73,11 +75,12 @@ type AppProps = { } & AppInitialProps & NextAppProps; -const unverifiedUserPermittedPagePathnames = ["/verification"]; +const unverifiedUserPermittedPagePathnames = ["/verification", "/signup"]; -const App: FunctionComponent = ({ +const App: FunctionComponent = ({ Component, pageProps, + redirectTo, emotionCache = clientSideEmotionCache, }) => { // Helps prevent tree mismatch between server and client on initial render @@ -107,6 +110,17 @@ const App: FunctionComponent = ({ const awaitingEmailVerificationStatus = !!authenticatedUser && !emailVerificationStatusKnown && !aal2Required; + /** + * Handle client-side redirects determined by getInitialProps. These are + * deferred to useEffect so they don't conflict with the in-progress route + * transition (which would stall the NProgress bar). + */ + useEffect(() => { + if (redirectTo && router.isReady) { + void router.replace(redirectTo); + } + }, [redirectTo, router]); + useEffect(() => { if ( !router.isReady || @@ -240,7 +254,7 @@ const App: FunctionComponent = ({ const AppWithTypeSystemContextProvider: AppPage = ( props, ) => { - const { initialAuthenticatedUserSubgraph, user } = props; + const { initialAuthenticatedUserSubgraph, redirectTo, user } = props; return ( @@ -248,7 +262,7 @@ const AppWithTypeSystemContextProvider: AppPage = ( initialAuthenticatedUserSubgraph={initialAuthenticatedUserSubgraph} key={user?.accountId} > - + ); @@ -337,19 +351,28 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { ? getRoots(initialAuthenticatedUserSubgraph)[0] : undefined; + /** + * Helper: on server-side, performs an HTTP 307 redirect. On client-side, + * returns a redirectTo field so the component can handle it via useEffect + * (avoids calling router.push during an active transition, which stalls NProgress). + */ + const redirect = (location: string): AppInitialProps => { + redirectInGetInitialProps({ appContext, location }); + return { redirectTo: req ? undefined : location }; + }; + /** @todo: make additional pages publicly accessible */ if (!userEntity) { // If the user is logged out and not on a page that should be publicly accessible... if (!publiclyAccessiblePagePathnames.includes(pathname)) { // ...redirect them to the sign in page - redirectInGetInitialProps({ - appContext, - location: `/signin${ + return redirect( + `/signin${ ["", "/", "/404"].includes(pathname) ? "" : `?return_to=${req?.url ?? asPath}` }`, - }); + ); } return {}; @@ -361,15 +384,22 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { if (primaryEmailVerified === false) { if (!unverifiedUserPermittedPagePathnames.includes(pathname)) { - redirectInGetInitialProps({ appContext, location: "/verification" }); + return { + initialAuthenticatedUserSubgraph, + user, + ...redirect("/verification"), + }; } return { initialAuthenticatedUserSubgraph, user }; } if (primaryEmailVerified === true && pathname === "/verification") { - redirectInGetInitialProps({ appContext, location: "/" }); - return { initialAuthenticatedUserSubgraph, user }; + return { + initialAuthenticatedUserSubgraph, + user, + ...redirect("/"), + }; } // If the user is logged in but hasn't completed signup... @@ -384,18 +414,30 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { // ...if they have access to HASH but aren't on the signup page... if (hasAccessToHash && !pathname.startsWith("/signup")) { // ...then redirect them to the signup page. - redirectInGetInitialProps({ appContext, location: "/signup" }); + return { + initialAuthenticatedUserSubgraph, + user, + ...redirect("/signup"), + }; // ...if they don't have access to HASH but aren't on the home page... } else if (!hasAccessToHash && pathname !== "/") { // ...then redirect them to the home page. - redirectInGetInitialProps({ appContext, location: "/" }); + return { + initialAuthenticatedUserSubgraph, + user, + ...redirect("/"), + }; } } else if (redirectIfAuthenticatedPathnames.includes(pathname)) { /** * If the user has completed signup and is on a page they shouldn't be on * (e.g. /signup), then redirect them to the home page. */ - redirectInGetInitialProps({ appContext, location: "/" }); + return { + initialAuthenticatedUserSubgraph, + user, + ...redirect("/"), + }; } // For each feature flag... @@ -417,7 +459,11 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { if (!isUserAdmin) { // ...then redirect them to the home page instead. - redirectInGetInitialProps({ appContext, location: "/" }); + return { + initialAuthenticatedUserSubgraph, + user, + ...redirect("/"), + }; } } } diff --git a/apps/hash-frontend/src/pages/shared/_app.util.ts b/apps/hash-frontend/src/pages/shared/_app.util.ts index 89f46804841..722af32a70f 100644 --- a/apps/hash-frontend/src/pages/shared/_app.util.ts +++ b/apps/hash-frontend/src/pages/shared/_app.util.ts @@ -8,26 +8,30 @@ export type AppPage

, IP = P> = NextComponentType< P >; +/** + * Redirect during getInitialProps. Server-side, this sends an HTTP 307. + * Client-side, this is a no-op — callers should return a `redirectTo` field + * from getInitialProps so the component can handle it via useEffect, avoiding + * calling router.push during an active route transition (which stalls NProgress). + */ export const redirectInGetInitialProps = (params: { appContext: AppContext; location: string; }) => { const { appContext: { - ctx: { req, res }, - router, + ctx: { res }, }, location, } = params; - if (req && res) { + if (res) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- not a function whilst building next, so return instead. if (!res.writeHead) { return; } res.writeHead(307, { Location: location }); res.end(); - } else { - void router.push(location); } + // On client-side, do nothing. The component handles redirects via useEffect. }; diff --git a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx index 6ef18e90fae..a27383e58e4 100644 --- a/apps/hash-frontend/src/pages/shared/auth-info-context.tsx +++ b/apps/hash-frontend/src/pages/shared/auth-info-context.tsx @@ -227,8 +227,17 @@ export const AuthInfoProvider: FunctionComponent = ({ emailVerificationStatusKnown, isInstanceAdmin, refetch: async () => { - // Refetch the detail on orgs in case this refetch is following them being modified - await refetchOrgs(); + // Refetch the detail on orgs in case this refetch is following them being modified. + // Only attempt if the user has completed signup – users who haven't finished + // setup (e.g. still verifying email) cannot query entities yet. + if (authenticatedUser?.accountSignupComplete) { + try { + await refetchOrgs(); + } catch { + // Swallow so that fetchAuthenticatedUser still runs + } + } + return fetchAuthenticatedUser(); }, }), diff --git a/apps/hash-frontend/src/pages/shared/verify-email-step.tsx b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx index a087e51176a..46139a325a5 100644 --- a/apps/hash-frontend/src/pages/shared/verify-email-step.tsx +++ b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx @@ -6,6 +6,7 @@ import type { AxiosError } from "axios"; import type { FormEventHandler, FunctionComponent } from "react"; import { useCallback, useMemo, useRef, useState } from "react"; +import { isProduction } from "../../lib/config"; import { Button } from "../../shared/ui"; import { AuthHeading } from "./auth-heading"; import { AuthPaper } from "./auth-paper"; @@ -152,8 +153,6 @@ export const VerifyEmailStep: FunctionComponent = ({ }); }; - const codeSentInSession = !!flow; - return ( Verify your email address @@ -164,9 +163,9 @@ export const VerifyEmailStep: FunctionComponent = ({ mb: 3, }} > - {codeSentInSession + {flow ? `Enter the verification code sent to ${email}` - : `We've sent a verification code to ${email}. Click the link in the email to verify, or request a new code below to enter manually.`} + : `We've sent a verification code to ${email}. Click the link in the email to verify instantly, or request a new code below to enter manually.`} = ({ width: "100%", }} > - {codeSentInSession ? ( + {flow ? ( <> = ({ ))} {errorMessage ? {errorMessage} : null} + {!isProduction ? ( + palette.gray[50], + textAlign: "center", + }} + > + Dev mode: check{" "} + + MailSlurper (localhost:4436) + {" "} + for the verification email. + + ) : null} ); }; diff --git a/apps/hash-frontend/src/pages/verification.page.tsx b/apps/hash-frontend/src/pages/verification.page.tsx index 65103a01fb4..5ebedf3bd74 100644 --- a/apps/hash-frontend/src/pages/verification.page.tsx +++ b/apps/hash-frontend/src/pages/verification.page.tsx @@ -1,4 +1,4 @@ -import { Box, CircularProgress, Typography } from "@mui/material"; +import { Box, CircularProgress, styled, Typography } from "@mui/material"; import type { VerificationFlow } from "@ory/client"; import type { AxiosError } from "axios"; import { useRouter } from "next/router"; @@ -7,12 +7,30 @@ import { useEffect, useRef, useState } from "react"; import { useLogoutFlow } from "../components/hooks/use-logout-flow"; import type { NextPageWithLayout } from "../shared/layout"; import { getPlainLayout } from "../shared/layout"; +import type { ButtonProps } from "../shared/ui"; import { Button } from "../shared/ui"; import { useAuthInfo } from "./shared/auth-info-context"; import { AuthLayout } from "./shared/auth-layout"; import { mustGetCsrfTokenFromFlow, oryKratosClient } from "./shared/ory-kratos"; import { VerifyEmailStep } from "./shared/verify-email-step"; +const LogoutButton = styled((props: ButtonProps) => ( + ); }; From 49ecd7cdde70b55917b580dc709a52b701ff2fdd Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Thu, 12 Feb 2026 14:14:42 +0000 Subject: [PATCH 15/32] sign up flow fixes, test fixes, email template padding --- .env.test | 2 +- PLAN-NEXT.md | 660 ----------------- PLAN.md | 696 ------------------ apps/hash-api/src/graphql/resolvers/index.ts | 5 +- .../knowledge/user/has-access-to-hash.ts | 6 +- .../src/shared/user-has-access-to-hash.ts | 6 +- .../recovery_code/invalid/email.body.gotmpl | 17 +- .../recovery_code/valid/email.body.gotmpl | 25 +- .../invalid/email.body.gotmpl | 17 +- .../verification_code/valid/email.body.gotmpl | 27 +- apps/hash-frontend/src/pages/_app.page.tsx | 244 +++--- .../src/pages/settings/security.page.tsx | 19 +- .../src/pages/shared/_app.util.ts | 11 +- .../src/pages/shared/verify-code.tsx | 301 -------- .../src/pages/shared/verify-email-step.tsx | 129 ++-- apps/hash-frontend/src/pages/signin.page.tsx | 11 +- apps/hash-frontend/src/pages/signup.page.tsx | 42 +- .../signup.page/signup-registration-form.tsx | 53 +- .../src/pages/verification.page.tsx | 15 +- .../shared/draft-entities-count-context.tsx | 2 +- .../src/shared/invites-context.tsx | 2 +- .../src/shared/notification-count-context.tsx | 2 +- .../graph/knowledge/system-types/user.test.ts | 10 +- tests/hash-playwright/tests/mfa.spec.ts | 33 +- 24 files changed, 364 insertions(+), 1971 deletions(-) delete mode 100644 PLAN-NEXT.md delete mode 100644 PLAN.md delete mode 100644 apps/hash-frontend/src/pages/shared/verify-code.tsx diff --git a/.env.test b/.env.test index 1ee9e5eaeba..4add5784885 100644 --- a/.env.test +++ b/.env.test @@ -17,4 +17,4 @@ AWS_S3_UPLOADS_SECRET_ACCESS_KEY="dev-s3-secret-access-key" AWS_S3_UPLOADS_FORCE_PATH_STYLE=true FILE_UPLOAD_PROVIDER="AWS_S3" -USER_EMAIL_ALLOW_LIST='["charlie@example.com"]' +USER_EMAIL_ALLOW_LIST='["charlie@example.com", "mfa-enable-totp@example.com", "mfa-totp-login@example.com", "mfa-backup-code@example.com", "mfa-disable-totp@example.com", "mfa-wrong-code@example.com"]' diff --git a/PLAN-NEXT.md b/PLAN-NEXT.md deleted file mode 100644 index 3c393cf7386..00000000000 --- a/PLAN-NEXT.md +++ /dev/null @@ -1,660 +0,0 @@ -# Optional MFA: TOTP + Backup Codes - -Add optional multi-factor authentication to HASH. Users can enable TOTP (Google Authenticator, Authy, etc.) in their account settings. Backup codes (lookup secrets) are generated alongside as a fallback. Users with MFA enabled must enter a code after their password at login. - -## Table of Contents - -- [Current State](#current-state) -- [Target State](#target-state) -- [Architecture Context](#architecture-context) -- [Implementation Steps](#implementation-steps) - - [Kratos Configuration](#kratos-configuration) - - [Frontend: Security Settings Page](#frontend-security-settings-page) - - [Frontend: Login Flow for AAL2](#frontend-login-flow-for-aal2) - - [Frontend: Auth Context AAL2 Handling](#frontend-auth-context-aal2-handling) - - [API: Auth Middleware](#api-auth-middleware) -- [Playwright Tests](#playwright-tests) -- [Files Changed Summary](#files-changed-summary) -- [Key Reference Files](#key-reference-files) -- [Notes](#notes) - ---- - -## Current State - -No MFA is implemented, but there is partial scaffolding: - -- **Frontend error handler** (`apps/hash-frontend/src/pages/shared/use-kratos-flow-error-handler.ts` line 31) already handles `session_aal2_required` errors from Kratos -- redirects the user to Kratos's `redirect_browser_to` URL. -- **Signin page** (`apps/hash-frontend/src/pages/signin.page.tsx` lines 57-59, 135) already accepts an `aal` query parameter and passes it to `createBrowserLoginFlow()`. Comment says: "AAL = Authorization Assurance Level. This implies that we want to upgrade the AAL, meaning that we want to perform two-factor authentication/verification." -- **API auth middleware** (`apps/hash-api/src/auth/create-auth-handlers.ts` lines 121-123, 209-211) catches 403 from `toSession()` but has a TODO: `/** @todo: figure out if this should be handled here, or in the next.js app (when implementing 2FA) */` -- **Kratos config** has no TOTP, WebAuthn, or lookup_secret methods enabled. Only `password` and `code` are configured. -- **No security settings page** exists. Password changes are on a standalone `/change-password` page outside the settings layout. - -## Target State - -```mermaid -flowchart TD - subgraph settings [Settings: Security Page] - S1[User opens /settings/security] - S1 --> S2{TOTP enabled?} - S2 -->|No| S3[Show QR code + secret] - S3 --> S4[User scans + enters code] - S4 --> S5[TOTP enabled] - S5 --> S6[Open backup codes modal] - S6 --> S7["User saves codes + confirms"] - S2 -->|Yes| S8[Show TOTP status] - S8 --> S9["Disable TOTP (requires code)"] - S8 --> S10["Regenerate backup codes (privileged session)"] - S10 --> S6 - end - - subgraph login [Login with MFA] - L1[User enters email + password] - L1 --> L2{User has TOTP?} - L2 -->|No| L3[Normal login - AAL1 sufficient] - L2 -->|Yes| L4[Show TOTP code input] - L4 --> L5[User enters code from app] - L5 --> L6[Session upgraded to AAL2] - L4 --> L7[Or: use backup code] - L7 --> L6 - end -``` - ---- - -## Architecture Context - -### How MFA works in Ory Kratos - -Kratos uses **Authentication Assurance Levels (AAL)**: - -- **AAL1**: Single-factor authentication (password only) -- **AAL2**: Multi-factor authentication (password + TOTP/backup code) - -When configured with `session.whoami.required_aal: highest_available`: - -- Users **without** TOTP: AAL1 sessions are sufficient, everything works as before -- Users **with** TOTP: `toSession()` returns 403 if the session is only AAL1, forcing AAL2 completion - -### How TOTP setup works (Kratos settings flow) - -1. Create a settings flow: `createBrowserSettingsFlow()` -2. The flow's UI nodes include a `totp` group with: - - `totp_qr`: A node with `type: "img"` containing a data URI for the QR code - - `totp_secret_key`: A text node with the TOTP secret (for manual entry) - - `totp_code`: An input node for the verification code -3. User scans QR code with authenticator app, enters the 6-digit code -4. Submit: `updateSettingsFlow()` with `method: "totp"` and the entered code -5. Kratos verifies the code and stores the TOTP credential on the identity - -To **unlink** TOTP: The settings flow includes a `totp_unlink` button node. Submitting it removes TOTP from the identity. - -### How lookup secrets (backup codes) work - -1. Create a settings flow -2. The flow's UI nodes include a `lookup_secret` group with: - - `lookup_secret_codes`: A text node containing the generated codes (shown once) - - `lookup_secret_confirm`: A checkbox/hidden input to confirm codes have been saved -3. Submit: `updateSettingsFlow()` with `method: "lookup_secret"` to regenerate, or confirm to save -4. Each backup code is single-use - -### How TOTP login works (AAL2 step) - -1. User logs in with email+password (creates AAL1 session) -2. Frontend calls `toSession()` -- if user has TOTP, Kratos returns 403 with `redirect_browser_to` -3. Frontend redirects to `/signin?aal=aal2&flow={flowId}` (or Kratos provides the URL) -4. `createBrowserLoginFlow({ aal: "aal2" })` returns a login flow with TOTP/lookup_secret UI nodes -5. User enters TOTP code (or backup code) -6. Submit: `updateLoginFlow()` with `method: "totp"` (or `method: "lookup_secret"`) and the code -7. Session is upgraded to AAL2 -8. `toSession()` now succeeds - -### Kratos settings flow UI node groups - -When you create a settings flow, Kratos returns UI nodes organized into groups. The relevant groups for MFA are: - -- **`password`** group: Password change inputs (existing functionality) -- **`totp`** group: TOTP setup QR code, secret, code input, or unlink button -- **`lookup_secret`** group: Backup codes display, confirm/regenerate - -Each group should be rendered as a separate section on the security settings page. - ---- - -## Implementation Steps - -### Kratos Configuration - -#### Step 1: Enable TOTP and lookup_secret methods - -**Files:** `apps/hash-external-services/kratos/kratos.dev.yml` and `apps/hash-external-services/kratos/kratos.prod.yml` - -Add to `selfservice.methods`: - -```yaml -selfservice: - methods: - password: - enabled: true - link: - config: - enabled: false - base_url: http://localhost:3000/api/ory - code: - config: - enabled: true - # NEW: - totp: - config: - # "issuer" is shown in authenticator apps alongside the account name - issuer: HASH - enabled: true - lookup_secret: - enabled: true -``` - -#### Step 2: Configure AAL requirement - -**Files:** `apps/hash-external-services/kratos/kratos.dev.yml` and `apps/hash-external-services/kratos/kratos.prod.yml` - -Add or update the `session` block: - -```yaml -session: - lifespan: 26280h - whoami: - required_aal: highest_available -``` - -`highest_available` means: - -- Users without a second factor: AAL1 is sufficient -- Users with a second factor (TOTP): AAL2 is required -- `toSession()` returns 403 until AAL2 is completed - -#### Step 3: Update settings flow UI URL - -**File:** `apps/hash-external-services/kratos/kratos.dev.yml` - -Currently points to `/change-password`. Update to point to the new security settings page: - -```yaml -selfservice: - flows: - settings: - ui_url: http://localhost:3000/settings/security -``` - -The prod config uses an env var (`SELFSERVICE_FLOWS_SETTINGS_UI_URL`) which should also be updated in the deployment configuration to point to `/settings/security`. - ---- - -### Frontend: Security Settings Page - -#### Step 4: Create security settings page - -**File:** `apps/hash-frontend/src/pages/settings/security.page.tsx` (new file) - -A new page within the settings layout that handles password changes, TOTP setup/teardown, and backup codes. This page replaces the standalone `/change-password` page for most users. - -**Approach:** Create a single Kratos settings flow on mount. The flow's UI nodes contain all the sections (password, totp, lookup_secret). Render each group as a separate card/section. - -**Page structure:** - -``` -/settings/security -├── Change Password section -│ ├── New password input -│ └── Submit button (method: "password") -├── Two-Factor Authentication section -│ ├── IF not enabled: -│ │ ├── QR code image (from totp_qr node) -│ │ ├── Secret key text (from totp_secret_key node, for manual entry) -│ │ ├── Verification code input (totp_code node) -│ │ └── "Enable" submit button (method: "totp") -│ │ └── [On success: open Backup Codes modal] -│ └── IF enabled: -│ ├── Status: "TOTP is enabled" -│ ├── "Disable TOTP" button (requires entering current TOTP code or backup code) -│ └── "Regenerate backup codes" button (requires privileged session) -│ └── [Opens Backup Codes modal with new codes] -└── Backup Codes modal (dialog) - ├── Display generated codes in a grid/list - ├── "Copy codes" button - ├── Warning: "These codes will only be shown once. Save them securely." - └── "I've saved my codes" confirmation button (closes modal) -``` - -**Key implementation details:** - -- Use the settings layout: `SecurityPage.getLayout = getSettingsLayout` -- Create the settings flow on mount using the same pattern as `change-password.page.tsx`: - - ```typescript - oryKratosClient.createBrowserSettingsFlow() - ``` - -- Filter UI nodes by `group` to render each section: - - ```typescript - const totpNodes = flow.ui.nodes.filter((node) => node.group === "totp"); - const lookupNodes = flow.ui.nodes.filter((node) => node.group === "lookup_secret"); - const passwordNodes = flow.ui.nodes.filter((node) => node.group === "password"); - ``` - -- For the QR code: Find the node where `attributes.id === "totp_qr"` or `attributes.node_type === "img"`. The `attributes.src` contains a `data:image/png;base64,...` URI. Render it as an `` tag. -- For the secret key: Find the text node with `attributes.id === "totp_secret_key"`. Display it for manual entry into authenticator apps. -- For TOTP setup submission: - - ```typescript - oryKratosClient.updateSettingsFlow({ - flow: flow.id, - updateSettingsFlowBody: { - method: "totp", - totp_code: enteredCode, - csrf_token: mustGetCsrfTokenFromFlow(flow), - }, - }) - ``` - -- For TOTP unlink: The user must enter a current TOTP code or a backup code to confirm disabling. First verify the code (submit with `method: "totp"` and the entered code to validate it), then submit with `method: "totp"` and `totp_unlink: true` to remove the TOTP credential. This prevents accidental or unauthorized disabling of MFA. -- **Backup codes modal:** After TOTP is successfully enabled, automatically open a modal dialog showing the generated backup codes. The modal should: - - Display the codes from the `lookup_secret_codes` text node in the settings flow response - - Include a "Copy codes" button for convenience - - Show a warning: "These codes will only be shown once. Save them securely." - - Include an "I've saved my codes" button that submits `method: "lookup_secret"` with `lookup_secret_confirm: true` and closes the modal -- For regenerating backup codes: Submit with `method: "lookup_secret"` and `lookup_secret_regenerate: true`. This invalidates all previous backup codes. Display the new codes in the same modal pattern. Regeneration requires a privileged session (within the 15-minute window). -- After each submission, re-fetch the settings flow to update the UI -- **Important:** Kratos requires a privileged session (recently authenticated) to modify security settings. If the session is too old, Kratos returns a `session_refresh_required` error with a `redirect_browser_to` URL. The existing `useKratosErrorHandler` already handles this (line 52-60 in `use-kratos-flow-error-handler.ts`). - -**Style:** Use `SettingsPageContainer` from `apps/hash-frontend/src/pages/settings/shared/settings-page-container.tsx` for consistent styling with other settings pages. - -**Reference:** The existing `change-password.page.tsx` demonstrates the Kratos settings flow pattern (create flow, submit with method, error handling). Extend this pattern to support multiple methods. - -#### Step 5: Add "Security" to settings sidebar - -**File:** `apps/hash-frontend/src/pages/shared/settings-layout.tsx` - -Add a "Security" item to `generateMenuLinks()` at line 46. The commented-out "Personal info" line shows where account-level items should go: - -```typescript -const menuItems: SidebarItemData[] = [ - // { label: "Personal info", href: "/settings/personal" }, - { - label: "Security", - href: "/settings/security", - icon: /* ShieldIcon or LockIcon */, - }, - { - label: "Organizations", - href: "/settings/organizations", - icon: PeopleGroupIcon, - }, - // ...existing items -]; -``` - -Pick an appropriate icon from the existing icon set in `apps/hash-frontend/src/shared/icons/` (e.g., a shield or lock icon). Check what's available before creating a new one. - -#### Step 6: Keep /change-password for recovery, duplicate password UI in /settings/security - -**File:** `apps/hash-frontend/src/pages/change-password.page.tsx` - -Keep `/change-password` as-is for unauthenticated password recovery flows. Kratos redirects recovered users to the settings `ui_url` to set a new password, and the user may not be fully authenticated when they arrive. The `/change-password` page uses `getPlainLayout` (no settings sidebar), which is appropriate for this case. - -The password change UI should also be included in the new `/settings/security` page so that authenticated users can change their password from settings. Password changes work in both contexts because they use the same Kratos settings flow. - ---- - -### Frontend: Login Flow for AAL2 - -#### Step 7: Update signin page to handle AAL2 flows - -**File:** `apps/hash-frontend/src/pages/signin.page.tsx` - -The signin page already passes `aal` to `createBrowserLoginFlow()` (line 135), but the UI only renders email+password inputs. When `aal=aal2`, Kratos returns a login flow with `totp` and `lookup_secret` UI node groups instead of `password`. - -**Changes:** - -a) **Detect AAL2 flow:** Check the flow's `requested_aal` field or check for the presence of `totp` group nodes: - -```typescript -const isAal2Flow = flow?.requested_aal === "aal2" - || flow?.ui.nodes.some((node) => node.group === "totp"); -``` - -b) **Render TOTP input when AAL2:** - -```typescript -{isAal2Flow ? ( - setShowLookupSecretInput(true)} - /> -) : ( - // existing email+password form -)} -``` - -c) **TOTP login submission:** - -```typescript -const handleTotpSubmit = (totpCode: string) => { - oryKratosClient.updateLoginFlow({ - flow: flow.id, - updateLoginFlowBody: { - method: "totp", - totp_code: totpCode, - csrf_token: mustGetCsrfTokenFromFlow(flow), - }, - }) - .then(async () => { - const { authenticatedUser } = await refetch(); - // ...redirect to home - }) - .catch(handleFlowError); -}; -``` - -d) **Backup code fallback:** Show a "Use a backup code" link that switches the input to accept a lookup secret instead: - -```typescript -oryKratosClient.updateLoginFlow({ - flow: flow.id, - updateLoginFlowBody: { - method: "lookup_secret", - lookup_secret: enteredBackupCode, - csrf_token: mustGetCsrfTokenFromFlow(flow), - }, -}) -``` - -e) **UI for AAL2 step:** - -- Heading: "Enter your authentication code" -- Text: "Open your authenticator app and enter the code" -- 6-digit code input field -- Submit button -- "Use a backup code instead" link (toggles to a different input) -- Error messages from flow UI nodes -- Use the same `AuthPaper` / `AuthLayout` styling as the password step - -Use judgment based on complexity: if the AAL2 rendering logic is small, keep it inline in `signin.page.tsx`. If it grows beyond a handful of components (QR code input, backup code toggle, error handling), extract it into a separate `signin-totp-step.tsx` file. - -#### Step 8: Handle post-password-login redirect to AAL2 - -**File:** `apps/hash-frontend/src/pages/signin.page.tsx` - -Currently, after successful password login (line 183-195), the page calls `refetch()` and redirects home. But if the user has TOTP enabled and `required_aal: highest_available`, `refetch()` will fail (because `toSession()` in the auth context returns 403). - -**Fix:** After password login succeeds, check if AAL2 is needed before redirecting: - -```typescript -.then(async ({ data: loginResponse }) => { - // Check if AAL2 is required via continue_with actions - const aal2Action = loginResponse.continue_with?.find( - (action) => action.action === "show_verification_ui" - || (action.action === "redirect_browser_to" - && action.redirect_browser_to?.includes("aal=aal2")), - ); - - if (aal2Action?.redirect_browser_to) { - // Redirect to AAL2 login flow - void router.push(aal2Action.redirect_browser_to); - return; - } - - // No AAL2 needed, proceed normally - const { authenticatedUser } = await refetch(); - // ...redirect -}) -``` - -Alternatively, a simpler approach: after password login, try calling `toSession()`. If it returns 403, extract the `redirect_browser_to` URL and redirect: - -```typescript -.then(async () => { - try { - await oryKratosClient.toSession(); - // Session is valid, no AAL2 needed - const { authenticatedUser } = await refetch(); - void router.push(returnTo ?? "/"); - } catch (err) { - if (err.response?.status === 403) { - // AAL2 required -- Kratos response includes redirect URL - const redirectTo = err.response.data?.redirect_browser_to; - if (redirectTo) { - void router.push(redirectTo); - return; - } - } - throw err; - } -}) -``` - ---- - -### Frontend: Auth Context AAL2 Handling - -#### Step 9: Handle 403 from `toSession()` in `AuthInfoProvider` - -**File:** `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` - -After the email verification plan changes, the auth context calls `oryKratosClient.toSession()` to get verifiable addresses. If the user has TOTP and only AAL1, this call returns 403. - -Currently, the `.catch(() => undefined)` swallows the 403, meaning the user appears unauthenticated after password login (before AAL2). This causes confusing behavior. - -**Fix:** Distinguish between "no session" and "AAL2 required": - -```typescript -oryKratosClient - .toSession() - .then(({ data }) => ({ session: data, aal2Required: false })) - .catch((err: AxiosError) => { - if (err.response?.status === 403) { - return { session: undefined, aal2Required: true }; - } - return { session: undefined, aal2Required: false }; - }); -``` - -Expose `aal2Required` in the auth context value so components can react: - -```typescript -type AuthInfoContextValue = { - authenticatedUser?: User; - isInstanceAdmin: boolean | undefined; - aal2Required: boolean; // NEW - refetch: RefetchAuthInfoFunction; -}; -``` - -Pages can then check `aal2Required` and redirect to `/signin?aal=aal2` if needed. The `_app.page.tsx` or a layout component could handle this globally. - ---- - -### API: Auth Middleware - -#### Step 10: Resolve TODO in auth middleware for 403 handling - -**File:** `apps/hash-api/src/auth/create-auth-handlers.ts` - -The current behavior (catching 403 and returning `undefined` session) is actually correct for the API: - -- Users with AAL1 when AAL2 is required should be treated as unauthenticated for API access -- This prevents access to protected resources until AAL2 is completed -- The frontend handles the UX of redirecting to the AAL2 login flow - -**Change:** Replace the TODO comment with a clear explanation and log at debug level: - -```typescript -.catch((err: AxiosError) => { - if (err.response && err.response.status === 403) { - // User has a session but hasn't completed required AAL2 (2FA). - // Treat as unauthenticated -- the frontend handles redirecting - // to the AAL2 login flow. - logger.debug( - "Session requires AAL2 but only has AAL1. Treating as unauthenticated.", - ); - } - // ...existing debug logging for other errors - return undefined; -}); -``` - -This applies to both occurrences (line 121 and line 209) in the file. - ---- - -## Playwright Tests - -### Step 11: Add MFA test utilities - -**File:** `tests/hash-playwright/tests/shared/totp-utils.ts` (new file) - -Helper for generating TOTP codes in tests. Use the `otplib` or `otpauth` npm package to generate valid TOTP codes from the secret key displayed during setup: - -```typescript -import { authenticator } from "otplib"; - -/** - * Generates a TOTP code from a secret key. - * Used in tests to simulate authenticator app behavior. - */ -export const generateTotpCode = (secret: string): string => { - return authenticator.generate(secret); -}; -``` - -Add `otplib` (or equivalent) as a dev dependency of the Playwright test package. - -### Step 12: Add MFA test cases - -**File:** `tests/hash-playwright/tests/mfa.spec.ts` (new file) - -**a) User can enable TOTP in settings:** - -```typescript -test("user can enable TOTP", async ({ page }) => { - // Log in as existing user - // Navigate to /settings/security - // Extract TOTP secret from the page - // Generate a valid TOTP code using otplib - // Enter the code and submit - // Expect "TOTP enabled" or disable button to appear - // Expect backup codes to be shown -}); -``` - -**b) User with TOTP must enter code at login:** - -```typescript -test("user with TOTP is prompted for code at login", async ({ page }) => { - // Enable TOTP for user (reuse setup from test above or use API) - // Log out - // Log in with email+password - // Expect TOTP code input to appear - // Generate and enter valid TOTP code - // Expect successful login -}); -``` - -**c) User can log in with backup code:** - -```typescript -test("user can use backup code instead of TOTP", async ({ page }) => { - // Enable TOTP + save backup codes - // Log out and log in with password - // Click "Use a backup code" - // Enter one of the backup codes - // Expect successful login -}); -``` - -**d) User can disable TOTP:** - -```typescript -test("user can disable TOTP", async ({ page }) => { - // Enable TOTP for user - // Navigate to /settings/security - // Click "Disable TOTP" - // TOTP should be disabled - // Log out and log in -- should not be prompted for TOTP code -}); -``` - -**e) Wrong TOTP code shows error:** - -```typescript -test("wrong TOTP code shows error at login", async ({ page }) => { - // Enable TOTP, log out, log in with password - // Enter wrong TOTP code - // Expect error message -}); -``` - ---- - -## Files Changed Summary - -### Kratos Config (2 files) - -| File | Change | -|------|--------| -| `apps/hash-external-services/kratos/kratos.dev.yml` | Enable `totp` and `lookup_secret` methods; set `session.whoami.required_aal: highest_available`; update settings `ui_url` to `/settings/security` | -| `apps/hash-external-services/kratos/kratos.prod.yml` | Same method additions and AAL config | - -### Frontend (4-5 files) - -| File | Change | -|------|--------| -| `apps/hash-frontend/src/pages/settings/security.page.tsx` | **New file** -- security settings page with TOTP setup/disable, backup codes, password change | -| `apps/hash-frontend/src/pages/shared/settings-layout.tsx` | Add "Security" item to settings sidebar | -| `apps/hash-frontend/src/pages/signin.page.tsx` | Handle AAL2 flows: show TOTP input + backup code option when `aal=aal2`; handle post-password redirect to AAL2 | -| `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` | Handle 403 from `toSession()`; expose `aal2Required` flag | -| `apps/hash-frontend/src/pages/signin.page/signin-totp-step.tsx` | **New file** (if needed) -- TOTP code input component for the login AAL2 step; extract only if inline logic in `signin.page.tsx` grows too complex | - -### API (1 file) - -| File | Change | -|------|--------| -| `apps/hash-api/src/auth/create-auth-handlers.ts` | Replace TODO with proper comment; keep 403-as-unauthenticated behavior | - -### Tests (2 files) - -| File | Change | -|------|--------| -| `tests/hash-playwright/tests/shared/totp-utils.ts` | **New file** -- TOTP code generation helper using `otplib` | -| `tests/hash-playwright/tests/mfa.spec.ts` | **New file** -- MFA test cases (enable, login, backup codes, disable) | - ---- - -## Key Reference Files - -| File | Why | -|------|-----| -| `apps/hash-frontend/src/pages/change-password.page.tsx` | Existing settings flow implementation -- shows the pattern for creating/submitting Kratos settings flows | -| `apps/hash-frontend/src/pages/signin.page.tsx` | Current login flow -- already has `aal` parameter support, needs AAL2 UI | -| `apps/hash-frontend/src/pages/shared/use-kratos-flow-error-handler.ts` | Already handles `session_aal2_required` and `session_refresh_required` errors | -| `apps/hash-frontend/src/pages/shared/ory-kratos.ts` | Kratos client + helpers (`mustGetCsrfTokenFromFlow`, `gatherUiNodeValuesFromFlow`) | -| `apps/hash-frontend/src/pages/shared/settings-layout.tsx` | Settings sidebar configuration -- add "Security" link here | -| `apps/hash-frontend/src/pages/settings/shared/settings-page-container.tsx` | Container component for settings pages -- use for consistent styling | -| `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` | Auth context -- needs `aal2Required` flag | -| `apps/hash-api/src/auth/create-auth-handlers.ts` | Auth middleware with TODO about 403/2FA handling | -| `apps/hash-external-services/kratos/kratos.dev.yml` | Kratos dev config to update | -| `apps/hash-external-services/kratos/identity.schema.json` | Identity schema -- no changes needed (TOTP credentials are stored separately by Kratos, not in traits) | - ---- - -## Notes - -- **TOTP credentials are not stored in the identity schema.** Kratos manages them internally as credentials alongside the password credential. No changes to `identity.schema.json` are needed. -- **Privileged sessions:** Kratos requires a recently authenticated session to change security settings (TOTP, password). The `privileged_session_max_age` is kept at its current 15-minute default. If the session is too old, Kratos returns `session_refresh_required` with a redirect to re-authenticate. The existing error handler already handles this. -- **Backup codes are single-use.** Once a code is used for login, it's consumed. The user should be warned to regenerate codes when they're running low. -- **The `@ory/client` package** (already a dependency) provides all the types needed: `SettingsFlow`, `LoginFlow`, `UiNode`, etc. -- **Kratos v1.2.0** fully supports TOTP and lookup_secret methods. No version upgrade needed. -- **The `/change-password` page is kept** for account recovery flows (Kratos redirects recovered users to the settings `ui_url` to set a new password). Password change UI is duplicated in `/settings/security` for authenticated users. The Kratos settings `ui_url` is updated to `/settings/security`, but `/change-password` remains as a standalone page with `getPlainLayout` for recovery flows where the user may not be fully authenticated. -- **QR code rendering:** The TOTP QR code is provided by Kratos as a `data:image/png;base64,...` URI in the `totp_qr` UI node. It can be rendered directly as an ``. No QR code generation library is needed on the frontend. -- **TOTP issuer name:** The `issuer: HASH` config in Kratos determines what name appears in the authenticator app (e.g., "HASH: user@example.com"). Choose a clear, recognizable name. -- **Testing TOTP:** The `otplib` package can generate valid TOTP codes from a secret. In Playwright tests, extract the secret from the settings page and use `otplib` to generate the current code. This avoids needing to simulate QR code scanning. diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index b2b4221af72..00000000000 --- a/PLAN.md +++ /dev/null @@ -1,696 +0,0 @@ -# Email Verification Step in Signup Flow - -Add an email verification step to the signup flow between registration (email+password) and account setup (username). After registering, users see a "verify your email" screen with a code input and a resend button, and can only proceed once their email is verified. The API also enforces this server-side to prevent bypassing the frontend. - -## Table of Contents - -- [Current State](#current-state) -- [Target State](#target-state) -- [Architecture Context](#architecture-context) -- [Implementation Steps](#implementation-steps) - - [Frontend: Auth Context and User Type](#frontend-auth-context-and-user-type) - - [Frontend: Signup Page Changes](#frontend-signup-page-changes) - - [API: Server-Side Enforcement](#api-server-side-enforcement) -- [Playwright Tests](#playwright-tests) -- [Files Changed Summary](#files-changed-summary) -- [Key Reference Files](#key-reference-files) -- [Notes](#notes) - ---- - -## Current State - -The signup flow is: **Register (email+password) -> Account Setup (username/display name) -> Done**. - -Email verification was partially scaffolded but left disabled: - -- `apps/hash-frontend/src/pages/signup.page.tsx` line 150: `userHasVerifiedEmail` is hardcoded to `true` -- `apps/hash-frontend/src/pages/signup.page/signup-steps.tsx` lines 20-25: `"verify-email"` step is commented out -- `apps/hash-frontend/src/pages/signup.page.tsx` line 188: placeholder `null` where the verification form should render -- `apps/hash-frontend/src/lib/user-and-org.ts` line 555: `verified` is hardcoded to `false` in `constructUser`, with a TODO at line 375 -- `apps/hash-frontend/src/pages/verification.page.tsx`: standalone verification page exists but is disconnected from signup -- `apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts`: no email verification check before allowing signup completion - -Kratos is already configured for code-based verification (`selfservice.flows.verification.use: code`, `enabled: true`, 48h lifespan) and has email templates ready at `apps/hash-external-services/kratos/templates/verification_code/`. - -## Target State - -**Register (email+password) -> Verify Email (enter code) -> Account Setup (username) -> Done** - -```mermaid -flowchart TD - A[User visits /signup] --> B[SignupRegistrationForm] - B -->|"Email + password submitted"| C[Kratos creates identity + session] - C --> D[webhook creates HASH user] - D --> E{Email verified?} - E -->|No| F[VerifyEmailStep] - F -->|"Create verification flow + submit email"| G[Kratos sends code via email] - G --> H[User enters code] - H -->|"Submit code to Kratos"| I{Code valid?} - I -->|Yes| J[Email marked verified] - I -->|No| H - J --> E - E -->|Yes| K[AccountSetupForm] - K --> L[Done - redirect to /] - F -->|"Resend button"| G -``` - ---- - -## Architecture Context - -### How auth works - -- `apps/hash-frontend` is the Next.js client. It communicates with Ory Kratos via `oryKratosClient` (defined in `apps/hash-frontend/src/pages/shared/ory-kratos.ts`), which points to `{apiOrigin}/auth`. -- `apps/hash-api` proxies all `/auth/*` requests to Kratos's public API (see `apps/hash-api/src/index.ts` lines 179-223 for the proxy setup, mounted at line 436). -- Kratos is configured in `apps/hash-external-services/kratos/`. The dev config is `kratos.dev.yml`, prod is `kratos.prod.yml`. Both use the same identity schema at `identity.schema.json`. -- After registration, Kratos fires a webhook to `POST /kratos-after-registration` on the API, which creates the HASH user entity. Then a `session` hook creates a Kratos session (the user is logged in). - -### How the Kratos verification flow works - -The Ory Kratos verification flow is a two-step process using the code method: - -1. **Create a verification flow:** - - ```typescript - const { data: flow } = await oryKratosClient.createBrowserVerificationFlow(); - ``` - -2. **Submit the email to trigger sending the code:** - - ```typescript - await oryKratosClient.updateVerificationFlow({ - flow: flow.id, - updateVerificationFlowBody: { - method: "code", - email: userEmail, - csrf_token: mustGetCsrfTokenFromFlow(flow), - }, - }); - ``` - - Kratos sends a 6-digit code to the email address. The email uses the template at `apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl`. - -3. **Submit the code to verify:** - - ```typescript - await oryKratosClient.updateVerificationFlow({ - flow: flow.id, - updateVerificationFlowBody: { - method: "code", - code: userEnteredCode, - csrf_token: mustGetCsrfTokenFromFlow(flow), - }, - }); - ``` - - On success, Kratos marks the address as verified in `identity.verifiable_addresses`. - -4. **Checking verification status:** The Kratos session (retrieved via `oryKratosClient.toSession()`) includes `identity.verifiable_addresses`, each of which has a `verified: boolean` field. - -The existing standalone `apps/hash-frontend/src/pages/verification.page.tsx` already demonstrates this flow -- use it as a reference. - -### How signup completion works - -- `isAccountSignupComplete` (API-side, `apps/hash-api/src/graph/knowledge/system-types/user.ts` line 162) is defined as `!!shortname && !!displayName`. -- The `userBeforeEntityUpdateHookCallback` (in `apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts` line 146) is the server-side gate. When an incomplete user sets shortname+displayName, it checks `userHasAccessToHash` and then grants web ownership. **This is where the email verification check must be added.** -- Many parts of the API and frontend gate functionality behind `isAccountSignupComplete` / `accountSignupComplete`. - -### Email infrastructure (relevant to tests) - -There are two separate email systems: - -- **API emails** use `DummyEmailTransporter` in dev/test, which writes to `var/api/dummy-email-transporter/email-dumps.yml`. The existing Playwright helper `getDerivedPayloadFromMostRecentEmail` reads from this file. -- **Kratos emails** (including verification codes) are sent via SMTP to **mailslurper** (Docker service). Mailslurper exposes: port 1025 (SMTP), port 4436 (web UI), port 4437 (REST API). See `apps/hash-external-services/docker-compose.dev.yml` lines 98-102. - -These are completely separate. The new verification codes are sent by Kratos, so tests need to read from mailslurper, not the YAML file. - ---- - -## Implementation Steps - -### Frontend: Auth Context and User Type - -#### Step 1: Update `constructUser` to populate `verified` from Kratos - -**File:** `apps/hash-frontend/src/lib/user-and-org.ts` - -Currently `verified` is hardcoded to `false` at line 555, and there's a commented-out TODO at line 375 showing the intended approach. `constructUser` is called from 3 places: - -1. `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` -- authenticated user (has Kratos session available) -2. `apps/hash-frontend/src/pages/@/[shortname].page.tsx` -- other user's profile page (no Kratos session) -3. `apps/hash-frontend/src/components/hooks/use-users-with-links.ts` -- user list (no Kratos session) - -Only call site 1 has access to the Kratos session. - -**Changes:** - -a) Add an optional `verifiableAddresses` parameter to `constructUser`: - -```typescript -import type { VerifiableIdentityAddress } from "@ory/client"; - -export const constructUser = (params: { - orgMembershipLinks?: LinkEntity[]; - subgraph: Subgraph>; - resolvedOrgs?: Org[]; - userEntity: Entity; - verifiableAddresses?: VerifiableIdentityAddress[]; // NEW -}): User => { - // ...existing code... - - // Replace the commented-out TODO at line 375 with: - const isPrimaryEmailAddressVerified = - params.verifiableAddresses?.find( - ({ value }) => value === primaryEmailAddress, - )?.verified === true; - - // ...existing code... - - // At line 555, replace `verified: false` with: - emails: [ - { - address: primaryEmailAddress, - verified: isPrimaryEmailAddressVerified, - primary: true, - }, - ], -``` - -b) The `VerifiableIdentityAddress` type from `@ory/client` has this shape (for reference): - -```typescript -interface VerifiableIdentityAddress { - id: string; - status: string; - value: string; // the email address - verified: boolean; - via: string; // "email" - // ...timestamps etc -} -``` - -#### Step 2: Fetch Kratos session in `AuthInfoProvider` - -**File:** `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` - -The `AuthInfoProvider` fetches the authenticated user via the GraphQL `meQuery`. It needs to also fetch the Kratos session so it can pass `verifiableAddresses` to `constructUser`. - -**Changes:** - -- Import `oryKratosClient` from `../shared/ory-kratos` -- Add state for verifiable addresses: `const [verifiableAddresses, setVerifiableAddresses] = useState([])` -- In `fetchAuthenticatedUser`, fetch the Kratos session in parallel with the user subgraph: - -```typescript -const fetchAuthenticatedUser = useCallback(async () => { - const [subgraph, kratosSession] = await Promise.all([ - apolloClient.query({ - query: meQuery, - fetchPolicy: "network-only", - }) - .then(({ data }) => - mapGqlSubgraphFieldsFragmentToSubgraph>( - data.me.subgraph, - ), - ) - .catch(() => undefined), - - oryKratosClient - .toSession() - .then(({ data }) => data) - .catch(() => undefined), - ]); - - const newVerifiableAddresses = - kratosSession?.identity.verifiable_addresses ?? []; - setVerifiableAddresses(newVerifiableAddresses); - - if (!subgraph) { - setAuthenticatedUserSubgraph(undefined); - return {}; - } - - setAuthenticatedUserSubgraph(subgraph); - - return { authenticatedUser: constructUserValue(subgraph) }; -}, [constructUserValue, apolloClient]); -``` - -- Update the `constructUserValue` memoized callback to pass `verifiableAddresses`: - -```typescript -const constructUserValue = useCallback( - (subgraph: Subgraph> | undefined) => { - if (!subgraph) { - return undefined; - } - const userEntity = getRoots(subgraph)[0]!; - // ...existing entity type check... - return constructUser({ - orgMembershipLinks: userMemberOfLinks, - subgraph, - resolvedOrgs, - userEntity, - verifiableAddresses, // NEW - }); - }, - [resolvedOrgs, userMemberOfLinks, verifiableAddresses], -); -``` - -After this change, `authenticatedUser.emails[0].verified` will reflect the real Kratos verification status everywhere in the app. - ---- - -### Frontend: Signup Page Changes - -#### Step 3: Create the `VerifyEmailStep` component - -**File:** `apps/hash-frontend/src/pages/signup.page/verify-email-step.tsx` (new file) - -This component handles the verification flow inline within the signup page. - -**Props:** - -```typescript -interface VerifyEmailStepProps { - email: string; - onVerified: () => void; -} -``` - -**Behavior:** - -- **On mount:** Create a Kratos verification flow and immediately submit the user's email to trigger sending the code (see [Architecture Context](#how-the-kratos-verification-flow-works) above for the API calls). -- **UI elements:** - - Heading: "Verify your email address" - - Message: "We've sent a verification code to {email}" - - Code input field (text, 6 digits) - - Submit button - - "Resend verification email" button -- creates a new verification flow and resubmits the email - - Error/status messages from Kratos flow UI nodes -- **On code submit:** Call `oryKratosClient.updateVerificationFlow()` with the code -- **On success:** Call `onVerified` callback prop -- **Style:** Use `AuthPaper` and `AuthHeading` for consistency with the existing registration form (see `apps/hash-frontend/src/pages/signup.page/signup-registration-form.tsx`) - -**Reference implementation:** The existing standalone `apps/hash-frontend/src/pages/verification.page.tsx` demonstrates the Kratos verification flow mechanics. Reuse the same pattern but adapted for inline use. Key patterns to borrow: - -- `createBrowserVerificationFlow` / `getVerificationFlow` for initialization -- `updateVerificationFlow` for submission -- `isUiNodeInputAttributes` for extracting form nodes -- `useKratosErrorHandler` for error handling - -#### Step 4: Wire `VerifyEmailStep` into `signup.page.tsx` - -**File:** `apps/hash-frontend/src/pages/signup.page.tsx` - -a) **Un-comment the verification check** at line 147-150. Replace: - -```typescript -/** @todo: un-comment this to actually check whether the email is verified */ -// const userHasVerifiedEmail = -// authenticatedUser?.emails.find(({ verified }) => verified) !== undefined; -const userHasVerifiedEmail = true; -``` - -With: - -```typescript -const userHasVerifiedEmail = - authenticatedUser?.emails.find(({ verified }) => verified) !== undefined; -``` - -b) **Render `VerifyEmailStep`** when the user is authenticated but not verified. Replace the conditional at line 182-189: - -```typescript -// Current (line 182-189): -userHasVerifiedEmail ? ( - -) : /** @todo: add verification form */ -null - -// New: -userHasVerifiedEmail ? ( - -) : ( - refetchAuthenticatedUser()} - /> -) -``` - -The `onVerified` callback calls `refetchAuthenticatedUser()` from the auth context, which re-fetches both the user subgraph and the Kratos session, updating `authenticatedUser.emails[0].verified` and triggering the transition to `AccountSetupForm`. - -c) **Update step tracking.** Update the `currentStep` prop passed to `SignupSteps`: - -```typescript -currentStep={ - invitation && !authenticatedUser - ? "accept-invitation" - : !userHasVerifiedEmail - ? "verify-email" - : "reserve-username" -} -``` - -#### Step 5: Un-comment the "verify-email" step in `signup-steps.tsx` - -**File:** `apps/hash-frontend/src/pages/signup.page/signup-steps.tsx` - -- Un-comment lines 21-25 to add the `"verify-email"` step back to `stepsWithoutInvitation` -- Add `"verify-email"` to the `StepName` union type at line 12 -- The steps will now be 3 (verify email, reserve username, start using HASH). Check if a `Circle4RegularIcon` exists for the invitation flow variant (4 steps). If not, either create one or adjust the icon mapping. - -#### Edge case: user returns with unverified email - -If a user registers but doesn't verify, then later returns to `/signup`: - -- The auth middleware identifies them via session cookie -- `authenticatedUser` is defined, `userHasVerifiedEmail` is false -- The `VerifyEmailStep` is shown, which creates a new verification flow and sends a fresh code -- This is the correct behavior -- no special handling needed - ---- - -### API: Server-Side Enforcement - -#### Step 6: Add `isUserEmailVerified` helper - -**File:** `apps/hash-api/src/auth/ory-kratos.ts` - -Add a helper function that checks Kratos for email verification status: - -```typescript -export const isUserEmailVerified = async ( - kratosIdentityId: string, -): Promise => { - const { data: identity } = await kratosIdentityApi.getIdentity({ - id: kratosIdentityId, - }); - return ( - identity.verifiable_addresses?.some((addr) => addr.verified) ?? false - ); -}; -``` - -This uses the existing `kratosIdentityApi` (admin API, already imported in the file) which has access to identity details including `verifiable_addresses`. - -#### Step 7: Add email verification gate in signup completion - -**File:** `apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts` - -This is the critical server-side enforcement. Currently at line 146, when an incomplete user sets shortname+displayName (completing signup), the hook only checks `userHasAccessToHash`. Add an email verification check: - -```typescript -import { isUserEmailVerified } from "../../../../../auth/ory-kratos"; - -// ... existing code ... - -const isIncompleteUser = !user.isAccountSignupComplete; - -if (isIncompleteUser && updatedShortname && updatedDisplayName) { - if (!(await userHasAccessToHash(context, authentication, user))) { - throw Error.forbidden( - "The user does not have access to the HASH instance, and therefore cannot complete account signup.", - ); - } - - // NEW: Check email is verified before allowing signup completion - if (!(await isUserEmailVerified(user.kratosIdentityId))) { - throw Error.forbidden( - "You must verify your email address before completing account setup.", - ); - } - - // Now that the user has completed signup, we can transfer the ownership of the web - await addActorGroupAdministrator( - context.graphApi, - { actorId: systemAccountId }, - { actorId: user.accountId, actorGroupId: user.accountId }, - ); -} -``` - -This prevents a user from completing signup via a direct GraphQL mutation without first verifying their email. - ---- - -## Playwright Tests - -### Step 8: Add `getKratosVerificationCode` helper - -**File:** `tests/hash-playwright/tests/shared/get-kratos-verification-code.ts` (new file) - -Kratos sends verification emails via SMTP to mailslurper (not the API's `DummyEmailTransporter`). Mailslurper exposes a REST API on port 4437 (see `apps/hash-external-services/docker-compose.dev.yml` lines 98-102). We need a helper to query this API. - -```typescript -/** - * Reads the most recent verification code from Kratos-sent emails - * via the mailslurper API. - * - * Mailslurper exposes its API on port 4437 in the dev docker-compose setup. - * Kratos verification emails use the template at: - * apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl - * which contains: "entering the following code:\n{{ .VerificationCode }}" - * - * Kratos replaces {{ .VerificationCode }} with a 6-digit numeric code. - */ -export const getKratosVerificationCode = async ( - emailAddress: string, - afterTimestamp?: number, -): Promise => { - const maxWaitMs = 10_000; - const pollIntervalMs = 250; - let elapsed = 0; - - while (elapsed < maxWaitMs) { - const response = await fetch("http://localhost:4437/mail"); - const data = await response.json(); - - // mailslurper response shape: { mailItems: [...], totalRecords, totalPages } - const matchingEmail = data.mailItems?.find( - (item: { toAddresses: string[]; subject: string; dateSent: string }) => - item.toAddresses?.includes(emailAddress) && - item.subject === "Please verify your email address" && - (!afterTimestamp || - new Date(item.dateSent).getTime() >= afterTimestamp), - ); - - if (matchingEmail) { - // Extract 6-digit code from email body - const codeMatch = matchingEmail.body.match( - /following code:.*?(\d{6})/s, - ); - if (codeMatch?.[1]) { - return codeMatch[1]; - } - } - - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - elapsed += pollIntervalMs; - } - - throw new Error( - `No verification email found for ${emailAddress} within ${maxWaitMs}ms`, - ); -}; -``` - -**Important:** Verify the exact mailslurper API response shape and the regex against the actual email template. The subject line comes from `apps/hash-external-services/kratos/templates/verification_code/valid/email.subject.gotmpl` (currently "Please verify your email address"). The code pattern comes from `email.body.gotmpl`. - -### Step 9: Update `signup.spec.ts` - -**File:** `tests/hash-playwright/tests/signup.spec.ts` - -The existing "user can sign up" test will **break** because it expects to see "Thanks for confirming your account" immediately after registration. After the changes, the verification step will appear first. - -**Updated test:** - -```typescript -import { getKratosVerificationCode } from "./shared/get-kratos-verification-code"; - -test("user can sign up", async ({ page }) => { - await page.goto("/signup"); - - const randomNumber = Math.floor(Math.random() * 10_000) - .toString() - .padEnd(4, "0"); - const email = `${randomNumber}@example.com`; - - // Step 1: Register - await page.fill('[placeholder="Enter your email address"]', email); - await page.fill('[type="password"]', "some-complex-pw-1ab2"); - - const emailDispatchTimestamp = Date.now(); - await page.click("text=Sign up"); - - // Step 2: Verify email - await expect( - page.locator("text=Verify your email address"), - ).toBeVisible(); - - const verificationCode = await getKratosVerificationCode( - email, - emailDispatchTimestamp, - ); - - await page.fill( - '[placeholder="Enter your verification code"]', - verificationCode, - ); - await page.click("text=Verify"); - - // Step 3: Account setup - await expect( - page.locator("text=Thanks for confirming your account"), - ).toBeVisible(); - - await page.fill('[placeholder="example"]', randomNumber.toString()); - await page.fill('[placeholder="Jonathan Smith"]', "New User"); - await page.click("text=Continue"); - - await page.waitForURL("/"); - await expect(page.locator("text=Get support")).toBeVisible(); -}); -``` - -### Step 10: Add new test cases - -**File:** `tests/hash-playwright/tests/signup.spec.ts` (add to existing file) - -**a) Wrong verification code shows error:** - -```typescript -test("wrong verification code shows error", async ({ page }) => { - // Register a user (same setup as the main test)... - - // On verification step, enter wrong code - await page.fill('[placeholder="Enter your verification code"]', "000000"); - await page.click("text=Verify"); - - // Expect error message from Kratos - await expect( - page.locator("text=/code is invalid|code is expired/i"), - ).toBeVisible(); - - // Should still be on verification step - await expect( - page.locator("text=Verify your email address"), - ).toBeVisible(); -}); -``` - -**b) Resend verification email:** - -```typescript -test("can resend verification email", async ({ page }) => { - // Register a user... - // On verification step, click resend - const resendTimestamp = Date.now(); - await page.click("text=Resend verification email"); - - // Get new code from the resent email - const newCode = await getKratosVerificationCode(email, resendTimestamp); - - // Enter new code and verify - await page.fill('[placeholder="Enter your verification code"]', newCode); - await page.click("text=Verify"); - - // Should proceed to account setup - await expect( - page.locator("text=Thanks for confirming your account"), - ).toBeVisible(); -}); -``` - -**c) Returning user with unverified email sees verification step:** - -```typescript -test("returning user with unverified email sees verification step", async ({ - page, -}) => { - // Register a user... - // Verification step appears - await expect( - page.locator("text=Verify your email address"), - ).toBeVisible(); - - // Navigate away - await page.goto("/"); - // Return to signup -- should still see verification - await page.goto("/signup"); - - await expect( - page.locator("text=Verify your email address"), - ).toBeVisible(); -}); -``` - -**Note:** Consider extracting the registration setup into a helper function to avoid duplication across tests. - ---- - -## Files Changed Summary - -### Frontend (5 files) - -| File | Change | -|------|--------| -| `apps/hash-frontend/src/lib/user-and-org.ts` | Add optional `verifiableAddresses` param to `constructUser`; populate `verified` from it instead of hardcoding `false` | -| `apps/hash-frontend/src/pages/shared/auth-info-context.tsx` | Fetch Kratos session in parallel with user subgraph; pass `verifiableAddresses` to `constructUser` | -| `apps/hash-frontend/src/pages/signup.page.tsx` | Un-comment verification check; render `VerifyEmailStep` when unverified; update step tracking | -| `apps/hash-frontend/src/pages/signup.page/verify-email-step.tsx` | **New file** -- inline verification code form component | -| `apps/hash-frontend/src/pages/signup.page/signup-steps.tsx` | Un-comment `"verify-email"` step; add to `StepName` type | - -### API (2 files) - -| File | Change | -|------|--------| -| `apps/hash-api/src/auth/ory-kratos.ts` | Add `isUserEmailVerified` helper function | -| `apps/hash-api/src/graph/knowledge/primitive/entity/before-update-entity-hooks/user-before-update-entity-hook-callback.ts` | Add email verification check before allowing signup completion | - -### Tests (2 files) - -| File | Change | -|------|--------| -| `tests/hash-playwright/tests/shared/get-kratos-verification-code.ts` | **New file** -- helper to read Kratos verification codes from mailslurper | -| `tests/hash-playwright/tests/signup.spec.ts` | Update existing test; add 3 new test cases | - -### No Kratos config changes needed - -- Kratos already has verification enabled with the `code` method, 48h lifespan, email templates, and `ui_url: http://localhost:3000/verification` -- The API already proxies all `/auth/*` requests to Kratos, which includes verification flow endpoints -- The frontend's `oryKratosClient` can create and update verification flows through the existing proxy - ---- - -## Key Reference Files - -These files are useful context for the implementer: - -| File | Why | -|------|-----| -| `apps/hash-frontend/src/pages/verification.page.tsx` | Existing standalone verification page -- demonstrates the Kratos verification flow mechanics (create flow, submit email, submit code, error handling) | -| `apps/hash-frontend/src/pages/signup.page/signup-registration-form.tsx` | The registration form -- shows the style/pattern for auth forms (AuthPaper, AuthHeading, TextField, error handling) | -| `apps/hash-frontend/src/pages/shared/ory-kratos.ts` | Kratos client setup and helpers (`oryKratosClient`, `mustGetCsrfTokenFromFlow`, `gatherUiNodeValuesFromFlow`) | -| `apps/hash-frontend/src/pages/shared/use-kratos-flow-error-handler.ts` | Error handler for Kratos flows -- reuse in VerifyEmailStep | -| `apps/hash-external-services/kratos/kratos.dev.yml` | Kratos dev config -- shows verification flow settings (lines 75-80) | -| `apps/hash-external-services/kratos/identity.schema.json` | Identity schema -- shows email verification config (`"verification": { "via": "email" }`) | -| `apps/hash-external-services/kratos/templates/verification_code/` | Email templates for verification codes (valid and invalid) | -| `apps/hash-api/src/auth/ory-kratos.ts` | Kratos API client setup (public + admin APIs) | -| `apps/hash-api/src/auth/create-auth-handlers.ts` | Auth middleware and after-registration webhook handler | -| `apps/hash-api/src/graph/knowledge/system-types/user.ts` | API User type (`emails: string[]`, `kratosIdentityId`, `isAccountSignupComplete`) | -| `apps/hash-external-services/docker-compose.dev.yml` | Mailslurper port mappings (lines 98-102) | -| `tests/hash-playwright/tests/shared/get-derived-payload-from-most-recent-email.ts` | Existing email helper for API emails (pattern to follow for the mailslurper helper) | - ---- - -## Notes - -- The `@ory/client` package (already a dependency) provides the `VerifiableIdentityAddress` type and the `FrontendApi` methods used throughout. -- The Kratos admin API (`kratosIdentityApi`) is used server-side only. The frontend uses the public API (`oryKratosClient`) which is proxied through the Node API. -- The `loginUsingTempForm` Playwright helper (used by many tests) uses email+password login and is unaffected by these changes. The `loginUsingUi` helper uses a code-based login flow via the API's DummyEmailTransporter and is also unaffected. -- The Playwright tests run with `workers: 1` because concurrent tests break login. This remains true after these changes. -- Verification codes have a 48-hour lifespan (configured in Kratos). The "Resend" button on the frontend creates an entirely new verification flow, invalidating any previous code. diff --git a/apps/hash-api/src/graphql/resolvers/index.ts b/apps/hash-api/src/graphql/resolvers/index.ts index b99f00ef5af..e4bf015676c 100644 --- a/apps/hash-api/src/graphql/resolvers/index.ts +++ b/apps/hash-api/src/graphql/resolvers/index.ts @@ -123,6 +123,7 @@ export const resolvers: Omit & { isShortnameTaken: isShortnameTakenResolver, embedCode, hashInstanceSettings: hashInstanceSettingsResolver, + hasAccessToHash: hasAccessToHashResolver, /** Any user – type fetching */ queryDataTypes: queryDataTypesResolver, @@ -137,7 +138,6 @@ export const resolvers: Omit & { /** Logged in users (who may not have completed signup) */ me: loggedInMiddleware(meResolver), getWaitlistPosition: loggedInMiddleware(getWaitlistPositionResolver), - hasAccessToHash: loggedInMiddleware(hasAccessToHashResolver), /** Logged in and signed up users */ getBlockProtocolBlocks: loggedInAndSignedUpMiddleware( @@ -187,6 +187,8 @@ export const resolvers: Omit & { Mutation: { /** Logged in users (who may not have completed signup) */ submitEarlyAccessForm: loggedInMiddleware(submitEarlyAccessFormResolver), + /** The resolver itself gates updates to only the user entity if they haven't completed signup */ + updateEntity: loggedInMiddleware(updateEntityResolver), /** Logged in and signed up users */ updateBlockCollectionContents: loggedInAndSignedUpMiddleware( @@ -222,7 +224,6 @@ export const resolvers: Omit & { // Knowledge createEntity: loggedInAndSignedUpMiddleware(createEntityResolver), - updateEntity: loggedInAndSignedUpMiddleware(updateEntityResolver), updateEntities: loggedInAndSignedUpMiddleware(updateEntitiesResolver), archiveEntity: loggedInAndSignedUpMiddleware(archiveEntityResolver), archiveEntities: loggedInAndSignedUpMiddleware(archiveEntitiesResolver), diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/user/has-access-to-hash.ts b/apps/hash-api/src/graphql/resolvers/knowledge/user/has-access-to-hash.ts index 496c1500541..d985b39e453 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/user/has-access-to-hash.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/user/has-access-to-hash.ts @@ -1,17 +1,17 @@ import { userHasAccessToHash } from "../../../../shared/user-has-access-to-hash"; import type { Query, ResolverFn } from "../../../api-types.gen"; -import type { LoggedInGraphQLContext } from "../../../context"; +import type { GraphQLContext } from "../../../context"; import { graphQLContextToImpureGraphContext } from "../../util"; export const hasAccessToHashResolver: ResolverFn< Query["hasAccessToHash"], Record, - LoggedInGraphQLContext, + GraphQLContext, Record > = async (_, __, context) => { return userHasAccessToHash( graphQLContextToImpureGraphContext(context), context.authentication, - context.user, + context.user ?? null, ); }; diff --git a/apps/hash-api/src/shared/user-has-access-to-hash.ts b/apps/hash-api/src/shared/user-has-access-to-hash.ts index 3140041be88..627c812895f 100644 --- a/apps/hash-api/src/shared/user-has-access-to-hash.ts +++ b/apps/hash-api/src/shared/user-has-access-to-hash.ts @@ -46,8 +46,12 @@ if (process.env.USER_EMAIL_ALLOW_LIST) { export const userHasAccessToHash = async ( context: ImpureGraphContext, authentication: AuthenticationContext, - user: User, + user: User | null, ) => { + if (!user) { + return false; + } + if (!userEmailAllowList) { return true; } diff --git a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl index c31acea9dae..4cbe425c68f 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/invalid/email.body.gotmpl @@ -3,24 +3,25 @@ + - - + +
- +
- diff --git a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl index a2c40c53328..9d3a6a065a3 100644 --- a/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/recovery_code/valid/email.body.gotmpl @@ -3,29 +3,30 @@ + - -
-

+

+

Recovery attempted

-

+

Someone attempted to recover access to a HASH account using this email address, but it is not currently associated with any registered user.

-

+

If this was you and you're unable to access your account, check whether you signed up with a different email address.

-

+

If you didn't request this, you can safely ignore this email.

+ +
- +
- -
-

+

+

Recover your account

-

+

Enter the code below in HASH to recover access to your account.

+ - @@ -34,12 +35,12 @@ - diff --git a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl index 0b84aee9440..99c0adc9047 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/invalid/email.body.gotmpl @@ -3,24 +3,25 @@ + - -
+ {{ .RecoveryCode }}
-

+

+

Enter this code in HASH directly. Do not share this code with anyone.

-

- If you didn't request this, please contact support@hash.ai. +

+ If you didn't request this, please contact support@hash.ai.

+ +
- +
- diff --git a/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl b/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl index 2b064ec257b..e1340bb0aa2 100644 --- a/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl +++ b/apps/hash-external-services/kratos/templates/verification_code/valid/email.body.gotmpl @@ -6,29 +6,30 @@ + - -
-

+

+

Verification attempted

-

+

Someone attempted to verify this email address for a HASH account, but it is not currently associated with any registered user.

-

+

If this was you and you're unable to access your account, check whether you signed up with a different email address.

-

+

If you didn't request this, you can safely ignore this email.

+ +
- +
- -
-

+

+

Verify your email address

-

+

Enter the code below in HASH to verify your email address.

+ - @@ -37,14 +38,14 @@ -
+ {{ .VerificationCode }}
-

+

+

Or click the button below to verify automatically:

@@ -54,8 +55,8 @@ - diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index 6469a3657cd..3595380c1df 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -64,9 +64,8 @@ const clientSideEmotionCache = createEmotionCache(); type AppInitialProps = { initialAuthenticatedUserSubgraph?: Subgraph>; - /** Set when getInitialProps determines a client-side redirect is needed. */ - redirectTo?: string; user?: MinimalUser; + redirectTo?: string; }; type AppProps = { @@ -77,11 +76,33 @@ type AppProps = { const unverifiedUserPermittedPagePathnames = ["/verification", "/signup"]; -const App: FunctionComponent = ({ +const globalStyles = ( + +); + +const App: FunctionComponent = ({ Component, pageProps, - redirectTo, emotionCache = clientSideEmotionCache, + redirectTo, }) => { // Helps prevent tree mismatch between server and client on initial render const [ssr, setSsr] = useState(true); @@ -99,40 +120,21 @@ const App: FunctionComponent = ({ const { aal2Required, authenticatedUser, emailVerificationStatusKnown } = useAuthInfo(); - const primaryEmailVerified = - authenticatedUser?.emails.find(({ primary }) => primary)?.verified ?? false; - - const userMustVerifyEmail = - !!authenticatedUser && - emailVerificationStatusKnown && - !primaryEmailVerified; - const awaitingEmailVerificationStatus = !!authenticatedUser && !emailVerificationStatusKnown && !aal2Required; /** - * Handle client-side redirects determined by getInitialProps. These are - * deferred to useEffect so they don't conflict with the in-progress route - * transition (which would stall the NProgress bar). + * Handle client-side redirects that were determined in getInitialProps. + * On the server these are HTTP 307s; on the client getInitialProps returns + * a `redirectTo` prop instead, and this effect performs the navigation after + * the current route transition completes (avoiding NProgress stalls). */ useEffect(() => { - if (redirectTo && router.isReady) { + if (redirectTo) { void router.replace(redirectTo); } }, [redirectTo, router]); - useEffect(() => { - if ( - !router.isReady || - !userMustVerifyEmail || - unverifiedUserPermittedPagePathnames.includes(router.pathname) - ) { - return; - } - - void router.replace("/verification"); - }, [router, userMustVerifyEmail]); - useEffect(() => { setSentryUser({ authenticatedUser }); }, [authenticatedUser]); @@ -141,48 +143,13 @@ const App: FunctionComponent = ({ // router.query is empty during server-side rendering for pages that don’t use // getServerSideProps. By showing app skeleton on the server, we avoid UI // mismatches during rehydration and improve type-safety of param extraction. - if (ssr || !router.isReady || awaitingEmailVerificationStatus) { + // We also gate on `redirectTo` so the page doesn't flash before navigating. + if (ssr || !router.isReady || awaitingEmailVerificationStatus || redirectTo) { return ; // Replace with app skeleton } const getLayout = Component.getLayout ?? getPlainLayout; - if ( - userMustVerifyEmail && - !unverifiedUserPermittedPagePathnames.includes(router.pathname) - ) { - return ; - } - - if (userMustVerifyEmail) { - return ( - - - - - - {getLayout()} - - - - - - ); - } - return ( @@ -228,25 +195,7 @@ const App: FunctionComponent = ({ - + {globalStyles} ); }; @@ -254,7 +203,7 @@ const App: FunctionComponent = ({ const AppWithTypeSystemContextProvider: AppPage = ( props, ) => { - const { initialAuthenticatedUserSubgraph, redirectTo, user } = props; + const { initialAuthenticatedUserSubgraph, user } = props; return ( @@ -262,7 +211,7 @@ const AppWithTypeSystemContextProvider: AppPage = ( initialAuthenticatedUserSubgraph={initialAuthenticatedUserSubgraph} key={user?.accountId} > - + ); @@ -351,31 +300,24 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { ? getRoots(initialAuthenticatedUserSubgraph)[0] : undefined; - /** - * Helper: on server-side, performs an HTTP 307 redirect. On client-side, - * returns a redirectTo field so the component can handle it via useEffect - * (avoids calling router.push during an active transition, which stalls NProgress). - */ - const redirect = (location: string): AppInitialProps => { - redirectInGetInitialProps({ appContext, location }); - return { redirectTo: req ? undefined : location }; - }; - /** @todo: make additional pages publicly accessible */ if (!userEntity) { + let redirectTo: string | undefined; + // If the user is logged out and not on a page that should be publicly accessible... if (!publiclyAccessiblePagePathnames.includes(pathname)) { // ...redirect them to the sign in page - return redirect( - `/signin${ + redirectTo = redirectInGetInitialProps({ + appContext, + location: `/signin${ ["", "/", "/404"].includes(pathname) ? "" : `?return_to=${req?.url ?? asPath}` }`, - ); + }); } - return {}; + return { redirectTo }; } const user = constructMinimalUser({ userEntity }); @@ -383,25 +325,28 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { const primaryEmailVerified = await getPrimaryEmailVerificationStatus(cookie); if (primaryEmailVerified === false) { + let redirectTo: string | undefined; + if (!unverifiedUserPermittedPagePathnames.includes(pathname)) { - return { - initialAuthenticatedUserSubgraph, - user, - ...redirect("/verification"), - }; + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/verification", + }); } - return { initialAuthenticatedUserSubgraph, user }; + return { initialAuthenticatedUserSubgraph, user, redirectTo }; } if (primaryEmailVerified === true && pathname === "/verification") { - return { - initialAuthenticatedUserSubgraph, - user, - ...redirect("/"), - }; + const redirectTo = redirectInGetInitialProps({ + appContext, + location: "/", + }); + return { initialAuthenticatedUserSubgraph, user, redirectTo }; } + let redirectTo: string | undefined; + // If the user is logged in but hasn't completed signup... if (!user.accountSignupComplete) { const hasAccessToHash = await apolloClient @@ -414,61 +359,60 @@ AppWithTypeSystemContextProvider.getInitialProps = async (appContext) => { // ...if they have access to HASH but aren't on the signup page... if (hasAccessToHash && !pathname.startsWith("/signup")) { // ...then redirect them to the signup page. - return { - initialAuthenticatedUserSubgraph, - user, - ...redirect("/signup"), - }; + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/signup", + }); // ...if they don't have access to HASH but aren't on the home page... } else if (!hasAccessToHash && pathname !== "/") { // ...then redirect them to the home page. - return { - initialAuthenticatedUserSubgraph, - user, - ...redirect("/"), - }; + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/", + }); } } else if (redirectIfAuthenticatedPathnames.includes(pathname)) { /** * If the user has completed signup and is on a page they shouldn't be on * (e.g. /signup), then redirect them to the home page. */ - return { - initialAuthenticatedUserSubgraph, - user, - ...redirect("/"), - }; + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/", + }); } // For each feature flag... - for (const featureFlag of featureFlags) { - /** - * ...if the user has not enabled the feature flag, - * and the page is a hidden pathname for that feature flag... - */ - if ( - !user.enabledFeatureFlags.includes(featureFlag) && - featureFlagHiddenPathnames[featureFlag].includes(pathname) - ) { - const isUserAdmin = await apolloClient - .query({ - query: getHashInstanceSettings, - context: { headers: { cookie } }, - }) - .then(({ data }) => !!data.hashInstanceSettings?.isUserAdmin); - - if (!isUserAdmin) { - // ...then redirect them to the home page instead. - return { - initialAuthenticatedUserSubgraph, - user, - ...redirect("/"), - }; + if (!redirectTo) { + for (const featureFlag of featureFlags) { + /** + * ...if the user has not enabled the feature flag, + * and the page is a hidden pathname for that feature flag... + */ + if ( + !user.enabledFeatureFlags.includes(featureFlag) && + featureFlagHiddenPathnames[featureFlag].includes(pathname) + ) { + const isUserAdmin = await apolloClient + .query({ + query: getHashInstanceSettings, + context: { headers: { cookie } }, + }) + .then(({ data }) => !!data.hashInstanceSettings?.isUserAdmin); + + if (!isUserAdmin) { + // ...then redirect them to the home page instead. + redirectTo = redirectInGetInitialProps({ + appContext, + location: "/", + }); + break; + } } } } - return { initialAuthenticatedUserSubgraph, user }; + return { initialAuthenticatedUserSubgraph, user, redirectTo }; }; export default AppWithTypeSystemContextProvider; diff --git a/apps/hash-frontend/src/pages/settings/security.page.tsx b/apps/hash-frontend/src/pages/settings/security.page.tsx index bdb5b51e562..51b60501489 100644 --- a/apps/hash-frontend/src/pages/settings/security.page.tsx +++ b/apps/hash-frontend/src/pages/settings/security.page.tsx @@ -57,16 +57,23 @@ const extractBackupCodesFromFlow = (flow: SettingsFlow): string[] => { return []; } - const normalizedText = codesText - .replace(//gi, "\n") - .replace(/<\/?[^>]+>/g, ""); - - const regexMatches = normalizedText.match(/[A-Z0-9]{4}(?:-[A-Z0-9]{4})+/gi); + // Extract backup codes directly by pattern rather than stripping HTML first. + // Kratos may return codes in an HTML-formatted string (with
tags, etc.), + // but we only care about the alphanumeric code values themselves. + const regexMatches = codesText.match(/[A-Z0-9]{4}(?:-[A-Z0-9]{4})+/gi); if (regexMatches?.length) { return regexMatches; } - return normalizedText + // Fallback: replace
with newlines, then use DOMParser to safely extract + // plain text. DOMParser creates an inert document — no scripts execute, no + // resources load, and no event handlers fire, unlike innerHTML on a live element. + // The data comes from Kratos in any case. + const withNewlines = codesText.replace(//gi, "\n"); + const parsed = new DOMParser().parseFromString(withNewlines, "text/html"); + const plainText = parsed.body.textContent; + + return plainText .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); diff --git a/apps/hash-frontend/src/pages/shared/_app.util.ts b/apps/hash-frontend/src/pages/shared/_app.util.ts index 722af32a70f..eef6b4373ff 100644 --- a/apps/hash-frontend/src/pages/shared/_app.util.ts +++ b/apps/hash-frontend/src/pages/shared/_app.util.ts @@ -10,14 +10,14 @@ export type AppPage

, IP = P> = NextComponentType< /** * Redirect during getInitialProps. Server-side, this sends an HTTP 307. - * Client-side, this is a no-op — callers should return a `redirectTo` field - * from getInitialProps so the component can handle it via useEffect, avoiding - * calling router.push during an active route transition (which stalls NProgress). + * Client-side, returns the redirect location so the caller can pass it as a + * prop – the component then handles it via useEffect, avoiding calling + * router.push during an active route transition (which stalls NProgress). */ export const redirectInGetInitialProps = (params: { appContext: AppContext; location: string; -}) => { +}): string | undefined => { const { appContext: { ctx: { res }, @@ -33,5 +33,6 @@ export const redirectInGetInitialProps = (params: { res.writeHead(307, { Location: location }); res.end(); } - // On client-side, do nothing. The component handles redirects via useEffect. + + // On client-side, return the location for the component to handle. }; diff --git a/apps/hash-frontend/src/pages/shared/verify-code.tsx b/apps/hash-frontend/src/pages/shared/verify-code.tsx deleted file mode 100644 index ce2cc06eadd..00000000000 --- a/apps/hash-frontend/src/pages/shared/verify-code.tsx +++ /dev/null @@ -1,301 +0,0 @@ -/** - * @todo H-2421: Check this file for redundancy after implementing email verification. - */ - -import { Box } from "@mui/material"; -import type { - ClipboardEventHandler, - FormEvent, - FunctionComponent, -} from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { KeyboardReturnIcon } from "../../shared/icons"; -import { SYNTHETIC_LOADING_TIME_MS } from "./auth-utils"; - -type VerifyCodeProps = { - defaultCode?: string; - goBack: () => void; - loading: boolean; - errorMessage?: string; - loginIdentifier: string; - handleSubmit: (code: string, withSyntheticLoading?: boolean) => void; - requestCode: () => void | Promise; - requestCodeLoading: boolean; -}; - -const isShortname = (identifier: string) => !identifier.includes("@"); - -const parseVerificationCodeInput = (inputCode: string) => - inputCode.replace(/\s/g, ""); - -const doesVerificationCodeLookValid = (code: string) => { - const units = code.split("-"); - return units.length >= 4 && units[3]!.length > 0; -}; - -export const VerifyCode: FunctionComponent = ({ - defaultCode, - goBack, - errorMessage, - loginIdentifier, - handleSubmit, - loading, - requestCode, - requestCodeLoading, -}) => { - const [state, setState] = useState({ - text: defaultCode ?? "", - emailResent: false, - syntheticLoading: false, - }); - - const { text, emailResent, syntheticLoading } = state; - const inputRef = useRef(null); - - const updateState = useCallback((newState: Partial) => { - setState((prevState) => ({ - ...prevState, - ...newState, - })); - }, []); - - useEffect(() => { - inputRef.current?.select(); - }, []); - - const isInputValid = useCallback( - () => doesVerificationCodeLookValid(text), - [text], - ); - - const onSubmit = (evt: FormEvent) => { - evt.preventDefault(); - handleSubmit(text); - }; - - const handleResendCode = () => { - updateState({ syntheticLoading: true }); - // eslint-disable-next-line @typescript-eslint/no-misused-promises - setTimeout(async () => { - try { - await requestCode(); - updateState({ emailResent: true, syntheticLoading: false }); - setTimeout(() => updateState({ emailResent: false }), 5000); - } catch { - updateState({ syntheticLoading: false }); - } - }, SYNTHETIC_LOADING_TIME_MS); - }; - - // The handler supports partial code pasting. Use case: - // 1. Open email, accidentally select all characters but the first one. - // 2. Manually type in the first character and then paste. - // 3. The form submits the entire code and not only clipboardData. - const handleInputPaste: ClipboardEventHandler = ({ - currentTarget, - }) => { - const originalValue = currentTarget.value; - - setImmediate(() => { - const valueAfterPasting = currentTarget.value; - if (!valueAfterPasting || originalValue === valueAfterPasting) { - return; - } - - const pastedCode = parseVerificationCodeInput(valueAfterPasting); - if (doesVerificationCodeLookValid(pastedCode)) { - handleSubmit(pastedCode, true); - } - }); - }; - - return ( -

-
-
-

- A verification code has been sent to{" "} - - {isShortname(loginIdentifier) - ? "your primary email address" - : loginIdentifier} - -

-

- Click the link in this email or enter the verification phrase below - to continue -

-
- - updateState({ text: parseVerificationCodeInput(target.value) }) - } - onPaste={handleInputPaste} - value={text} - ref={inputRef} - data-testid="verify-code-input" - /> - - {loading ? ( - Loading - ) : ( - <> - Submit - - - )} - - - {errorMessage && ( - - {errorMessage} - - )} -
-
-
- - ←{" "} - - Try logging in another way - - - {emailResent ? ( -
- No email yet? - - Email Resent - -
- ) : ( -
- No email yet? - - Resend email - -
- )} -
-
- ); -}; diff --git a/apps/hash-frontend/src/pages/shared/verify-email-step.tsx b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx index 46139a325a5..6594ad206b8 100644 --- a/apps/hash-frontend/src/pages/shared/verify-email-step.tsx +++ b/apps/hash-frontend/src/pages/shared/verify-email-step.tsx @@ -17,12 +17,19 @@ type VerifyEmailStepProps = { email: string; /** An error message to display initially (e.g. from a failed auto-verify attempt). */ initialError?: string; + /** + * An existing verification flow ID (e.g. from Ory's `continue_with` after + * registration). If provided, the flow is fetched on mount so the code input + * is shown immediately without sending an additional email. + */ + initialVerificationFlowId?: string; onVerified: () => void | Promise; }; export const VerifyEmailStep: FunctionComponent = ({ email, initialError, + initialVerificationFlowId, onVerified, }) => { const [flow, setFlow] = useState(); @@ -47,6 +54,12 @@ export const VerifyEmailStep: FunctionComponent = ({ const handleFlowErrorRef = useRef(handleFlowError); handleFlowErrorRef.current = handleFlowError; + /** + * The active flow ID – either from a flow we created in this session, or + * passed in from the registration response's `continue_with`. + */ + const activeFlowId = flow?.id ?? initialVerificationFlowId; + const extractCodeValue = useCallback((nextFlow: VerificationFlow) => { const codeInputNode = nextFlow.ui.nodes.find( ({ attributes }) => @@ -110,47 +123,64 @@ export const VerifyEmailStep: FunctionComponent = ({ [flow], ); - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); + const submitCode = useCallback( + (codeToSubmit: string) => { + if (!activeFlowId || !codeToSubmit) { + return; + } - if (!flow || !code) { - return; - } + setVerifyingCode(true); - setVerifyingCode(true); + let succeeded = false; - let succeeded = false; + const getFlowForSubmission = flow + ? Promise.resolve(flow) + : oryKratosClient + .getVerificationFlow({ id: activeFlowId }) + .then(({ data }) => { + setFlow(data); + return data; + }); - void oryKratosClient - .updateVerificationFlow({ - flow: flow.id, - updateVerificationFlowBody: { - method: "code", - code, - csrf_token: mustGetCsrfTokenFromFlow(flow), - }, - }) - .then(async () => { - await onVerified(); - succeeded = true; - }) - .catch(async (error: AxiosError) => { - await handleFlowErrorRef.current(error); - }) - .catch((error: AxiosError) => { - if (error.response?.status === 400) { - setFlow(error.response.data); - return; - } + void getFlowForSubmission + .then((resolvedFlow) => + oryKratosClient.updateVerificationFlow({ + flow: resolvedFlow.id, + updateVerificationFlowBody: { + method: "code", + code: codeToSubmit, + csrf_token: mustGetCsrfTokenFromFlow(resolvedFlow), + }, + }), + ) + .then(async () => { + await onVerified(); + succeeded = true; + }) + .catch(async (error: AxiosError) => { + await handleFlowErrorRef.current(error); + }) + .catch((error: AxiosError) => { + if (error.response?.status === 400) { + setFlow(error.response.data); + return; + } - return Promise.reject(error); - }) - .finally(() => { - // Only reset on failure – on success, onVerified triggers navigation. - if (!succeeded) { - setVerifyingCode(false); - } - }); + return Promise.reject(error); + }) + .finally(() => { + // Only reset on failure – on success, onVerified triggers navigation. + if (!succeeded) { + setVerifyingCode(false); + } + }); + }, + [activeFlowId, flow, onVerified], + ); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + submitCode(code); }; return ( @@ -163,9 +193,15 @@ export const VerifyEmailStep: FunctionComponent = ({ mb: 3, }} > - {flow - ? `Enter the verification code sent to ${email}` - : `We've sent a verification code to ${email}. Click the link in the email to verify instantly, or request a new code below to enter manually.`} + {activeFlowId ? ( + `Enter the verification code sent to ${email}` + ) : ( + <> + We've sent a verification code to {email}. Click + the link in the email to verify instantly, or request a new code + below to enter manually. + + )} = ({ width: "100%", }} > - {flow ? ( + {activeFlowId ? ( <> = ({ autoComplete="one-time-code" placeholder="Enter your verification code" value={code} - onChange={({ target }) => setCode(target.value)} + onChange={({ target }) => { + const value = target.value; + setCode(value); + + if (/^\d{6}$/.test(value)) { + submitCode(value); + } + }} error={ !!codeInputUiNode?.messages.find(({ type }) => type === "error") } @@ -237,12 +280,12 @@ export const VerifyEmailStep: FunctionComponent = ({ textAlign: "center", }} > - Dev mode: check{" "} + [DEV] check{" "} MailSlurper (localhost:4436) {" "} diff --git a/apps/hash-frontend/src/pages/signin.page.tsx b/apps/hash-frontend/src/pages/signin.page.tsx index 26b6403a587..427c1be0530 100644 --- a/apps/hash-frontend/src/pages/signin.page.tsx +++ b/apps/hash-frontend/src/pages/signin.page.tsx @@ -419,7 +419,7 @@ const SigninPage: NextPageWithLayout = () => { )} - {flow?.ui.messages?.map(({ id, text }) => ( - {text} + {flow?.ui.messages?.map(({ id, text, type }) => ( + + type === "error" ? palette.error.main : palette.gray[70], + }} + > + {text} + ))} {errorMessage ? {errorMessage} : null} diff --git a/apps/hash-frontend/src/pages/verification.page.tsx b/apps/hash-frontend/src/pages/verification.page.tsx index 52a16cc1668..cb7b81c7b8d 100644 --- a/apps/hash-frontend/src/pages/verification.page.tsx +++ b/apps/hash-frontend/src/pages/verification.page.tsx @@ -79,9 +79,28 @@ const VerifyEmailPage: NextPageWithLayout = () => { }, }), ) - .then(async () => { - await refetch(); - void router.replace("/"); + .then(async ({ data: updatedFlow }) => { + if (updatedFlow.state === "passed_challenge") { + await refetch(); + void router.replace("/"); + } else { + // Kratos returns 200 even when the code is invalid – extract error + // messages from the flow body. + const errorMessages = + updatedFlow.ui.messages + ?.filter(({ type }) => type === "error") + .map(({ text }) => text) ?? []; + + setAutoVerifyError( + errorMessages.length > 0 + ? errorMessages.join(" ") + : "The verification code was not accepted. Please try again.", + ); + setAutoVerifying(false); + + // Strip the code and flow params from the URL so we don't retry + void router.replace("/verification", undefined, { shallow: true }); + } }) .catch((error: AxiosError) => { const errorMessages = From 13f1e468d8621f4106064de0b516445a19c165f3 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 11:52:50 +0000 Subject: [PATCH 20/32] remove unneeded verify button clicks --- tests/hash-playwright/tests/shared/signup-utils.ts | 1 - tests/hash-playwright/tests/signup.spec.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/tests/hash-playwright/tests/shared/signup-utils.ts b/tests/hash-playwright/tests/shared/signup-utils.ts index 2d14d01128e..a99c8d67d21 100644 --- a/tests/hash-playwright/tests/shared/signup-utils.ts +++ b/tests/hash-playwright/tests/shared/signup-utils.ts @@ -61,7 +61,6 @@ export const verifyEmailOnPage = async ( '[placeholder="Enter your verification code"]', verificationCode, ); - await page.getByRole("button", { name: "Verify" }).click(); }; /** diff --git a/tests/hash-playwright/tests/signup.spec.ts b/tests/hash-playwright/tests/signup.spec.ts index 21ae156c236..50b18fce53d 100644 --- a/tests/hash-playwright/tests/signup.spec.ts +++ b/tests/hash-playwright/tests/signup.spec.ts @@ -26,7 +26,6 @@ test("allowlisted user can verify email and complete signup", async ({ // Submitting an incorrect code should show an error await page.fill('[placeholder="Enter your verification code"]', "000000"); - await page.getByRole("button", { name: "Verify" }).click(); await expect( page.locator( @@ -50,7 +49,6 @@ test("allowlisted user can verify email and complete signup", async ({ '[placeholder="Enter your verification code"]', verificationCode, ); - await page.getByRole("button", { name: "Verify" }).click(); // Complete signup after verification const uniqueSuffix = `${Date.now()}${Math.floor(Math.random() * 1_000)}`; From 32d4260d02ab1ee5e0938f1932a3af72afd3cb5e Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 11:53:59 +0000 Subject: [PATCH 21/32] add mailslurper diagnostics --- .../shared/get-kratos-verification-code.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts b/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts index 3c0ff169621..5f4bc02b0cc 100644 --- a/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts +++ b/tests/hash-playwright/tests/shared/get-kratos-verification-code.ts @@ -50,6 +50,7 @@ export const getKratosVerificationCode = async ( const pollIntervalMs = 250; let elapsed = 0; let lastError: unknown; + let lastMailItems: MailslurperMailItem[] | undefined; while (elapsed < maxWaitMs) { try { @@ -65,6 +66,8 @@ export const getKratosVerificationCode = async ( mailItems?: MailslurperMailItem[]; }; + lastMailItems = data.mailItems; + const matchingMailItems = data.mailItems ?.filter((mailItem) => { @@ -107,7 +110,39 @@ export const getKratosVerificationCode = async ( const lastErrorMessage = lastError instanceof Error ? ` Last error: ${lastError.message}` : ""; + // Build diagnostic summary from the last poll to help debug failures. + const allItems = lastMailItems ?? []; + const toTargetAddress = allItems.filter((item) => + extractToAddresses(item.toAddresses).includes(emailAddress), + ); + const verificationToTarget = toTargetAddress.filter((item) => + isVerificationSubject(item.subject), + ); + const timestampFilteredOut = + afterTimestamp !== undefined + ? verificationToTarget.filter((item) => { + const sent = item.dateSent + ? new Date(item.dateSent).getTime() + : undefined; + return typeof sent === "number" && sent < afterTimestamp; + }) + : []; + + const diagnostics = [ + `Total emails in mailslurper: ${allItems.length}`, + `Emails to ${emailAddress}: ${toTargetAddress.length}`, + `Verification emails to ${emailAddress}: ${verificationToTarget.length}`, + afterTimestamp !== undefined + ? `Filtered out by timestamp (sent before ${new Date(afterTimestamp).toISOString()}): ${timestampFilteredOut.length}` + : null, + toTargetAddress.length > 0 + ? `Subjects to target: ${toTargetAddress.map((item) => JSON.stringify(item.subject)).join(", ")}` + : null, + ] + .filter(Boolean) + .join("; "); + throw new Error( - `No verification email found for ${emailAddress} within ${maxWaitMs}ms.${lastErrorMessage}`, + `No verification email found for ${emailAddress} within ${maxWaitMs}ms.${lastErrorMessage} [${diagnostics}]`, ); }; From 5981b00cff54a490dc976e11825e4907550891b3 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 12:06:00 +0000 Subject: [PATCH 22/32] disable email verification cleanup for now --- .../create-unverified-email-cleanup-job.ts | 2 +- apps/hash-api/src/index.ts | 23 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts b/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts index 3ba6976d152..5d3d0fbaee4 100644 --- a/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts +++ b/apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts @@ -18,7 +18,7 @@ import { deleteKratosIdentity, kratosIdentityApi } from "./ory-kratos"; * retroactive deletion of accounts that existed before email verification * was introduced. */ -const DEFAULT_ROLLOUT_AT = new Date("2026-02-13T00:00:00.000Z"); +const DEFAULT_ROLLOUT_AT = new Date("2026-02-14T00:00:00.000Z"); const DEFAULT_RELEASE_TTL_HOURS = 24 * 7; const DEFAULT_SWEEP_INTERVAL_MINUTES = 60; diff --git a/apps/hash-api/src/index.ts b/apps/hash-api/src/index.ts index 1ff368f3628..de956af7b9d 100644 --- a/apps/hash-api/src/index.ts +++ b/apps/hash-api/src/index.ts @@ -842,17 +842,18 @@ const main = async () => { }); if (!isTestEnv) { - const unverifiedEmailCleanupJob = createUnverifiedEmailCleanupJob({ - context: machineActorContext, - logger, - }); - - await unverifiedEmailCleanupJob.start(); - - shutdown.addCleanup( - "Unverified email cleanup job", - unverifiedEmailCleanupJob.stop, - ); + /** + * H-6218 – introduce this after optimising the query and doing more testing + */ + // const unverifiedEmailCleanupJob = createUnverifiedEmailCleanupJob({ + // context: machineActorContext, + // logger, + // }); + // await unverifiedEmailCleanupJob.start(); + // shutdown.addCleanup( + // "Unverified email cleanup job", + // unverifiedEmailCleanupJob.stop, + // ); } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition From cdf0a2f65a12ff4ef2b3d868af5f6814f579b943 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 12:22:05 +0000 Subject: [PATCH 23/32] comment out unused import --- apps/hash-api/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/hash-api/src/index.ts b/apps/hash-api/src/index.ts index de956af7b9d..afc12946a21 100644 --- a/apps/hash-api/src/index.ts +++ b/apps/hash-api/src/index.ts @@ -53,7 +53,7 @@ import { addKratosAfterRegistrationHandler, createAuthMiddleware, } from "./auth/create-auth-handlers"; -import { createUnverifiedEmailCleanupJob } from "./auth/create-unverified-email-cleanup-job"; +// import { createUnverifiedEmailCleanupJob } from "./auth/create-unverified-email-cleanup-job"; import { getActorIdFromRequest } from "./auth/get-actor-id"; import { oauthConsentRequestHandler, From 03b771c29f0166ceecab28a36bac2e4faf5221e5 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 12:27:47 +0000 Subject: [PATCH 24/32] handle signup page for already-verified users --- apps/hash-frontend/src/pages/signup.page.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/hash-frontend/src/pages/signup.page.tsx b/apps/hash-frontend/src/pages/signup.page.tsx index a9d7bbaf587..3fed60012b2 100644 --- a/apps/hash-frontend/src/pages/signup.page.tsx +++ b/apps/hash-frontend/src/pages/signup.page.tsx @@ -1,4 +1,4 @@ -import { useLazyQuery, useMutation, useQuery } from "@apollo/client"; +import { useMutation, useQuery } from "@apollo/client"; import type { EntityId } from "@blockprotocol/type-system"; import { ArrowUpRightRegularIcon } from "@hashintel/design-system"; import { Grid, styled } from "@mui/material"; @@ -73,9 +73,10 @@ const SignupPage: NextPageWithLayout = () => { const userHasVerifiedEmail = authenticatedUser?.emails.find(({ verified }) => verified) !== undefined; - const [fetchHasAccess, { data: userHasAccessToHashData }] = - useLazyQuery(hasAccessToHashQuery, { + const { data: userHasAccessToHashData, refetch: refetchHasAccess } = + useQuery(hasAccessToHashQuery, { fetchPolicy: "network-only", + skip: !userHasVerifiedEmail, }); const { invitationId } = router.query; @@ -196,9 +197,9 @@ const SignupPage: NextPageWithLayout = () => { onVerified={async () => { await refetchAuthenticatedUser(); - const { data } = await fetchHasAccess(); + const { data } = await refetchHasAccess(); - if (!data?.hasAccessToHash) { + if (!data.hasAccessToHash) { void router.replace("/"); } }} From 5aae8cd46ff16821ba3c512f157e2f94040195e4 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 12:46:40 +0000 Subject: [PATCH 25/32] further tweak signup logic --- apps/hash-frontend/src/pages/signup.page.tsx | 24 ++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/hash-frontend/src/pages/signup.page.tsx b/apps/hash-frontend/src/pages/signup.page.tsx index 3fed60012b2..17536fcba6f 100644 --- a/apps/hash-frontend/src/pages/signup.page.tsx +++ b/apps/hash-frontend/src/pages/signup.page.tsx @@ -1,9 +1,9 @@ -import { useMutation, useQuery } from "@apollo/client"; +import { useLazyQuery, useMutation, useQuery } from "@apollo/client"; import type { EntityId } from "@blockprotocol/type-system"; import { ArrowUpRightRegularIcon } from "@hashintel/design-system"; import { Grid, styled } from "@mui/material"; import { useRouter } from "next/router"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useUpdateAuthenticatedUser } from "../components/hooks/use-update-authenticated-user"; import type { @@ -73,12 +73,22 @@ const SignupPage: NextPageWithLayout = () => { const userHasVerifiedEmail = authenticatedUser?.emails.find(({ verified }) => verified) !== undefined; - const { data: userHasAccessToHashData, refetch: refetchHasAccess } = - useQuery(hasAccessToHashQuery, { + const [fetchHasAccess, { data: userHasAccessToHashData }] = + useLazyQuery(hasAccessToHashQuery, { fetchPolicy: "network-only", - skip: !userHasVerifiedEmail, }); + /** + * Eagerly fetch access when the user already has a verified email on mount + * (e.g. page refresh after verification). The lazy query in `onVerified` + * handles the in-session verification flow. + */ + useEffect(() => { + if (userHasVerifiedEmail && !userHasAccessToHashData) { + void fetchHasAccess(); + } + }, [userHasVerifiedEmail, userHasAccessToHashData, fetchHasAccess]); + const { invitationId } = router.query; const [showInvitationStep, setShowInvitationStep] = useState(true); @@ -197,9 +207,9 @@ const SignupPage: NextPageWithLayout = () => { onVerified={async () => { await refetchAuthenticatedUser(); - const { data } = await refetchHasAccess(); + const { data } = await fetchHasAccess(); - if (!data.hasAccessToHash) { + if (!data?.hasAccessToHash) { void router.replace("/"); } }} From 24ce4cc73c666341bd3b4af5880fad815bf757cd Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 13:59:50 +0000 Subject: [PATCH 26/32] more test fixes --- tests/hash-playwright/tests/mfa.spec.ts | 5 ++- .../tests/shared/totp-utils.ts | 14 +++++++ tests/hash-playwright/tests/signup.spec.ts | 39 +++---------------- 3 files changed, 24 insertions(+), 34 deletions(-) diff --git a/tests/hash-playwright/tests/mfa.spec.ts b/tests/hash-playwright/tests/mfa.spec.ts index 3e38084c075..6f11781110c 100644 --- a/tests/hash-playwright/tests/mfa.spec.ts +++ b/tests/hash-playwright/tests/mfa.spec.ts @@ -1,7 +1,7 @@ import { resetDb } from "./shared/reset-db"; import { expect, type Page, test } from "./shared/runtime"; import { createUserAndCompleteSignup } from "./shared/signup-utils"; -import { generateTotpCode } from "./shared/totp-utils"; +import { generateTotpCode, waitForFreshTotpWindow } from "./shared/totp-utils"; const enableTotpForCurrentUser = async (page: Page) => { await page.goto("/settings/security"); @@ -16,6 +16,7 @@ const enableTotpForCurrentUser = async (page: Page) => { throw new Error("Could not read TOTP secret key from settings page."); } + await waitForFreshTotpWindow(); await page.fill( '[placeholder="Enter your 6-digit code"]', generateTotpCode(secret), @@ -81,6 +82,7 @@ test("user with TOTP is prompted for code at login", async ({ page }) => { page.locator("text=Enter your authentication code"), ).toBeVisible(); + await waitForFreshTotpWindow(); await page.fill( '[data-testid="signin-aal2-code-input"]', generateTotpCode(secret), @@ -122,6 +124,7 @@ test("user can disable TOTP", async ({ page }) => { await page.goto("/settings/security"); await page.click('[data-testid="disable-totp-button"]'); + await waitForFreshTotpWindow(); await page.fill( '[placeholder="Enter a current code to disable"]', generateTotpCode(secret), diff --git a/tests/hash-playwright/tests/shared/totp-utils.ts b/tests/hash-playwright/tests/shared/totp-utils.ts index f57a835e80f..dbc3a7d83ac 100644 --- a/tests/hash-playwright/tests/shared/totp-utils.ts +++ b/tests/hash-playwright/tests/shared/totp-utils.ts @@ -55,3 +55,17 @@ export const generateTotpCode = ( return (binaryCode % 1_000_000).toString().padStart(6, "0"); }; + +/** + * If fewer than `bufferMs` remain in the current 30-second TOTP window, + * wait for the next window so the generated code has enough validity time. + */ +export const waitForFreshTotpWindow = async (bufferMs = 5_000) => { + const secondsIntoWindow = (Date.now() / 1_000) % 30; + const msRemaining = (30 - secondsIntoWindow) * 1_000; + if (msRemaining < bufferMs) { + await new Promise((resolve) => { + setTimeout(resolve, msRemaining + 200); + }); + } +}; diff --git a/tests/hash-playwright/tests/signup.spec.ts b/tests/hash-playwright/tests/signup.spec.ts index 50b18fce53d..c3705cda015 100644 --- a/tests/hash-playwright/tests/signup.spec.ts +++ b/tests/hash-playwright/tests/signup.spec.ts @@ -1,4 +1,3 @@ -import { getKratosVerificationCode } from "./shared/get-kratos-verification-code"; import { resetDb } from "./shared/reset-db"; import { expect, test } from "./shared/runtime"; import { @@ -20,37 +19,11 @@ test("allowlisted user can verify email and complete signup", async ({ email: allowlistedEmail, }); - await expect( - page.getByRole("heading", { name: "Verify your email address" }), - ).toBeVisible({ timeout: 15_000 }); - - // Submitting an incorrect code should show an error - await page.fill('[placeholder="Enter your verification code"]', "000000"); - - await expect( - page.locator( - "text=/verification code.*(invalid|expired|used)|code is invalid|code is expired/i", - ), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Verify your email address" }), - ).toBeVisible(); - - // Resend the verification email and verify with the correct code - const resendTimestamp = Date.now(); - await page.getByRole("button", { name: "Resend verification email" }).click(); - - const verificationCode = await getKratosVerificationCode( + await verifyEmailOnPage(page, { email, - Math.max(emailDispatchTimestamp, resendTimestamp), - ); - - await page.fill( - '[placeholder="Enter your verification code"]', - verificationCode, - ); + afterTimestamp: emailDispatchTimestamp, + }); - // Complete signup after verification const uniqueSuffix = `${Date.now()}${Math.floor(Math.random() * 1_000)}`; const shortname = `signup${uniqueSuffix}`.slice(0, 24); @@ -75,19 +48,19 @@ test("waitlisted user is redirected to waitlist after signup", async ({ await page.waitForURL("/"); await expect( - page.getByRole("heading", { name: "on the waitlist", exact: false }), + page.getByText("on the waitlist", { exact: false }), ).toBeVisible(); await page.goto("/settings/security"); await page.waitForURL("/"); await expect( - page.getByRole("heading", { name: "on the waitlist", exact: false }), + page.getByText("on the waitlist", { exact: false }), ).toBeVisible(); await page.goto("/signup"); await page.waitForURL("/"); await expect( - page.getByRole("heading", { name: "on the waitlist", exact: false }), + page.getByText("on the waitlist", { exact: false }), ).toBeVisible(); }); From 450f4d8bf7c24d13c0ffb0908f9a7ae3785b9670 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 14:03:37 +0000 Subject: [PATCH 27/32] comment out TOTP UI for now --- .../src/pages/settings/security.page.tsx | 370 +++++++++--------- 1 file changed, 191 insertions(+), 179 deletions(-) diff --git a/apps/hash-frontend/src/pages/settings/security.page.tsx b/apps/hash-frontend/src/pages/settings/security.page.tsx index 51b60501489..46f2b4e2c85 100644 --- a/apps/hash-frontend/src/pages/settings/security.page.tsx +++ b/apps/hash-frontend/src/pages/settings/security.page.tsx @@ -518,210 +518,222 @@ const SecurityPage: NextPageWithLayout = () => { - - - Two-factor authentication - - - {isTotpEnabled ? ( - - palette.gray[80] }}> - TOTP is enabled for your account. - - {showTotpDisableForm ? ( - - - setDisableTotpCode(target.value) - } - error={ - !!totpCodeUiNode?.messages.find( - ({ type }) => type === "error", - ) - } - helperText={totpCodeUiNode?.messages.map( - ({ id, text }) => ( - {text} - ), - )} - required - inputProps={{ inputMode: "numeric" }} - /> + {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- @todo H-6219 restore this */} + {false && ( + + + Two-factor authentication + + + {isTotpEnabled ? ( + + palette.gray[80] }}> + TOTP is enabled for your account. + + {showTotpDisableForm ? ( + + + setDisableTotpCode(target.value) + } + error={ + !!totpCodeUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={totpCodeUiNode?.messages.map( + ({ id, text }) => ( + {text} + ), + )} + required + inputProps={{ inputMode: "numeric" }} + /> + + + + + + ) : ( - - ) : ( + )} + + ) : showTotpSetupForm ? ( + + palette.gray[80] }} + > + Scan the QR code with your authenticator app, then enter the + 6-digit code to enable TOTP. + + {totpQrCodeDataUri ? ( + + `1px solid ${palette.gray[30]}`, + }} + /> + ) : null} + {totpSecretKey ? ( + + ({ + color: palette.gray[80], + mb: 0.75, + display: "block", + })} + > + {totpQrCodeDataUri + ? "Alternatively, use the secret key below for manual setup." + : "QR code unavailable. Use the secret key below for manual setup."} + + palette.gray[20], + fontFamily: "monospace", + }} + > + {totpSecretKey} + + + ) : null} + setTotpCode(target.value)} + error={ + !!totpCodeUiNode?.messages.find( + ({ type }) => type === "error", + ) + } + helperText={totpCodeUiNode?.messages.map(({ id, text }) => ( + {text} + ))} + required + inputProps={{ inputMode: "numeric" }} + /> - )} - - ) : showTotpSetupForm ? ( - - palette.gray[80] }} + + ) : ( + - Scan the QR code with your authenticator app, then enter the - 6-digit code to enable TOTP. - - {totpQrCodeDataUri ? ( - `1px solid ${palette.gray[30]}`, - }} - /> - ) : null} - {totpSecretKey ? ( + palette.gray[80] }}> + TOTP is currently disabled for your account. + - ({ - color: palette.gray[80], - mb: 0.75, - display: "block", - })} - > - {totpQrCodeDataUri - ? "Alternatively, use the secret key below for manual setup." - : "QR code unavailable. Use the secret key below for manual setup."} - - palette.gray[20], - fontFamily: "monospace", + - ) : null} - setTotpCode(target.value)} - error={ - !!totpCodeUiNode?.messages.find( - ({ type }) => type === "error", - ) - } - helperText={totpCodeUiNode?.messages.map(({ id, text }) => ( - {text} - ))} - required - inputProps={{ inputMode: "numeric" }} - /> - - - - - ) : ( - - palette.gray[80] }}> - TOTP is currently disabled for your account. - - - - - - )} - + )} + + )} {flow?.ui.messages?.map(({ id, text }) => ( {text} From 160ddc7f9063a96456ec49c7ca0fb39f7d5e5b77 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 14:03:59 +0000 Subject: [PATCH 28/32] slightly bump rate limit --- apps/hash-api/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/hash-api/src/index.ts b/apps/hash-api/src/index.ts index afc12946a21..159c85e1028 100644 --- a/apps/hash-api/src/index.ts +++ b/apps/hash-api/src/index.ts @@ -102,7 +102,7 @@ const shutdown = new GracefulShutdown(logger, "SIGINT", "SIGTERM"); const baseRateLimitOptions: Partial = { windowMs: process.env.NODE_ENV === "test" ? 10 : 1000 * 10, // 10 seconds - limit: 10, // Limit each IP to 10 requests every 10 seconds + limit: 12, // Limit each IP to 12 requests every 10 seconds standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }; From 3c513f5b0d43bc4a3c8bd56fd1bc811442cd9ebe Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 14:05:21 +0000 Subject: [PATCH 29/32] skip TOTP tests --- tests/hash-playwright/tests/mfa.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/hash-playwright/tests/mfa.spec.ts b/tests/hash-playwright/tests/mfa.spec.ts index 6f11781110c..b2f8aace650 100644 --- a/tests/hash-playwright/tests/mfa.spec.ts +++ b/tests/hash-playwright/tests/mfa.spec.ts @@ -56,7 +56,10 @@ test.beforeEach(async () => { await resetDb(); }); -test("user can enable TOTP", async ({ page }) => { +/** + * @todo H-6219 restore these tests when restoring TOTP functionality + */ +test.skip("user can enable TOTP", async ({ page }) => { await createUserAndCompleteSignup(page, { email: "mfa-enable-totp@example.com", shortname: "mfa-enable-totp", @@ -67,7 +70,7 @@ test("user can enable TOTP", async ({ page }) => { expect(backupCodes.length).toBeGreaterThan(0); }); -test("user with TOTP is prompted for code at login", async ({ page }) => { +test.skip("user with TOTP is prompted for code at login", async ({ page }) => { const credentials = await createUserAndCompleteSignup(page, { email: "mfa-totp-login@example.com", shortname: "mfa-totp-login", @@ -92,7 +95,7 @@ test("user with TOTP is prompted for code at login", async ({ page }) => { await expect(page.locator("text=Get support")).toBeVisible(); }); -test("user can use backup code instead of TOTP", async ({ page }) => { +test.skip("user can use backup code instead of TOTP", async ({ page }) => { const credentials = await createUserAndCompleteSignup(page, { email: "mfa-backup-code@example.com", shortname: "mfa-backup-code", @@ -115,7 +118,7 @@ test("user can use backup code instead of TOTP", async ({ page }) => { await expect(page.locator("text=Get support")).toBeVisible(); }); -test("user can disable TOTP", async ({ page }) => { +test.skip("user can disable TOTP", async ({ page }) => { const credentials = await createUserAndCompleteSignup(page, { email: "mfa-disable-totp@example.com", shortname: "mfa-disable-totp", @@ -145,7 +148,7 @@ test("user can disable TOTP", async ({ page }) => { ).not.toBeVisible(); }); -test("wrong TOTP code shows error at login", async ({ page }) => { +test.skip("wrong TOTP code shows error at login", async ({ page }) => { const credentials = await createUserAndCompleteSignup(page, { email: "mfa-wrong-code@example.com", shortname: "mfa-wrong-code", From ccc35f605c00d968fd011e768c791be9a41ae49a Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 16:07:33 +0000 Subject: [PATCH 30/32] fix gate on getPendingInvitationByEntityId --- apps/hash-api/src/graphql/resolvers/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/hash-api/src/graphql/resolvers/index.ts b/apps/hash-api/src/graphql/resolvers/index.ts index e4bf015676c..101b28388a2 100644 --- a/apps/hash-api/src/graphql/resolvers/index.ts +++ b/apps/hash-api/src/graphql/resolvers/index.ts @@ -124,6 +124,7 @@ export const resolvers: Omit & { embedCode, hashInstanceSettings: hashInstanceSettingsResolver, hasAccessToHash: hasAccessToHashResolver, + getPendingInvitationByEntityId: getPendingInvitationByEntityIdResolver, /** Any user – type fetching */ queryDataTypes: queryDataTypesResolver, @@ -163,9 +164,7 @@ export const resolvers: Omit & { getMyPendingInvitations: loggedInAndSignedUpMiddleware( getMyPendingInvitationsResolver, ), - getPendingInvitationByEntityId: loggedInAndSignedUpMiddleware( - getPendingInvitationByEntityIdResolver, - ), + getLinearOrganization: loggedInAndSignedUpMiddleware( getLinearOrganizationResolver, ), From 936ba0f0930fc7cd3674aecad99464596fe05d09 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 16:27:57 +0000 Subject: [PATCH 31/32] PR feedback --- .../src/pages/shared/_app.util.ts | 1 + .../src/pages/verification.page.tsx | 46 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/_app.util.ts b/apps/hash-frontend/src/pages/shared/_app.util.ts index eef6b4373ff..aa04f6f9fd2 100644 --- a/apps/hash-frontend/src/pages/shared/_app.util.ts +++ b/apps/hash-frontend/src/pages/shared/_app.util.ts @@ -35,4 +35,5 @@ export const redirectInGetInitialProps = (params: { } // On client-side, return the location for the component to handle. + return location; }; diff --git a/apps/hash-frontend/src/pages/verification.page.tsx b/apps/hash-frontend/src/pages/verification.page.tsx index cb7b81c7b8d..fd1f20f6434 100644 --- a/apps/hash-frontend/src/pages/verification.page.tsx +++ b/apps/hash-frontend/src/pages/verification.page.tsx @@ -34,7 +34,8 @@ const LogoutButton = styled((props: ButtonProps) => ( const VerifyEmailPage: NextPageWithLayout = () => { const router = useRouter(); const { logout } = useLogoutFlow(); - const { authenticatedUser, refetch } = useAuthInfo(); + const { authenticatedUser, emailVerificationStatusKnown, refetch } = + useAuthInfo(); const primaryEmailVerified = authenticatedUser?.emails.find(({ primary }) => primary)?.verified ?? false; @@ -48,6 +49,32 @@ const VerifyEmailPage: NextPageWithLayout = () => { const [autoVerifyError, setAutoVerifyError] = useState(); const autoVerifyAttempted = useRef(false); + /** + * If the user isn't signed in, redirect them to the sign-in page and + * preserve the verification query params so they can complete verification + * after authenticating. + * + * We wait for `emailVerificationStatusKnown` so we don't redirect while + * the auth info is still loading (where `authenticatedUser` is also + * `undefined`). + */ + useEffect(() => { + if (!authenticatedUser && emailVerificationStatusKnown && router.isReady) { + const returnTo = `/verification${ + urlCode && urlFlowId + ? `?code=${encodeURIComponent(urlCode)}&flow=${encodeURIComponent(urlFlowId)}` + : "" + }`; + void router.replace(`/signin?return_to=${encodeURIComponent(returnTo)}`); + } + }, [ + authenticatedUser, + emailVerificationStatusKnown, + router, + urlCode, + urlFlowId, + ]); + /** * When the page is loaded with both `code` and `flow` query params (e.g. * from clicking the verification link in an email), attempt to verify the @@ -102,16 +129,19 @@ const VerifyEmailPage: NextPageWithLayout = () => { void router.replace("/verification", undefined, { shallow: true }); } }) - .catch((error: AxiosError) => { + .catch((error: AxiosError) => { + const flowData = error.response?.data as + | Partial + | undefined; const errorMessages = - error.response?.data.ui.messages + flowData?.ui?.messages ?.filter(({ type }) => type === "error") .map(({ text }) => text) ?? []; setAutoVerifyError( errorMessages.length > 0 ? errorMessages.join(" ") - : "The verification link may have expired. A new code has been sent to your email.", + : "There was an issue using the verification code. You can try again or send a new code.", ); setAutoVerifying(false); @@ -127,7 +157,13 @@ const VerifyEmailPage: NextPageWithLayout = () => { router, ]); - if (!authenticatedUser || primaryEmailVerified) { + if (primaryEmailVerified) { + return null; + } + + if (!authenticatedUser) { + // The useEffect above will redirect to /signin – return null while that + // navigation is in progress to avoid flashing the verified-user UI. return null; } From 4c6c4ee60d37bb743886b754e2fbacd09eeb68c8 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 13 Feb 2026 16:35:43 +0000 Subject: [PATCH 32/32] fix docker compose Kratos public URL --- apps/hash-external-services/docker-compose.prod.yml | 2 +- apps/hash-external-services/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hash-external-services/docker-compose.prod.yml b/apps/hash-external-services/docker-compose.prod.yml index d627e8b8aaa..718bf008226 100644 --- a/apps/hash-external-services/docker-compose.prod.yml +++ b/apps/hash-external-services/docker-compose.prod.yml @@ -18,7 +18,7 @@ services: COOKIES_DOMAIN: "${KRATOS_COOKIE_DOMAIN}" COOKIES_SAME_SITE: "Lax" OAUTH2_PROVIDER_URL: "http://hydra:4445" - SERVE_PUBLIC_BASE_URL: "${FRONTEND_URL}/api/ory" + SERVE_PUBLIC_BASE_URL: "${FRONTEND_URL}" SERVE_PUBLIC_CORS_ALLOWED_HEADERS: "Authorization,Content-Type,X-Session-Token,X-CSRF-Token" SERVE_PUBLIC_CORS_ALLOWED_ORIGINS: "${FRONTEND_URL}" SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: "${FRONTEND_URL}/" diff --git a/apps/hash-external-services/docker-compose.yml b/apps/hash-external-services/docker-compose.yml index f547fa6820b..10f9797c91d 100644 --- a/apps/hash-external-services/docker-compose.yml +++ b/apps/hash-external-services/docker-compose.yml @@ -65,7 +65,7 @@ services: SECRETS_COOKIE: "VERY-INSECURE-AND-SHOULD-ONLY-BE-USED-IN-DEV" SECRETS_CIPHER: "32-LONG-SECRET-NOT-SECURE-AT-ALL" DSN: "postgres://${HASH_KRATOS_PG_USER}:${HASH_KRATOS_PG_PASSWORD}@postgres:${POSTGRES_PORT}/${HASH_KRATOS_PG_DATABASE}" - SERVE_PUBLIC_BASE_URL: "http://localhost:4433" + SERVE_PUBLIC_BASE_URL: "http://localhost:3000" SELFSERVICE_DEFAULT_BROWSER_RETURN_URL: "http://localhost:3000/" SELFSERVICE_ALLOWED_RETURN_URLS: "http://localhost:3000" SELFSERVICE_METHODS_LINK_CONFIG_BASE_URL: "http://localhost:3000"
- + Verify email address
-

+

+

This code expires in 48 hours. If you didn't create a HASH account, you can safely ignore this email.