Compare commits

..
Author SHA1 Message Date
Catalin Pit 976ad7fcd5 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-09-02 15:51:15 +03:00
Catalin Pit f4a7b4db0d Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-08-28 08:06:50 +03:00
Catalin Pit b152d11461 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-08-19 13:17:49 +03:00
Catalin Pit 0557ce3dce Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-08-17 10:51:48 +03:00
Catalin Pit 6b8eb9fc6a Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-16 08:43:44 +03:00
Catalin Pit 12d44e1c59 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-08 10:34:25 +03:00
Catalin Pit 9dc66afc7b Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-06 09:44:38 +03:00
Catalin Pit 7a13be6bf2 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-29 10:10:49 +03:00
Catalin Pit 1032028395 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-25 09:02:29 +03:00
Catalin Pit 2396d0d5d0 refactor: improve logger for wrong pdf placeholders 2026-06-19 13:26:54 +03:00
Catalin Pit dac262edc9 chore: add comment 2026-06-19 10:36:48 +03:00
Catalin Pit 25eb4ffedf refactor: simplify placeholder key-value parsing logic in PDF helpers 2026-06-19 08:43:22 +03:00
Catalin Pit ee0ea82635 chore: add tests 2026-06-18 15:06:15 +03:00
Catalin Pit 5f7f6698fd refactor: improve error handling and validation for PDF field metadata parsing 2026-06-18 14:03:21 +03:00
Catalin Pit 402809c809 refactor: add detailed docs for placeholder string parsing functions 2026-06-18 11:52:27 +03:00
Catalin Pit 733e273c05 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-18 09:32:18 +03:00
Catalin Pit 7c7933c2d4 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-17 11:50:50 +03:00
Catalin Pit bb120d65dd Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-05-27 12:50:34 +03:00
Catalin Pit 9c14aa6297 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-05-26 08:54:20 +03:00
Catalin Pit 6387e809a8 chore: merged main 2026-05-26 08:51:26 +03:00
Catalin Pit 5b2b1591a4 feat: enhance placeholder parsing and validation for selection fields in PDF helpers 2026-05-26 08:45:57 +03:00
Catalin Pit e5cb4c6bfd chore: refine dropdown placeholder support in PDF fields documentation and implementation 2026-05-22 11:27:18 +03:00
Catalin Pit 8185f916d3 chore: update plan and documentation 2026-05-22 09:00:46 +03:00
Catalin Pit c308dbf257 feat: support selection field options in PDF placeholders 2026-05-08 12:11:06 +03:00
349 changed files with 6181 additions and 12175 deletions
@@ -0,0 +1,222 @@
---
date: 2026-05-07
title: Pdf Placeholder Selection Fields
---
## Summary
Extend PDF placeholders so radio and dropdown fields can be configured from the existing Documenso placeholder syntax:
```text
{{FIELD_TYPE, RECIPIENT, key=value, key=value}}
```
Do not introduce a new delimiter style. Existing applications may already generate placeholders in this format, so the new selection-field behavior should fit into it.
## Goals
- Keep the current placeholder grammar unchanged.
- Support checkbox placeholders with option lists, checked values, validation, direction, required, read-only, and font size.
- Support radio placeholders with option lists, default/preselected values, direction, required, read-only, and font size.
- Support dropdown placeholders with option lists, default value, required, read-only, and font size.
- Use `options` as the only public list key in PDF placeholders.
- Convert `options` into internal `fieldMeta.values` during parsing.
- Make generated fields usable immediately in the editor, signing UI, preview renderer, and final PDF export.
## Non-Goals
- No semicolon placeholder syntax.
- No `values` alias in PDF placeholder syntax.
- No database migration.
- No behavior change for existing placeholders such as `{{text, r1, required=true}}`.
## Placeholder Syntax
Use the existing comma-separated placeholder format:
```text
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
{{radio, r1, options=Card|Bank transfer|Check, defaultValue=Check}}
{{radio, r1, options=Basic|Pro|Enterprise, selected=Pro, direction=horizontal}}
{{dropdown, r1, options=United States|Canada|United Kingdom}}
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
```
Use `|` inside `options` because `,` is already the top-level placeholder delimiter.
Parsing rules:
- Split top-level placeholder tokens on unescaped commas.
- Split metadata tokens on the first unescaped equals sign.
- Split `options` on unescaped pipes.
- Trim option values and drop empty values.
- Preserve option order.
- Support escaped delimiters: `\,`, `\=`, and `\|`.
- Treat field type values case-insensitively.
## Field Type Mapping
- `checkbox` maps to `FieldType.CHECKBOX`.
- `radio` maps to `FieldType.RADIO`.
- `dropdown` maps to `FieldType.DROPDOWN`.
## Metadata Mapping
### Checkbox
Example:
```text
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
```
Normalize to:
```ts
{
type: FieldType.CHECKBOX,
fieldMeta: {
type: 'checkbox',
validationRule: 'Select at least',
validationLength: 1,
values: [
{ id: 1, value: 'Email', checked: true },
{ id: 2, value: 'SMS', checked: false },
{ id: 3, value: 'Phone', checked: true },
],
},
}
```
Accepted keys:
- `options`
- `checked`
- `direction=vertical|horizontal`
- `validationRule=atLeast|exactly|atMost`
- `validationLength=1`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
Map checkbox validation aliases internally: `atLeast` -> `Select at least`, `exactly` -> `Select exactly`, `atMost` -> `Select at most`.
Checkbox placeholders do not support `label` or `placeholder` metadata.
### Radio
Example:
```text
{{radio, r1, options=Card|Bank transfer|Check, selected=Bank transfer}}
```
Normalize to:
```ts
{
type: FieldType.RADIO,
fieldMeta: {
type: 'radio',
values: [
{ id: 1, value: 'Card', checked: false },
{ id: 2, value: 'Bank transfer', checked: true },
{ id: 3, value: 'Check', checked: false },
],
},
}
```
Accepted keys:
- `options`
- `selected`, `default`, or `defaultValue`
- `direction=vertical|horizontal`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
Radio placeholders do not support `label` or `placeholder` metadata.
### Dropdown
Example:
```text
{{dropdown, r1, options=Sales|Legal|Finance, defaultValue=Legal}}
```
Normalize to:
```ts
{
type: FieldType.DROPDOWN,
fieldMeta: {
type: 'dropdown',
values: [{ value: 'Sales' }, { value: 'Legal' }, { value: 'Finance' }],
defaultValue: 'Legal',
},
}
```
Accepted keys:
- `options`
- `selected`, `default`, or `defaultValue`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
`defaultValue` should only be set if it matches one parsed option.
Dropdown placeholders do not support `label` or `placeholder` metadata.
## Code Touchpoints
- `packages/lib/server-only/pdf/helpers.ts`
- Extend `parseFieldMetaFromPlaceholder` so `options` normalizes into checkbox/radio/dropdown `fieldMeta.values`.
- Add delimiter-aware helpers for commas, equals signs, and pipes.
- `packages/lib/server-only/pdf/auto-place-fields.ts`
- Replace plain comma splitting with delimiter-aware splitting.
- Preserve the existing positional structure: field type, recipient, metadata.
- `packages/lib/types/field-meta.ts`
- Keep current internal schemas: checkbox/radio/dropdown still store options as `fieldMeta.values`.
- `packages/ui/primitives/document-flow/field-content.tsx`
- Display a radio fallback when a placeholder-created radio has no options.
- Docs:
- `apps/docs/content/docs/users/documents/advanced/pdf-placeholders.mdx`
- `apps/docs/content/docs/developers/api/fields.mdx`
## Test Plan
Unit tests:
- `options=Yes|No|Maybe` becomes stable radio values.
- `selected=No` marks only the matching radio option checked.
- Checkbox `options`, `checked`, `validationRule`, and `validationLength` normalize correctly.
- Dropdown `options` and `defaultValue` normalize correctly.
- Escaped delimiters parse correctly, for example `options=Sales\|Ops|Legal\, Compliance|A\=B`.
E2E/API tests:
- Add a PDF fixture with checkbox, radio, and dropdown placeholders using the current syntax.
- Verify created fields have schema-compatible metadata and expected options/defaults.
Suggested verification:
```bash
npm run test -w @documenso/lib -- server-only/pdf/helpers.test.ts
npm run test:dev -w @documenso/app-tests -- e2e/auto-placing-fields/auto-place-fields-document.spec.ts
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-fields.spec.ts
npx tsc --noEmit -p packages/lib/tsconfig.json
npx tsc --noEmit -p apps/remix/tsconfig.json
```
Do not use `npm run build` for routine verification unless explicitly requested.
## Decisions
- Keep the existing placeholder format.
- Use only `options` publicly.
- Keep `values` as an internal metadata field only.
- Use `|` as the option delimiter inside `options`.
+1 -1
View File
@@ -2,7 +2,7 @@ name: 'Setup node'
inputs:
node_version:
required: false
default: v24.x
default: v22.x
runs:
using: 'composite'
-20
View File
@@ -30,26 +30,6 @@ jobs:
- name: Build app
run: npm run build
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: ./.github/actions/node-install
- name: Copy env
run: cp .env.example .env
# Includes the 2FA enforcement drift guard (packages/trpc), which is the
# only check that catches a session route without enforcement middleware.
- name: Run unit tests
run: npm run test -w @documenso/lib -w @documenso/trpc
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
+1 -1
View File
@@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
- Node.js (v24 or above)
- Node.js (v22 or above)
- Postgres SQL Database
- Docker (optional)
@@ -51,7 +51,6 @@ async function createAndSendDocument(
pdfBuffer: Buffer,
filename: string,
title: string,
externalId: string,
recipients: Recipient[],
): Promise<CreateAndSendResult> {
const recipientPayload = recipients.map((recipient, index) => ({
@@ -90,7 +89,6 @@ async function createAndSendDocument(
JSON.stringify({
type: 'DOCUMENT',
title,
externalId,
recipients: recipientPayload,
meta: {
subject: `Please sign: ${title}`,
@@ -147,7 +145,6 @@ const result = await createAndSendDocument(
pdfBuffer,
'contract.pdf',
'Service Agreement',
'nda-contract-ndac214',
[
{ email: 'client@example.com', name: 'John Smith', role: 'SIGNER' },
{ email: 'manager@company.com', name: 'Jane Doe', role: 'SIGNER' },
@@ -175,7 +172,6 @@ ENVELOPE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/create" \
-F 'payload={
"type": "DOCUMENT",
"title": "Service Agreement",
"externalId": "nda-contract-ndac214",
"recipients": [
{
"email": "client@example.com",
@@ -245,8 +241,6 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}'
</Tab>
</Tabs>
`externalId` is your application's own reference for this document, such as an invoice number or a database key. Documenso stores it on the envelope and repeats it in every webhook as `payload.externalId`, so your handler can match the event to your record without keeping a lookup table of Documenso IDs. To react when everyone has signed, see [Workflow 4](#workflow-4-wait-for-completion-with-webhooks). To fetch the finished PDF, see [Workflow 5](#workflow-5-download-signed-documents).
---
## Workflow 2: Create Document from Template with Custom Data
@@ -9,7 +9,6 @@
"background-jobs",
"signing-certificate",
"telemetry",
"two-factor-enforcement",
"organisation-limits",
"advanced"
]
@@ -1,54 +0,0 @@
---
title: Two-Factor Enforcement
description: Require two-factor authentication instance-wide or per organisation, with grace periods and important limitations.
---
import { Callout } from 'fumadocs-ui/components/callout';
## Overview
Documenso can require users to enable two-factor authentication (2FA) at two levels:
- **Instance-wide enforcement** — configured by an instance administrator under **Admin → Site Settings**. Requires a Documenso license that includes the feature. Once a user's grace period expires, they are redirected to a forced enrolment page before they can continue using the app.
- **Per-organisation enforcement** — available to everyone, configured by organisation admins in the organisation settings. Once a member's grace period expires, only access to that organisation (and its teams) is blocked; the rest of the app stays usable.
A user satisfies enforcement when they have 2FA enabled **and** their current session has passed a second factor (a TOTP/backup-code challenge, a user-verified passkey sign-in, or enabling 2FA during the session). Sessions created before the user enabled 2FA must sign out and back in to verify.
## Grace Periods
Both levels support a grace period of 0–365 days:
- **Instance**: the window starts at the later of the user's grace start (typically account creation, restarted by an admin 2FA reset) and the moment enforcement was enabled.
- **Organisation**: the window starts at the latest of joining the organisation, the moment the organisation enabled enforcement, and the user's grace start.
A grace period of **0 days** enforces immediately: for the instance policy, users are forced to enrol right after signing up or signing in; for the organisation policy, members are blocked from the organisation until they enrol.
**Joining is never blocked; access is.** Invitations and SSO sign-ins always succeed — the grace window starts at join. Members who never comply still occupy a seat and count towards member limits; organisation admins can see per-member 2FA compliance in the members list.
Reducing an active grace period requires an explicit acknowledgement in the settings UI, since it can immediately block users who have not yet enrolled.
Enabling enforcement requires the acting administrator to already satisfy the policy themselves (2FA enabled and verified on their current session). This prevents administrators from locking themselves out with a 0-day grace period.
## Known Limitation: API Tokens Are Exempt
<Callout type="warn">
Enforcement applies to interactive (session-based) access only. **API tokens minted before a
user's deadline keep working after it.** A blocked user cannot mint new tokens, but existing
tokens are not revoked by enforcement. If you need to cut off a non-compliant user's API access,
revoke their tokens explicitly.
</Callout>
## Licensing
Instance-wide enforcement is license-gated:
- Without the license, the instance-wide section in Admin → Site Settings is visible but disabled.
- If enforcement was configured while licensed and the license later lapses, the stored configuration becomes **inactive** (nothing is enforced) and the only permitted change is disabling it.
Per-organisation enforcement does not require a license. When instance-wide enforcement is active, it takes precedence over organisation policies.
---
## See Also
- [License](/docs/self-hosting/configuration/license) - Configuring your Documenso license
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
## Prerequisites
- Node.js 24 or later
- npm 11.17 or later
- Node.js 22 or later
- npm 11 or later
- PostgreSQL 14 or later
- A Linux server (for systemd service setup)
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
| Requirement | Version |
| ----------- | ------- |
| Node.js | 24+ |
| npm | 11.17+ |
| Node.js | 22+ |
| npm | 11+ |
---
@@ -169,7 +169,7 @@ Documenso runs on:
| MySQL/MariaDB | PostgreSQL-specific features required |
| SQLite | Not suitable for production workloads |
| MongoDB | Relational database required |
| Node.js < 24 | Modern JavaScript features required |
| Node.js < 22 | Modern JavaScript features required |
---
@@ -109,6 +109,37 @@ You can customize fields by adding options after the recipient identifier:
| `maxValue` | Number | Maximum allowed value |
| `numberFormat` | Format string | Number display format |
### Selection Field Options
Checkbox, radio, and dropdown placeholders can define their selectable choices via the `options` property.
Separate choices with pipe (`|`) characters.
Checkbox, radio, and dropdown placeholders do not support `label` or `placeholder` metadata.
| Option | Applies To | Values | Description |
| ------------------ | ------------------------- | ------------------------ | ---------------------------------------- |
| `options` | Checkbox, Radio, Dropdown | `Option 1|Option 2` | Selectable choices |
| `checked` | Checkbox | `Option 1|Option 2` | Pre-checked choices |
| `selected` | Radio, Dropdown | One option value | Pre-selected/default choice |
| `default` | Radio, Dropdown | One option value | Alias for `selected` |
| `defaultValue` | Radio, Dropdown | One option value | Alias for `selected` |
| `direction` | Checkbox, Radio | `vertical`, `horizontal` | Option layout |
| `validationRule` | Checkbox | `atLeast`, `exactly`, `atMost` | Checkbox selection validation rule |
| `validationLength` | Checkbox | Number (e.g., `1`) | Checkbox validation option count |
| `required` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the field must be completed |
| `readOnly` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the pre-selected value is locked |
| `fontSize` | Checkbox, Radio, Dropdown | Number (e.g., `12`) | Field text size |
For checkbox validation, `validationLength` defines the option count:
- `atLeast` means at least that many options must be selected
- `exactly` means exactly that many options must be selected
- `atMost` means at most that many options must be selected
If an option needs a literal delimiter, escape it with a backslash:
```
{{dropdown, r1, options=Sales\|Ops|Legal\, Compliance|A\=B}}
```
### Examples with Options
```
@@ -116,6 +147,10 @@ You can customize fields by adding options after the recipient identifier:
{{number, r1, minValue=0, maxValue=100, value=50}}
{{name, r1, fontSize=14}}
{{text, r2, readOnly=true, text=Contract #12345}}
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
{{radio, r1, options=Card|Bank transfer|Check, selected=Check}}
{{dropdown, r1, options=United States|Canada|United Kingdom}}
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
```
<Callout type="info">
+1 -1
View File
@@ -15,7 +15,7 @@
"fumadocs-ui": "16.14.3",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "^16.3.3",
"next": "16.3.0",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "^16.3.3"
"next": "16.3.0"
},
"devDependencies": {
"@types/node": "^20",
+25
View File
@@ -0,0 +1,25 @@
FROM oven/bun:1 AS dependencies-env
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --production
FROM dependencies-env AS build-env
COPY ./package.json bun.lockb /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build
FROM dependencies-env
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "run", "start"]
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-alpine AS dependencies-env
RUN npm i -g pnpm
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile
FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build
FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]
@@ -56,7 +56,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentication for this user.`,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
)
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
@@ -85,8 +85,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will disable two factor
authentication for the user. Their two-factor enforcement grace period will restart from the moment of the
reset.
authentication for the user.
</Trans>
</AlertDescription>
</div>
@@ -109,8 +108,7 @@ export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserRese
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before proceeding. Any
two-factor enforcement grace period for this user will restart from the moment of the reset.
This action is irreversible. Please ensure you have informed the user before proceeding.
</Trans>
</AlertDescription>
</Alert>
@@ -1,7 +1,4 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@@ -18,11 +15,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -34,8 +29,6 @@ const ZBulkSendFormSchema = z.object({
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
@@ -49,9 +42,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
@@ -61,20 +51,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) {
return;
}
setOpen(value);
if (!value) {
setValidationError(null);
form.reset();
}
};
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
@@ -95,44 +71,36 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
};
const onSubmit = async (values: TBulkSendFormSchema) => {
setValidationError(null);
try {
const csv = await values.file.text();
const result = await uploadBulkSend({
await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
if (!result.success) {
setValidationError(result.error);
return;
}
toast({
title: _(msg`Success`),
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
});
setOpen(false);
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
const error = AppError.parseError(err);
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
@@ -206,10 +174,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValidationError(null);
onChange(file);
}
}}
@@ -230,11 +195,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => {
setValidationError(null);
form.resetField('file');
}}
onClick={() => onChange(null)}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
@@ -257,72 +218,6 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
)}
/>
{validationError !== null && (
<Alert variant="destructive">
<AlertDescription className="max-h-32 overflow-y-auto">
{match(validationError)
.with({ type: 'PARSE_ERROR' }, () => (
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
))
.with({ type: 'EMPTY' }, () => (
<Trans>
The CSV does not contain any rows. Please add at least one row of recipient details.
</Trans>
))
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
<Trans>
<Plural value={rowCount} one="The CSV contains # row." other="The CSV contains # rows." />{' '}
<Plural
value={maxRows}
one="A maximum of # row is allowed per upload."
other="A maximum of # rows is allowed per upload."
/>
</Trans>
))
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
<>
<Trans>
The CSV is missing the following required columns. Please download the template CSV for the
correct format.
</Trans>
<ul className="mt-1 list-inside list-disc">
{missingColumns.map((column) => (
<li key={column} className="font-mono">
{column}
</li>
))}
</ul>
</>
))
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
<>
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
<ul className="mt-1 list-inside list-disc">
{rowErrors.map((rowError, index) => (
<li key={index}>
<Trans>
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
email or empty
</Trans>
</li>
))}
</ul>
</>
))
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
code === AppErrorCode.LIMIT_EXCEEDED ? (
<Trans>The CSV exceeds the maximum file size.</Trans>
) : (
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
),
)
.exhaustive()}
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="sendImmediately"
@@ -345,12 +240,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>
<DialogFooter className="mt-4">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Trans>Cancel</Trans>
</Button>
@@ -18,8 +18,6 @@ import { useCallback, useRef } from 'react';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
import { useCspNonce } from '~/utils/nonce';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -34,7 +32,6 @@ export interface ConfigureDocumentRecipientsProps {
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
const cspNonce = useCspNonce();
const $sensorApi = useRef<SensorAPI | null>(null);
@@ -215,7 +212,6 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
/>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -1,6 +1,5 @@
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -69,23 +68,15 @@ export const DisableAuthenticatorAppDialog = () => {
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
// Todo: (2FA enforcement, step 5) Once org enforcement state is available
// client-side, warn BEFORE disabling that org/team access will block at the
// org 2FA deadline. Until then the warning is shown after the fact based on
// the server response.
const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => {
try {
const { orgEnforcementApplies } = await authClient.twoFactor.disable({ totpCode, backupCode });
await authClient.twoFactor.disable({ totpCode, backupCode });
toast({
title: _(msg`Two-factor authentication disabled`),
description: orgEnforcementApplies
? _(
msg`Two-factor authentication has been disabled for your account. One of your organisations requires two-factor authentication: access to it will be blocked at its deadline until you re-enable 2FA.`,
)
: _(
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
),
description: _(
msg`Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.`,
),
});
flushSync(() => {
@@ -93,19 +84,7 @@ export const DisableAuthenticatorAppDialog = () => {
});
await refreshSession();
} catch (err) {
const error = AppError.parseError(err);
if (error.code === 'TWO_FACTOR_DISABLE_FORBIDDEN') {
toast({
title: _(msg`Unable to disable two-factor authentication`),
description: _(msg`Two-factor authentication is required by this instance and cannot be disabled.`),
variant: 'destructive',
});
return;
}
} catch (_err) {
toast({
title: _(msg`Unable to disable two-factor authentication`),
description: _(
@@ -1,7 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -70,18 +69,11 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
setSetup2FAData(data);
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Unable to setup two-factor authentication`),
description:
error.code === 'TWO_FACTOR_ALREADY_ENABLED'
? _(
msg`Two-factor authentication is already enabled for your account. Disable it before setting it up again.`,
)
: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
}
@@ -1,158 +0,0 @@
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { Trans } from '@lingui/react/macro';
import type React from 'react';
import { useState } from 'react';
import { type FieldValues, type Path, useFormContext } from 'react-hook-form';
import { z } from 'zod';
/**
* Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`.
*/
export const ZTwoFactorCodeFieldSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TTwoFactorCodeFieldSchema = z.infer<typeof ZTwoFactorCodeFieldSchema>;
export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode;
type TwoFactorMethod = 'totp' | 'backup';
export type TwoFactorCodeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
isSubmitting?: boolean;
submitLabel: React.ReactNode;
/**
* Called when the user submits the code. Typically the parent form's submit handler.
*/
onSubmit: () => void;
};
/**
* Collects a TOTP or backup code on top of an existing form, mirroring the
* sign in and disable 2FA dialogs.
*
* Must be rendered inside a `<Form>` whose values include `totpCode` and `backupCode`.
*/
export const TwoFactorCodeDialog = <T extends FieldValues & TTwoFactorCodeFieldSchema>({
open,
onOpenChange,
isSubmitting,
submitLabel,
onSubmit,
}: TwoFactorCodeDialogProps) => {
const form = useFormContext<T>();
const [method, setMethod] = useState<TwoFactorMethod>('totp');
const totpCodeName = 'totpCode' as Path<T>;
const backupCodeName = 'backupCode' as Path<T>;
const onToggleMethod = () => {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod((current) => (current === 'totp' ? 'backup' : 'totp'));
};
const handleOpenChange = (value: boolean) => {
if (isSubmitting) {
return;
}
if (!value) {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod('totp');
}
onOpenChange(value);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Two-Factor Authentication</Trans>
</DialogTitle>
<DialogDescription>
{method === 'totp' ? (
<Trans>Enter the code from your authenticator app to continue.</Trans>
) : (
<Trans>Enter one of your backup codes to continue.</Trans>
)}
</DialogDescription>
</DialogHeader>
<fieldset disabled={isSubmitting}>
{method === 'totp' && (
<FormField
control={form.control}
name={totpCodeName}
render={({ field }) => (
<FormItem>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6} autoFocus>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'backup' && (
<FormField
control={form.control}
name={backupCodeName}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" autoComplete="off" autoFocus {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={onToggleMethod}>
{method === 'totp' ? <Trans>Use Backup Code</Trans> : <Trans>Use Authenticator</Trans>}
</Button>
<Button type="button" loading={isSubmitting} onClick={onSubmit}>
{submitLabel}
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -1,282 +0,0 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZTwoFactorEnforcementFormSchema = z.object({
twoFactorRequired: z.boolean(),
twoFactorGracePeriodDays: z.coerce.number().int().min(0).max(365),
acknowledgeGracePeriodReduction: z.boolean(),
});
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
/**
* Organisation 2FA enforcement settings (require toggle + grace period).
*
* Rendered only for MANAGE_ORGANISATION_SECURITY holders (ADMIN). When
* instance-wide enforcement is active the fields are shown disabled — not
* hidden — with a banner explaining that the instance policy takes
* precedence, so a configured organisation policy stays visible instead of
* resurfacing already-expired later.
*/
export const OrganisationTwoFactorEnforcementForm = () => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { twoFactorEnforcement: instanceTwoFactorEnforcement } = useSession();
const isInstanceEnforcementActive = instanceTwoFactorEnforcement.required;
const { data: organisationWithSettings, isLoading } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const utils = trpc.useUtils();
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const settings = organisationWithSettings?.organisationGlobalSettings;
const form = useForm<TTwoFactorEnforcementFormSchema>({
values: {
twoFactorRequired: settings?.twoFactorRequired ?? false,
twoFactorGracePeriodDays: settings?.twoFactorGracePeriodDays ?? 7,
acknowledgeGracePeriodReduction: false,
},
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
});
const watchedValues = form.watch();
// Client-side mirror of the server's grace-reduction detection so we can
// surface the acknowledgement checkbox before submitting.
const isGraceReduction =
settings !== undefined &&
isTwoFactorGracePeriodReduction({
previous: settings.twoFactorRequired
? {
anchors: [settings.twoFactorEnforcedFrom],
gracePeriodDays: settings.twoFactorGracePeriodDays,
}
: null,
next: watchedValues.twoFactorRequired
? {
anchors: [settings.twoFactorRequired ? settings.twoFactorEnforcedFrom : new Date()],
gracePeriodDays: watchedValues.twoFactorGracePeriodDays,
}
: null,
now: new Date(),
});
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
try {
await updateOrganisationSettings({
organisationId: organisation.id,
acknowledgeGracePeriodReduction: data.acknowledgeGracePeriodReduction,
data: {
twoFactorRequired: data.twoFactorRequired,
twoFactorGracePeriodDays: data.twoFactorGracePeriodDays,
},
});
await utils.organisation.get.invalidate();
toast({
title: _(msg`Two-factor enforcement settings updated`),
});
form.setValue('acknowledgeGracePeriodReduction', false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
toast({
title: _(msg`Two-factor authentication required`),
description: _(
msg`You must have two-factor authentication enabled and verified on this session before requiring it for the organisation.`,
),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
variant: 'destructive',
});
}
};
if (isLoading || !settings) {
return (
<div className="flex justify-center rounded-lg border py-16">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isInstanceEnforcementActive}
className="flex flex-col gap-y-4"
>
{isInstanceEnforcementActive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Instance policy takes precedence</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Two-factor authentication is enforced instance-wide by your administrator, so the organisation policy
below is not editable while the instance policy is active.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="twoFactorRequired"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Members must enable two-factor authentication to access this organisation. Joining is never
blocked — the grace period starts when a member joins.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="twoFactorGracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a member has to enable two-factor authentication after joining. 0 blocks organisation
access immediately until they enrol.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{settings.twoFactorRequired && settings.twoFactorEnforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since {i18n.date(settings.twoFactorEnforcedFrom, { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<ul className="list-disc space-y-1 pl-4">
<li>
<Trans>
API tokens are exempt: tokens minted before a member's deadline keep working after it. Blocked
members cannot mint new tokens.
</Trans>
</li>
<li>
<Trans>
Members who have not yet complied still occupy a seat and count towards your member limit.
</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Members who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their organisation access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for members</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -1,53 +0,0 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CheckIcon } from 'lucide-react';
import { match } from 'ts-pattern';
/**
* Compact "send me a setup link" button that reports via toast, for settings
* cards where the surrounding layout provides the explanation.
*/
export const PasswordSetupRequestButton = () => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess } = usePasswordSetupRequest({
onSuccess: () => {
toast({
title: _(msg`Check your email`),
description: _(msg`We've sent a link to ${user.email}. Follow it to set your password.`),
duration: 5000,
});
},
onError: (errorCode) => {
toast({
title: _(msg`An error occurred`),
description: match(errorCode)
.with('SIGNIN_DISABLED', () => _(msg`Password sign in is disabled for this instance.`))
.otherwise(() => _(msg`We were unable to send the email. Please try again later.`)),
variant: 'destructive',
});
},
});
if (isSuccess) {
return (
<Button variant="outline" className="flex-shrink-0 bg-background" disabled>
<CheckIcon className="mr-2 h-4 w-4" />
<Trans>Link sent</Trans>
</Button>
);
}
return (
<Button variant="outline" className="flex-shrink-0 bg-background" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
);
};
@@ -1,61 +0,0 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
export type PasswordSetupRequestProps = {
className?: string;
};
/**
* Inline "send me a setup link" control with its own sent/error states, for
* contexts like dialogs where a toast would be missed.
*/
export const PasswordSetupRequest = ({ className }: PasswordSetupRequestProps) => {
const { _ } = useLingui();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess, errorCode } = usePasswordSetupRequest();
if (isSuccess) {
return (
<Alert className={className} variant="neutral">
<AlertTitle>
<Trans>Check your email</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue.
</Trans>
</AlertDescription>
</Alert>
);
}
return (
<div className={className}>
{errorCode && (
<Alert className="mb-4" variant="destructive">
<AlertTitle>
<Trans>An error occurred</Trans>
</AlertTitle>
<AlertDescription>
{match(errorCode)
.with('SIGNIN_DISABLED', () =>
_(msg`Password sign in is disabled for this instance. Please contact support.`),
)
.otherwise(() => _(msg`We were unable to send the email. Please try again or contact support.`))}
</AlertDescription>
</Alert>
)}
<Button type="button" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
</div>
);
};
+18 -59
View File
@@ -1,6 +1,6 @@
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -11,21 +11,20 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { z } from 'zod';
import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog';
export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
}).refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
export const ZPasswordFormSchema = z
.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
@@ -34,51 +33,29 @@ export type PasswordFormProps = {
user: SessionUser;
};
export const PasswordForm = ({ className, user }: PasswordFormProps) => {
export const PasswordForm = ({ className }: PasswordFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false);
const form = useForm<TPasswordFormSchema>({
values: {
currentPassword: '',
password: '',
repeatedPassword: '',
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async (values: TPasswordFormSchema) => {
const { currentPassword, password, totpCode, backupCode } = values;
// Collect the 2FA code in a dialog once the password fields are valid.
if (user.twoFactorEnabled && !hasTwoFactorCode(values)) {
if (isTwoFactorDialogOpen) {
const message = _(msg`A code is required`);
form.setError('totpCode', { message });
form.setError('backupCode', { message });
}
setIsTwoFactorDialogOpen(true);
return;
}
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
try {
await authClient.emailPassword.updatePassword({
currentPassword,
password,
totpCode: totpCode || undefined,
backupCode: backupCode || undefined,
});
form.reset();
setIsTwoFactorDialogOpen(false);
toast({
title: _(msg`Password updated`),
@@ -89,14 +66,9 @@ export const PasswordForm = ({ className, user }: PasswordFormProps) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`)
.with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`)
.with(AppErrorCode.SAME_PASSWORD, () => msg`Your new password cannot be the same as your old password.`)
.with(
AppErrorCode.INCORRECT_TWO_FACTOR_CODE,
AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS,
() => msg`The two factor code you provided is invalid. Please try again.`,
)
.with('NO_PASSWORD', () => msg`User has no password.`)
.with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`)
.with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`)
.otherwise(
() => msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
);
@@ -111,12 +83,7 @@ export const PasswordForm = ({ className, user }: PasswordFormProps) => {
return (
<Form {...form}>
{/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */}
<form
method="post"
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
@@ -173,14 +140,6 @@ export const PasswordForm = ({ className, user }: PasswordFormProps) => {
</Button>
</div>
</form>
<TwoFactorCodeDialog<TPasswordFormSchema>
open={isTwoFactorDialogOpen}
onOpenChange={setIsTwoFactorDialogOpen}
isSubmitting={isSubmitting}
submitLabel={<Trans>Update password</Trans>}
onSubmit={form.handleSubmit(onFormSubmit)}
/>
</Form>
);
};
@@ -1,332 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTwoFactorGracePeriodReduction } from '@documenso/lib/utils/two-factor';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { LoaderIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZTwoFactorEnforcementFormSchema = z.object({
enabled: z.boolean(),
gracePeriodDays: z.coerce.number().int().min(0).max(365),
acknowledgeGracePeriodReduction: z.boolean(),
});
type TTwoFactorEnforcementFormSchema = z.infer<typeof ZTwoFactorEnforcementFormSchema>;
/**
* Instance-wide 2FA enforcement settings for the admin site-settings page.
*
* License-gated states:
*
* - Licensed: full form.
* - Unlicensed + unconfigured: section visible but disabled with a "requires
* license" note.
* - Unlicensed + configured ("configured but inactive", e.g. license lapsed):
* stored values shown read-only with a disable-only affordance — the only
* permitted unlicensed update is turning the stored policy off.
*/
export const AdminTwoFactorEnforcementSection = () => {
const { _, i18n } = useLingui();
const { toast } = useToast();
const { data: enforcementConfig, isLoading } = trpc.admin.getTwoFactorEnforcement.useQuery();
const utils = trpc.useUtils();
const { mutateAsync: updateTwoFactorEnforcement, isPending: isUpdatePending } =
trpc.admin.updateTwoFactorEnforcement.useMutation();
const form = useForm<TTwoFactorEnforcementFormSchema>({
values: {
enabled: enforcementConfig?.enabled ?? false,
gracePeriodDays: enforcementConfig?.gracePeriodDays ?? 7,
acknowledgeGracePeriodReduction: false,
},
resolver: zodResolver(ZTwoFactorEnforcementFormSchema),
});
const watchedValues = form.watch();
const isLicensed = enforcementConfig?.isLicensed ?? false;
const isConfiguredButInactive = !isLicensed && (enforcementConfig?.enabled ?? false);
// Client-side mirror of the server's grace-reduction detection so we can
// surface the acknowledgement checkbox before submitting. The server resets
// `enforcedFrom` to now on an off→on transition, hence the `new Date()`
// anchor when the stored policy is currently disabled.
const isGraceReduction =
enforcementConfig !== undefined &&
isTwoFactorGracePeriodReduction({
previous: enforcementConfig.enabled
? {
anchors: [enforcementConfig.enforcedFrom ? new Date(enforcementConfig.enforcedFrom) : null],
gracePeriodDays: enforcementConfig.gracePeriodDays,
}
: null,
next: watchedValues.enabled
? {
anchors: [
enforcementConfig.enabled && enforcementConfig.enforcedFrom
? new Date(enforcementConfig.enforcedFrom)
: new Date(),
],
gracePeriodDays: watchedValues.gracePeriodDays,
}
: null,
now: new Date(),
});
const onUpdate = async (data: {
enabled: boolean;
gracePeriodDays: number;
acknowledgeGracePeriodReduction?: boolean;
}) => {
try {
await updateTwoFactorEnforcement(data);
await utils.admin.getTwoFactorEnforcement.invalidate();
toast({
title: _(msg`Two-factor enforcement settings updated`),
});
form.setValue('acknowledgeGracePeriodReduction', false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.TWO_FACTOR_REQUIRED) {
toast({
title: _(msg`Two-factor authentication required`),
description: _(
msg`Enable two-factor authentication on your own account and verify it on this session before requiring it for the instance.`,
),
variant: 'destructive',
});
return;
}
if (error.code === AppErrorCode.FORBIDDEN) {
toast({
title: _(msg`License required`),
description: _(
msg`Your license does not include instance-wide two-factor enforcement. Only disabling the stored configuration is permitted.`,
),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to update the two-factor enforcement settings. Please try again.`),
variant: 'destructive',
});
}
};
const onSubmit = async (data: TTwoFactorEnforcementFormSchema) => {
await onUpdate(data);
};
// Disable-only affordance for the "configured but inactive" state: submits
// an enabled→disabled transition with the stored values unchanged, which is
// the only unlicensed update the server accepts.
const onDisableOnly = async () => {
if (!enforcementConfig) {
return;
}
await onUpdate({
enabled: false,
gracePeriodDays: enforcementConfig.gracePeriodDays,
});
};
return (
<div>
<h2 className="font-semibold">
<Trans>Instance Two-Factor Enforcement</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Require every user on this instance, including administrators, to enable two-factor authentication within a
grace period.
</Trans>
</p>
{isLoading || !enforcementConfig ? (
<div className="mt-4 flex justify-center rounded-lg border py-16">
<LoaderIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting || !isLicensed} className="mt-4 flex flex-col gap-y-4">
{!isLicensed && !isConfiguredButInactive && (
<Alert variant="neutral">
<AlertTitle>
<Trans>Requires a license</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Instance-wide two-factor enforcement requires a Documenso license that includes this feature.
</Trans>
</AlertDescription>
</Alert>
)}
{isConfiguredButInactive && (
<Alert variant="warning">
<AlertTitle>
<Trans>Configured but inactive</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Two-factor enforcement is configured but your current license does not include this feature, so it
is not being enforced. You can disable the stored configuration below; changing it requires a
license.
</Trans>
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<FormLabel>
<Trans>Require two-factor authentication</Trans>
</FormLabel>
<FormDescription>
<Trans>
Users who have not enabled two-factor authentication by their deadline are redirected to a
forced enrolment page before they can continue.
</Trans>
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="gracePeriodDays"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grace period (days)</Trans>
</FormLabel>
<FormControl>
<Input type="number" min={0} max={365} {...field} />
</FormControl>
<FormDescription>
<Trans>
Number of days a user has to enable two-factor authentication. 0 forces enrolment immediately
after signing up or signing in.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{enforcementConfig.isActive && enforcementConfig.enforcedFrom && (
<p className="text-muted-foreground text-sm">
<Trans>
Enforcement has been active since{' '}
{i18n.date(new Date(enforcementConfig.enforcedFrom), { dateStyle: 'long' })}.
</Trans>
</p>
)}
<Alert variant="neutral">
<AlertDescription>
<Trans>
API tokens are exempt: tokens minted before a user's deadline keep working after it. Blocked users
cannot mint new tokens.
</Trans>
</AlertDescription>
</Alert>
{isGraceReduction && (
<Alert variant="warning">
<AlertTitle>
<Trans>This change reduces an active grace period</Trans>
</AlertTitle>
<AlertDescription className="flex flex-col gap-y-3">
<Trans>
Users who have not yet enabled two-factor authentication will have less time to comply — possibly
none, which blocks their access immediately.
</Trans>
<FormField
control={form.control}
name="acknowledgeGracePeriodReduction"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
</FormControl>
<FormLabel className="font-normal">
<Trans>I understand that this reduces the remaining grace period for users</Trans>
</FormLabel>
</FormItem>
)}
/>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isGraceReduction && !watchedValues.acknowledgeGracePeriodReduction}
>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
{isConfiguredButInactive && (
<div className="mt-4 flex justify-end">
<Button type="button" variant="destructive" loading={isUpdatePending} onClick={onDisableOnly}>
<Trans>Disable enforcement</Trans>
</Button>
</div>
)}
</form>
</Form>
)}
</div>
);
};
@@ -1,7 +1,5 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -9,13 +7,11 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password';
export type DocumentSigningAuthPasswordProps = {
open: boolean;
@@ -39,12 +35,8 @@ export const DocumentSigningAuthPassword = ({
}: DocumentSigningAuthPasswordProps) => {
const { t } = useLingui();
const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Fetched on demand since this is only needed once the user opts for password auth.
const { data: authMethodsData, isPending: isAuthMethodsPending } = trpc.auth.getAuthMethods.useQuery(undefined, {
enabled: !!user,
});
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const form = useForm<TPasswordAuthFormSchema>({
resolver: zodResolver(ZPasswordAuthFormSchema),
@@ -55,10 +47,6 @@ export const DocumentSigningAuthPassword = ({
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
// If the query fails we fall through to the regular password form rather than blocking.
const isPasswordSetupRequired =
!!user && !!authMethodsData && !authMethodsData.authMethods.includes(UserAuthMethod.PASSWORD);
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
@@ -76,6 +64,8 @@ export const DocumentSigningAuthPassword = ({
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
@@ -89,22 +79,9 @@ export const DocumentSigningAuthPassword = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (user && isAuthMethodsPending) {
return (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (isPasswordSetupRequired) {
return <DocumentSigningAuthSetPassword onOpenChange={onOpenChange} />;
}
return (
<Form {...form}>
{/* method="post" so a pre-hydration native submit can't leak the password into the URL. */}
<form method="post" onSubmit={form.handleSubmit(onFormSubmit)}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
{formErrorCode && (
@@ -1,63 +0,0 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { PasswordSetupRequest } from '~/components/forms/password-setup-request';
export type DocumentSigningAuthSetPasswordProps = {
onOpenChange: (value: boolean) => void;
};
/**
* Shown in place of the password reauth form when the signed in user has no
* password (e.g. they signed up via OAuth or a passkey).
*
* Password based action auth is meant to prove more than possession of a session,
* so rather than letting the session set a password inline we send the user the
* verified reset link and ask them to come back.
*/
export const DocumentSigningAuthSetPassword = ({ onOpenChange }: DocumentSigningAuthSetPasswordProps) => {
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
return (
<div className="space-y-4">
{isEmailPasswordSigninEnabled ? (
<>
<Alert variant="neutral">
<AlertTitle>
<Trans>No password set</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Signing this field requires a password, but your account does not have one. We can email you a link to
set one. Once done, sign in again and return to this document to continue.
</Trans>
</AlertDescription>
</Alert>
<PasswordSetupRequest />
</>
) : (
<Alert variant="warning">
<AlertTitle>
<Trans>Password authentication unavailable</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Your account does not have a password and password sign in is disabled for this instance. Please contact
the document sender to use a different authentication method.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</div>
);
};
@@ -28,7 +28,6 @@ import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type DocumentEditFormProps = {
@@ -43,7 +42,6 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const cspNonce = useCspNonce();
const navigate = useNavigate();
@@ -475,7 +473,6 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddFieldsFormPartial
@@ -45,7 +45,6 @@ import { isDeepEqual } from 'remeda';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
@@ -53,7 +52,6 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const cspNonce = useCspNonce();
const { t } = useLingui();
const { toast } = useToast();
@@ -797,7 +795,6 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -26,7 +26,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { useCspNonce } from '~/utils/nonce';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
@@ -43,7 +42,6 @@ type LocalFile = {
export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const cspNonce = useCspNonce();
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
@@ -496,7 +494,7 @@ export const EnvelopeEditorUploadPage = () => {
{/* Uploaded Files List */}
<div className="mt-4">
<DragDropContext nonce={cspNonce} onDragEnd={onDragEnd}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div
@@ -25,7 +25,6 @@ import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
export type TemplateEditFormProps = {
className?: string;
@@ -39,7 +38,6 @@ const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const TemplateEditForm = ({ initialTemplate, className, templateRootPath }: TemplateEditFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const cspNonce = useCspNonce();
const navigate = useNavigate();
const team = useCurrentTeam();
@@ -341,7 +339,6 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
onSubmit={onAddTemplatePlaceholderFormSubmit}
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddTemplateFieldsFormPartial
@@ -1,144 +0,0 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, XIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router';
type GraceBannerCandidate = {
/**
* Dismissal scope: `instance` or `org:<organisationId>`.
*/
scope: string;
deadline: Date;
isSessionUnverified: boolean;
};
const buildDismissalKey = (userId: number, candidate: GraceBannerCandidate) =>
`2fa-grace-banner:${userId}:${candidate.scope}:${candidate.deadline.getTime()}`;
const toCandidate = (
status: TTwoFactorEnforcementStatus,
scope: string,
isSessionUnverified: boolean,
): GraceBannerCandidate | null => {
// Banner territory is the grace window only: required, not yet satisfied,
// not yet expired. Expiry is handled by the org 403 screen (and, for
// instance enforcement, the onboarding redirect).
if (!status.required || status.isSatisfied || status.isDeadlineExpired) {
return null;
}
return {
scope,
deadline: status.deadline,
isSessionUnverified,
};
};
/**
* Shared grace-period banner for 2FA enforcement.
*
* Shows the NEAREST applicable deadline between instance enforcement and the
* current organisation's enforcement. Dismissal is stored in `sessionStorage`
* keyed by userId + scope + deadline, so a changed deadline re-shows the
* banner.
*/
export const TwoFactorGraceBanner = () => {
const { i18n } = useLingui();
const { sessionData } = useOptionalSession();
const currentOrganisation = useOptionalCurrentOrganisation();
const location = useLocation();
const [dismissedKeys, setDismissedKeys] = useState<string[]>([]);
const candidate = useMemo(() => {
if (!sessionData) {
return null;
}
const isSessionUnverified = sessionData.user.twoFactorEnabled && !sessionData.session.twoFactorVerified;
const candidates = [
toCandidate(sessionData.twoFactorEnforcement, 'instance', isSessionUnverified),
currentOrganisation
? toCandidate(currentOrganisation.twoFactorEnforcement, `org:${currentOrganisation.id}`, isSessionUnverified)
: null,
].filter((value): value is GraceBannerCandidate => value !== null);
if (candidates.length === 0) {
return null;
}
return candidates.reduce((nearest, current) =>
current.deadline.getTime() < nearest.deadline.getTime() ? current : nearest,
);
}, [sessionData, currentOrganisation]);
if (!sessionData || !candidate) {
return null;
}
const dismissalKey = buildDismissalKey(sessionData.user.id, candidate);
const isDismissed =
dismissedKeys.includes(dismissalKey) ||
(typeof window !== 'undefined' && window.sessionStorage.getItem(dismissalKey) === 'true');
if (isDismissed) {
return null;
}
const onDismiss = () => {
try {
window.sessionStorage.setItem(dismissalKey, 'true');
} catch {
// Storage may be unavailable (private browsing); fall back to state.
}
setDismissedKeys((keys) => [...keys, dismissalKey]);
};
const returnTo = encodeURIComponent(`${location.pathname}${location.search}`);
return (
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-between gap-x-4 px-4 py-2 font-medium text-sm text-yellow-900">
<div className="flex items-center gap-x-2">
<AlertTriangleIcon className="h-4 w-4 flex-shrink-0" />
<span>
{candidate.isSessionUnverified ? (
<Trans>
Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.
Two-factor authentication is enabled for your account, but this session has not been verified with a
second factor — sign out and log back in to verify this session.
</Trans>
) : (
<Trans>
Two-factor authentication is required from {i18n.date(candidate.deadline, { dateStyle: 'long' })}.{' '}
<Link to={`/onboarding/2fa?returnTo=${returnTo}`} className="underline">
Enable it now
</Link>{' '}
to keep access.
</Trans>
)}
</span>
</div>
<button
type="button"
className="rounded p-1 hover:bg-yellow-300 dark:hover:bg-yellow-500"
aria-label="Dismiss"
onClick={onDismiss}
>
<XIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
@@ -6,7 +6,6 @@ import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/orga
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -101,23 +100,6 @@ export const OrganisationMembersDataTable = () => {
header: _(msg`Groups`),
cell: ({ row }) => row.original.groups.filter((group) => group.type === OrganisationGroupType.CUSTOM).length,
},
{
// Per-member 2FA compliance indicator: enrolment is the durable half
// of the satisfaction rule, so org admins can see who has and hasn't
// enrolled (non-compliant members still consume seats).
header: _(msg`2FA`),
accessorKey: 'twoFactorEnabled',
cell: ({ row }) =>
row.original.twoFactorEnabled ? (
<Badge variant="default">
<Trans>Enrolled</Trans>
</Badge>
) : (
<Badge variant="neutral">
<Trans>Not enrolled</Trans>
</Badge>
),
},
{
header: _(msg`Actions`),
cell: ({ row }) => (
+3 -4
View File
@@ -7,11 +7,10 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { EntryContext, RouterContextProvider } from 'react-router';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
import { langCookie } from './storage/lang-cookie.server';
import { nonceContext } from './utils/nonce';
export const streamTimeout = 5_000;
@@ -20,7 +19,7 @@ export default async function handleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: RouterContextProvider,
loadContext: AppLoadContext,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
@@ -34,7 +33,7 @@ export default async function handleRequest(
// scripts it injects (route manifest, hydration data, module preloads).
// The same nonce is also exposed to the React tree via the root loader so
// our own inline scripts/styles can carry it.
const nonce = loadContext.get(nonceContext) || undefined;
const nonce = loadContext.nonce || undefined;
return new Promise((resolve, reject) => {
let shellRendered = false;
-13
View File
@@ -1,13 +0,0 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { type MiddlewareFunction, redirect } from 'react-router';
export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => {
const { user } = await getOptionalSession(request);
if (!user || !isAdmin(user)) {
throw redirect('/');
}
return next();
};
-8
View File
@@ -1,8 +0,0 @@
import type { MiddlewareFunction } from 'react-router';
import { getRequestNonce } from '../../server/load-context';
import { nonceContext } from '../utils/nonce';
export const nonceMiddleware: MiddlewareFunction = ({ context }) => {
context.set(nonceContext, getRequestNonce());
};
+3 -19
View File
@@ -3,10 +3,8 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { getBasePath } from '@documenso/lib/constants/app';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import { createPublicEnv } from '@documenso/lib/utils/env';
import { extractLocaleData } from '@documenso/lib/utils/i18n';
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
import { TrpcProvider } from '@documenso/trpc/react';
import { getOrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session';
import { Toaster } from '@documenso/ui/primitives/toaster';
@@ -25,16 +23,13 @@ import {
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import { nonceMiddleware } from '~/middleware/nonce';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta';
import { nonce, nonceContext } from './utils/nonce';
export const middleware = [nonceMiddleware];
import { nonce } from './utils/nonce';
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
@@ -65,19 +60,9 @@ export async function loader({ context, request }: Route.LoaderArgs) {
const disableAnimations = cookieHeader.includes('__disable_animations=true');
let organisations = null;
let twoFactorEnforcement: TTwoFactorEnforcementStatus = { required: false };
if (session.isAuthenticated) {
[organisations, twoFactorEnforcement] = await Promise.all([
getOrganisationSession({
userId: session.user.id,
user: session.user,
session: session.session,
}),
// Instance 2FA enforcement status is part of the session payload so
// layouts can derive banners/redirects client-side without new queries.
getTwoFactorEnforcementStatus({ user: session.user, session: session.session }),
]);
organisations = await getOrganisationSession({ userId: session.user.id });
}
return data(
@@ -89,13 +74,12 @@ export async function loader({ context, request }: Route.LoaderArgs) {
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
nonce: context.get(nonceContext),
nonce: context.nonce,
session: session.isAuthenticated
? {
user: session.user,
session: session.session,
organisations: organisations || [],
twoFactorEnforcement,
}
: null,
publicEnv: createPublicEnv(),
@@ -1,44 +1,31 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { calculateTwoFactorDeadlineTimerDelay } from '@documenso/lib/utils/two-factor';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useEffect } from 'react';
import { Link, Outlet, redirect, useLocation, useNavigate } from 'react-router';
import { Link, Outlet, redirect } from 'react-router';
import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { OrganisationBillingBanner } from '~/components/general/organisations/organisation-billing-banner';
import { OrganisationQuotaBanner } from '~/components/general/organisations/organisation-quota-banner';
import { TwoFactorGraceBanner } from '~/components/general/two-factor-grace-banner';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
/**
* Builds the enrolment redirect for an instance-blocked user, carrying the
* current path so they land back where they were after enrolling.
* Don't revalidate (run the loader on sequential navigations)
*
* Update values via providers.
*/
const buildTwoFactorOnboardingPath = (currentPath: string) => {
const returnTo = (isValidReturnTo(currentPath) && normalizeReturnTo(currentPath)) || '/';
return `/onboarding/2fa?returnTo=${encodeURIComponent(returnTo)}`;
};
// Note: no `shouldRevalidate` suppression on this layout (the root layout
// keeps its own) — the loader must rerun on navigations so the instance
// enforcement redirect below is re-evaluated server-side.
export const shouldRevalidate = () => false;
export async function loader({ request }: Route.LoaderArgs) {
const [session, banner] = await Promise.all([
@@ -50,22 +37,6 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/signin');
}
// Instance-wide 2FA enforcement (UX chokepoint — the security boundary is
// the tRPC/Hono asserts): a blocked user is redirected into forced
// enrolment. `/onboarding/2fa` lives outside this layout, so the redirect
// cannot loop. Never blocks login itself — signin/onboarding are outside
// this layout too.
const twoFactorEnforcement = await getTwoFactorEnforcementStatus({
user: session.user,
session: session.session,
});
if (twoFactorEnforcement.required && twoFactorEnforcement.isBlocked) {
const url = new URL(request.url);
throw redirect(buildTwoFactorOnboardingPath(`${url.pathname}${url.search}`));
}
return {
banner,
};
@@ -74,51 +45,7 @@ export async function loader({ request }: Route.LoaderArgs) {
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
const { banner } = loaderData;
const { user, session, organisations, twoFactorEnforcement } = useSession();
const location = useLocation();
const navigate = useNavigate();
// Client-side counterpart of the loader's instance enforcement redirect:
// parent-layout loaders don't rerun on every child navigation, and a grace
// deadline can pass while the app is open. The session provider refreshes
// the enforcement status on navigation/focus; the timer covers a deadline
// crossing while the tab sits idle.
const isInstanceTwoFactorBlocked = twoFactorEnforcement.required && twoFactorEnforcement.isBlocked;
useEffect(() => {
if (!twoFactorEnforcement.required || twoFactorEnforcement.isSatisfied) {
return;
}
const redirectToOnboarding = () => {
void navigate(buildTwoFactorOnboardingPath(`${location.pathname}${location.search}`));
};
if (twoFactorEnforcement.isBlocked) {
redirectToOnboarding();
return;
}
// Within grace: fire at the deadline instant. A `null` delay means the
// deadline is beyond `setTimeout` range (~24.8 days) — no timer needed,
// the status is re-evaluated long before then.
const timerDelay = calculateTwoFactorDeadlineTimerDelay({
deadline: twoFactorEnforcement.deadline,
now: new Date(),
});
if (timerDelay === null) {
return;
}
const timeout = window.setTimeout(redirectToOnboarding, timerDelay);
return () => {
window.clearTimeout(timeout);
};
}, [twoFactorEnforcement, location.pathname, location.search, navigate]);
const { user, organisations } = useSession();
const { layoutMode } = useChildRouteFlags();
@@ -153,65 +80,6 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
match?.id === 'routes/_authenticated+/t.$teamUrl+/templates.$id.edit',
);
// Per-organisation 2FA enforcement: when the current org/team context's
// organisation blocks the user, render a 403 screen (NOT a redirect — the
// rest of the app stays usable) linking to the enrolment page. Derived
// client-side from the session provider's bootstrap payload, which stays
// readable while blocked. This is UX only — the security boundary is the
// tRPC/Hono asserts.
const isCurrentOrganisationTwoFactorBlocked =
Boolean(orgUrl || teamUrl) &&
Boolean(currentOrganisation?.twoFactorEnforcement.required && currentOrganisation.twoFactorEnforcement.isBlocked);
// State (b): enrolled, but this session never passed a second factor —
// enrolment would rightly refuse, so the remediation is a fresh sign-in.
const requiresRelogin = user.twoFactorEnabled && !session.twoFactorVerified;
// Instance enforcement takes precedence over the org 403 below: render
// nothing while the effect above navigates to forced enrolment.
if (isInstanceTwoFactorBlocked) {
return null;
}
if (isCurrentOrganisationTwoFactorBlocked) {
const returnTo = encodeURIComponent(`${location.pathname}${location.search}`);
return (
<GenericErrorLayout
errorCode={403}
errorCodeMap={{
403: {
heading: msg`Two-factor authentication required`,
subHeading: msg`403 Forbidden`,
message: requiresRelogin
? msg`This organisation requires two-factor authentication. Two-factor authentication is enabled for your account, but this session has not been verified with a second factor. Sign out and log back in to verify this session.`
: msg`This organisation requires two-factor authentication. Enable it for your account to regain access. The rest of your account remains available.`,
},
}}
primaryButton={
requiresRelogin ? (
<Button onClick={() => void authClient.signOut()}>
<Trans>Sign out</Trans>
</Button>
) : (
<Button asChild>
<Link to={`/onboarding/2fa?returnTo=${returnTo}`}>
<Trans>Set up two-factor authentication</Trans>
</Link>
</Button>
)
}
secondaryButton={
<Button variant="ghost" asChild>
<Link to="/">
<Trans>Go home</Trans>
</Link>
</Button>
}
/>
);
}
if (orgNotFound || teamNotFound) {
return (
<GenericErrorLayout
@@ -244,8 +112,6 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={currentTeam || null}>
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
<TwoFactorGraceBanner />
<OrganisationBillingBanner />
<OrganisationQuotaBanner />
@@ -1,4 +1,4 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,18 +20,16 @@ import {
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import { adminMiddleware } from '~/middleware/admin';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Admin`);
}
export const middleware = [adminMiddleware];
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { user } = await getSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
@@ -1,7 +1,9 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -22,10 +24,32 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -35,8 +59,8 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
<div className="mt-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by claim ID or name`}
className="mb-4"
/>
@@ -1,6 +1,8 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { EmailTransportCreateDialog } from '~/components/dialogs/email-transport-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -9,10 +11,32 @@ import { AdminEmailTransportsTable } from '~/components/tables/admin-email-trans
export default function AdminEmailTransportsPage() {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -22,8 +46,8 @@ export default function AdminEmailTransportsPage() {
<div className="mt-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by name or from address`}
className="mb-4"
/>
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -5,9 +6,8 @@ import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans, useLingui } from '@lingui/react/macro';
import { debounce, parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import {
@@ -52,17 +52,14 @@ export default function OrganisationStats() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [{ query: searchQuery }, setSearchFilters] = useQueryStates(
{
query: parseAsString.withDefault(''),
page: parseAsInteger,
},
{ shallow: false, limitUrlUpdates: debounce(500) },
);
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const [displayMode, setDisplayMode] = useState<OrganisationStatsDisplayMode>('usage');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []);
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
@@ -74,12 +71,29 @@ export default function OrganisationStats() {
const claimOptions = claimsData?.data ?? [];
const onSearchQueryChange = (value: string) => {
void setSearchFilters({
query: value || null,
page: null,
});
};
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
if ((searchParams?.get('query') || '') !== debouncedSearchQuery) {
params.delete('page');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onPeriodChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
@@ -114,8 +128,8 @@ export default function OrganisationStats() {
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
<Input
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation name, URL or ID`}
className="flex-1"
/>
@@ -1,6 +1,8 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@@ -8,10 +10,32 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
export default function Organisations() {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -19,8 +43,8 @@ export default function Organisations() {
<div className="mt-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
className="mb-4"
/>
@@ -6,7 +6,6 @@ import { useLingui } from '@lingui/react';
import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section';
import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section';
import { AdminTwoFactorEnforcementSection } from '~/components/general/admin-two-factor-enforcement-section';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/site-settings';
@@ -32,8 +31,6 @@ export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentPro
<AdminSiteBannerSection banner={banner} />
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
<AdminTwoFactorEnforcementSection />
</div>
</div>
);
@@ -1,4 +1,3 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
@@ -76,11 +75,6 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { user: currentUser } = useSession();
// Self-reset is forbidden server-side; hide the affordance entirely.
const canResetTwoFactor = user.twoFactorEnabled && user.id !== currentUser.id;
const roles = user.roles ?? [];
const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation();
@@ -234,7 +228,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
</Accordion>
<div className="mt-16 flex flex-col gap-4">
{canResetTwoFactor && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
@@ -7,7 +7,6 @@ import { Trans } from '@lingui/react/macro';
import { OrganisationDeleteDialog } from '~/components/dialogs/organisation-delete-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { OrganisationTwoFactorEnforcementForm } from '~/components/forms/organisation-two-factor-enforcement-form';
import { OrganisationUpdateForm } from '~/components/forms/organisation-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
@@ -30,19 +29,6 @@ export default function OrganisationSettingsGeneral() {
<OrganisationUpdateForm />
</div>
{canExecuteOrganisationAction('MANAGE_ORGANISATION_SECURITY', organisation.currentOrganisationRole) && (
<>
<hr className="my-6" />
<SettingsHeader
title={_(msg`Two-factor authentication`)}
subtitle={_(msg`Require members of this organisation to use two-factor authentication.`)}
/>
<OrganisationTwoFactorEnforcementForm />
</>
)}
{canExecuteOrganisationAction('DELETE_ORGANISATION', organisation.currentOrganisationRole) && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -1,9 +1,10 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
@@ -14,16 +15,35 @@ import { OrganisationMembersDataTable } from '~/components/tables/organisation-m
export default function TeamsSettingsMembersPage() {
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader
@@ -37,8 +57,8 @@ export default function TeamsSettingsMembersPage() {
<div>
<div className="my-4 flex flex-row items-center justify-between space-x-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={_(msg`Search`)}
/>
@@ -1,6 +1,8 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -9,10 +11,27 @@ import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-t
export default function OrganisationSettingsTeamsPage() {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -21,8 +40,8 @@ export default function OrganisationSettingsTeamsPage() {
</SettingsHeader>
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,8 +1,6 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { getUserAuthMethods } from '@documenso/lib/server-only/user/get-user-auth-methods';
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
import { prisma } from '@documenso/prisma';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -14,7 +12,6 @@ import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-au
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
import { PasswordForm } from '~/components/forms/password';
import { PasswordSetupRequestButton } from '~/components/forms/password-setup-request-button';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
@@ -27,10 +24,33 @@ export function meta() {
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const authMethods = await getUserAuthMethods({ userId: user.id });
// Todo: Use providers instead after RR7 migration.
// const accounts = await prisma.account.findMany({
// where: {
// userId: user.id,
// },
// select: {
// provider: true,
// },
// });
// const providers = accounts.map((account) => account.provider);
// let hasEmailPasswordAccount = providers.includes('DOCUMENSO');
const hasEmailPasswordAccount: boolean = await prisma.user
.count({
where: {
id: user.id,
password: {
not: null,
},
},
})
.then((value) => value > 0);
return {
hasEmailPasswordAccount: authMethods.includes(UserAuthMethod.PASSWORD),
// providers,
hasEmailPasswordAccount,
};
}
@@ -40,36 +60,14 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
const { _ } = useLingui();
const { user } = useSession();
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
return (
<div>
<SettingsHeader
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
{hasEmailPasswordAccount && <PasswordForm user={user} />}
{!hasEmailPasswordAccount && isEmailPasswordSigninEnabled && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Set a password</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
<Trans>
Your account has no password. Add one to sign in with your email and to sign documents that require it.
We'll email you a link.
</Trans>
</AlertDescription>
</div>
<PasswordSetupRequestButton />
</Alert>
)}
<Alert className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
@@ -1,9 +1,11 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { TeamGroupCreateDialog } from '~/components/dialogs/team-group-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -14,12 +16,34 @@ import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsGroupsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const everyoneGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
@@ -37,8 +61,8 @@ export default function TeamsSettingsGroupsPage() {
</SettingsHeader>
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,6 +1,8 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { TeamMemberCreateDialog } from '~/components/dialogs/team-member-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -9,10 +11,32 @@ import { TeamMembersTable } from '~/components/tables/team-members-table';
export default function TeamsSettingsMembersPage() {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -21,8 +45,8 @@ export default function TeamsSettingsMembersPage() {
</SettingsHeader>
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
@@ -19,8 +20,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { CheckCircle2Icon, ChevronRightIcon, PencilIcon, TerminalIcon, XCircleIcon } from 'lucide-react';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
@@ -51,14 +51,13 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
const { toast } = useToast();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const parsedSearchParams = WebhookSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
@@ -82,6 +81,26 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
query: parsedSearchParams.query,
});
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
@@ -233,8 +252,8 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
<div className="mt-4">
<div className="mb-4 flex flex-row items-center justify-between gap-x-4">
<Input
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by ID`}
/>
@@ -1,244 +0,0 @@
import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigate } from 'react-router';
import { z } from 'zod';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/2fa-challenge';
export function meta() {
return appMetaTags(msg`Two-Factor Authentication`);
}
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
// A signed-in user has no pending challenge to complete (any successful
// sign-in clears it server-side).
if (isAuthenticated) {
throw redirect('/');
}
return null;
}
const ZTwoFactorChallengeFormSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
type TTwoFactorChallengeFormSchema = z.infer<typeof ZTwoFactorChallengeFormSchema>;
export default function TwoFactorChallenge() {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [isValidatingChallenge, setIsValidatingChallenge] = useState(true);
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<'totp' | 'backup'>('totp');
const form = useForm<TTwoFactorChallengeFormSchema>({
values: {
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZTwoFactorChallengeFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onRedirectToSignIn = async () => {
toast({
title: _(msg`Sign in required`),
description: _(msg`Your sign-in attempt has expired. Please sign in again.`),
variant: 'destructive',
});
await navigate('/signin');
};
useEffect(() => {
void authClient.twoFactor
.getChallenge()
.then(async ({ valid }) => {
if (!valid) {
await onRedirectToSignIn();
return;
}
setIsValidatingChallenge(false);
})
.catch(async () => onRedirectToSignIn());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onToggleTwoFactorAuthenticationMethodClick = () => {
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
if (method === 'totp') {
form.setValue('backupCode', '');
}
if (method === 'backup') {
form.setValue('totpCode', '');
}
setTwoFactorAuthenticationMethod(method);
};
const onFormSubmit = async ({ totpCode, backupCode }: TTwoFactorChallengeFormSchema) => {
try {
// On success this navigates to the server-provided redirect path.
await authClient.twoFactor.verifyChallenge(
twoFactorAuthenticationMethod === 'totp' ? { totpCode } : { backupCode },
);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AuthenticationErrorCode.TwoFactorChallengeExpired) {
await onRedirectToSignIn();
return;
}
if (error.code === AuthenticationErrorCode.InvalidTwoFactorCode) {
toast({
title: _(msg`Unable to sign in`),
description: _(msg`The two-factor authentication code provided is incorrect.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your code. Please try again.`),
variant: 'destructive',
});
}
};
if (isValidatingChallenge) {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Checking your sign-in...</Trans>
</p>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<h1 className="font-semibold text-2xl">
<Trans>Two-Factor Authentication</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
{twoFactorAuthenticationMethod === 'totp' ? (
<Trans>Enter the code from your authenticator app to finish signing in.</Trans>
) : (
<Trans>Enter one of your backup codes to finish signing in.</Trans>
)}
</p>
<hr className="-mx-6 my-4" />
<Form {...form}>
<form className="flex w-full flex-col gap-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
{twoFactorAuthenticationMethod === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{twoFactorAuthenticationMethod === 'backup' && (
<FormField
control={form.control}
name="backupCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex flex-col gap-y-2 sm:flex-row sm:justify-end sm:gap-x-2">
<Button type="button" variant="secondary" onClick={onToggleTwoFactorAuthenticationMethodClick}>
{twoFactorAuthenticationMethod === 'totp' ? (
<Trans>Use Backup Code</Trans>
) : (
<Trans>Use Authenticator</Trans>
)}
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</div>
</fieldset>
</form>
</Form>
<p className="mt-6 text-center text-muted-foreground text-sm">
<Trans>
Not you?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Back to sign in
</Link>
</Trans>
</p>
</div>
</div>
);
}
@@ -32,12 +32,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
organisation: {
select: {
name: true,
organisationGlobalSettings: {
select: {
twoFactorRequired: true,
twoFactorGracePeriodDays: true,
},
},
},
},
},
@@ -77,10 +71,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
},
});
// Non-blocking notice data: joining always succeeds, but the member's 2FA
// grace window starts at join when the organisation requires 2FA.
const twoFactorSettings = organisationMemberInvite.organisation.organisationGlobalSettings;
return {
state: 'Pending',
token: organisationMemberInvite.token,
@@ -88,8 +78,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
organisationName,
userExists: user !== null,
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
organisationTwoFactorRequired: twoFactorSettings.twoFactorRequired,
organisationTwoFactorGracePeriodDays: twoFactorSettings.twoFactorGracePeriodDays,
} as const;
}
@@ -153,8 +141,6 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
organisationName={data.organisationName}
userExists={data.userExists}
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
organisationTwoFactorRequired={data.organisationTwoFactorRequired}
organisationTwoFactorGracePeriodDays={data.organisationTwoFactorGracePeriodDays}
/>
);
}
@@ -165,8 +151,6 @@ type PendingInvitationProps = {
organisationName: string;
userExists: boolean;
isSessionUserTheInvitedUser: boolean;
organisationTwoFactorRequired: boolean;
organisationTwoFactorGracePeriodDays: number;
};
type InvitationResult = 'idle' | 'accepted' | 'declined';
@@ -179,8 +163,6 @@ const PendingInvitation = ({
organisationName,
userExists,
isSessionUserTheInvitedUser,
organisationTwoFactorRequired,
organisationTwoFactorGracePeriodDays,
}: PendingInvitationProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -307,24 +289,6 @@ const PendingInvitation = ({
</Trans>
</p>
{/* Non-blocking notice: accepting always succeeds; access to the
organisation blocks only after the grace period expires. */}
{organisationTwoFactorRequired && !actionIsDecline && (
<p className="mt-2 mb-4 text-muted-foreground text-sm">
{organisationTwoFactorGracePeriodDays > 0 ? (
<Trans>
This organisation requires two-factor authentication. You will need to enable it within{' '}
{organisationTwoFactorGracePeriodDays} days of joining to keep access to the organisation.
</Trans>
) : (
<Trans>
This organisation requires two-factor authentication. You will need to enable it immediately after
joining to access the organisation.
</Trans>
)}
</p>
)}
{acceptFailureReason && (
<p className="mt-2 mb-4 text-destructive text-sm">
{match(acceptFailureReason)
@@ -86,12 +86,6 @@ export async function loader({ params }: Route.LoaderArgs) {
name: true,
url: true,
avatarImageId: true,
organisationGlobalSettings: {
select: {
twoFactorRequired: true,
twoFactorGracePeriodDays: true,
},
},
},
});
@@ -113,10 +107,6 @@ export async function loader({ params }: Route.LoaderArgs) {
name: organisation.name,
url: organisation.url,
avatar: organisation.avatarImageId,
// Non-blocking notice data: SSO membership creation always succeeds;
// the 2FA grace window starts at join.
twoFactorRequired: organisation.organisationGlobalSettings.twoFactorRequired,
twoFactorGracePeriodDays: organisation.organisationGlobalSettings.twoFactorGracePeriodDays,
},
} as const;
}
@@ -276,26 +266,6 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
</div>
</div>
{/* Non-blocking notice: confirming always succeeds; organisation
access blocks only after the grace period expires. */}
{organisation.twoFactorRequired && (
<Alert variant="neutral">
<AlertDescription>
{organisation.twoFactorGracePeriodDays > 0 ? (
<Trans>
This organisation requires two-factor authentication. You will need to enable it within{' '}
{organisation.twoFactorGracePeriodDays} days of joining to keep access to the organisation.
</Trans>
) : (
<Trans>
This organisation requires two-factor authentication. You will need to enable it immediately after
joining to access the organisation.
</Trans>
)}
</AlertDescription>
</Alert>
)}
<div className="mb-4 flex items-center gap-x-2">
<Checkbox
id={`accept-conditions`}
@@ -1,8 +1,8 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
export const loader = async () => {
export const loader = () => {
try {
const certStatus = await getCertificateStatus();
const certStatus = getCertificateStatus();
return Response.json({
isAvailable: certStatus.isAvailable,
+1 -1
View File
@@ -22,7 +22,7 @@ export const loader = async () => {
}
try {
const certStatus = await getCertificateStatus();
const certStatus = getCertificateStatus();
if (certStatus.isAvailable) {
checks.certificate = { status: 'ok' };
@@ -137,9 +137,6 @@ export default function AuthoringLayout() {
teams: [team],
subscription: null,
currentOrganisationRole: OrganisationMemberRole.MEMBER,
// Hardcoded non-enforcing status: embed authoring is presign-token
// authorized (machine access), which is exempt from 2FA enforcement.
twoFactorEnforcement: { required: false },
};
return (
-385
View File
@@ -1,385 +0,0 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { AppError } from '@documenso/lib/errors/app-error';
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { isTwoFactorSatisfied } from '@documenso/lib/utils/two-factor';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigate, useRevalidator } from 'react-router';
import { renderSVG } from 'uqr';
import { z } from 'zod';
import { RecoveryCodeList } from '~/components/forms/2fa/recovery-code-list';
import { appMetaTags } from '~/utils/meta';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/2fa';
export function meta() {
return appMetaTags(msg`Two-Factor Authentication`);
}
export async function loader({ request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
const url = new URL(request.url);
const rawReturnTo = url.searchParams.get('returnTo') ?? undefined;
const returnTo = (isValidReturnTo(rawReturnTo) && normalizeReturnTo(rawReturnTo)) || '/';
if (!session.isAuthenticated) {
throw redirect(`/signin?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`);
}
// Auto-redirect ONLY when enforcement is already satisfied on arrival.
// Every other state (including "not required") renders the page — a user
// landing here after a backup-code recovery gets an explicit skip link
// instead of being bounced away.
if (
isTwoFactorSatisfied({
userTwoFactorEnabled: session.user.twoFactorEnabled,
sessionTwoFactorVerified: session.session.twoFactorVerified,
})
) {
throw redirect(returnTo);
}
const twoFactorEnforcement = await getTwoFactorEnforcementStatus({
user: session.user,
session: session.session,
});
return superLoaderJson({
returnTo,
isTwoFactorEnabled: session.user.twoFactorEnabled,
isSessionTwoFactorVerified: session.session.twoFactorVerified,
twoFactorEnforcement,
});
}
const ZEnableTwoFactorFormSchema = z.object({
token: z.string().min(6).max(6),
});
type TEnableTwoFactorFormSchema = z.infer<typeof ZEnableTwoFactorFormSchema>;
export default function OnboardingTwoFactorPage() {
const { returnTo, isTwoFactorEnabled, isSessionTwoFactorVerified, twoFactorEnforcement } =
useSuperLoaderData<typeof loader>();
const { _, i18n } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const { revalidate } = useRevalidator();
// The whole page renders from the loader snapshot + local state, never from
// the live session context. The session provider refreshes in the
// background (and `twoFactorEnabled` flips the moment 2FA is enabled), but
// navigation is controlled exclusively by this page's state machine —
// recovery codes are shown exactly once and must stay on screen until the
// user explicitly acknowledges saving them.
const [setupData, setSetupData] = useState<{ uri: string; secret: string } | null>(null);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [hasSetupFailed, setHasSetupFailed] = useState(false);
const hasRequestedSetupRef = useRef(false);
const isBlocked = twoFactorEnforcement.required && twoFactorEnforcement.isBlocked;
const canSkip = !isBlocked;
// State (b): enrolled, but this session was created before 2FA was enabled
// so it never passed a second factor. Setup would rightly refuse
// (already enabled), so the only remediation is a fresh sign-in.
const requiresRelogin = isTwoFactorEnabled && !isSessionTwoFactorVerified;
const form = useForm<TEnableTwoFactorFormSchema>({
defaultValues: {
token: '',
},
resolver: zodResolver(ZEnableTwoFactorFormSchema),
});
const { isSubmitting: isEnabling } = form.formState;
// Enrolment goes through the auth routes (`authClient.twoFactor.*`), NOT
// tRPC: while the user is blocked by instance enforcement, session tRPC
// procedures respond 403 — the remediation page must not depend on them.
const setupTwoFactor = async () => {
setHasSetupFailed(false);
try {
const data = await authClient.twoFactor.setup();
setSetupData(data);
} catch (err) {
const error = AppError.parseError(err);
// The user enrolled concurrently (e.g. in another tab). Re-run the
// loader instead of dead-ending on a retry that would refuse forever:
// it auto-redirects when this session became verified by the
// concurrent enable, or renders the sign-out-and-re-login prompt.
if (error.code === 'TWO_FACTOR_ALREADY_ENABLED') {
await revalidate();
return;
}
setHasSetupFailed(true);
toast({
title: _(msg`Unable to setup two-factor authentication`),
description: _(msg`We were unable to setup two-factor authentication for your account. Please try again.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (isTwoFactorEnabled || hasRequestedSetupRef.current) {
return;
}
hasRequestedSetupRef.current = true;
void setupTwoFactor();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onEnableSubmit = async ({ token }: TEnableTwoFactorFormSchema) => {
try {
const data = await authClient.twoFactor.enable({ code: token });
// Phase one complete. Do NOT navigate — show the recovery codes and
// wait for the explicit acknowledgement below.
setRecoveryCodes(data.recoveryCodes);
} catch (err) {
const error = AppError.parseError(err);
// Enabled concurrently (e.g. another tab) between setup and enable —
// the recovery codes were shown there. Re-run the loader to land on
// the correct state instead of claiming the code was wrong.
if (error.code === 'TWO_FACTOR_ALREADY_ENABLED') {
toast({
title: _(msg`Two-factor authentication is already enabled`),
description: _(msg`Two-factor authentication was already enabled for your account.`),
});
await revalidate();
return;
}
toast({
title: _(msg`Unable to setup two-factor authentication`),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
}
};
const onDownloadRecoveryCodes = () => {
if (!recoveryCodes) {
return;
}
const blob = new Blob([recoveryCodes.join('\n')], {
type: 'text/plain',
});
downloadFile({
filename: 'documenso-2FA-recovery-codes.txt',
data: blob,
});
};
// Phase two: only the explicit acknowledgement navigates away.
const onRecoveryCodesAcknowledged = async () => {
await navigate(returnTo);
};
const onSignOut = async () => {
await authClient.signOut();
};
return (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<h1 className="font-semibold text-2xl">
<Trans>Two-factor authentication</Trans>
</h1>
{twoFactorEnforcement.required && isBlocked && (
<p className="mt-2 text-muted-foreground text-sm">
<Trans>Two-factor authentication is required to continue using your account.</Trans>
</p>
)}
{twoFactorEnforcement.required && !isBlocked && (
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Two-factor authentication is required for your account from{' '}
{i18n.date(twoFactorEnforcement.deadline, { dateStyle: 'long' })}.
</Trans>
</p>
)}
<hr className="-mx-6 my-4" />
{requiresRelogin ? (
<div className="flex flex-col gap-y-4">
<p className="text-muted-foreground text-sm">
<Trans>
Two-factor authentication is enabled for your account, but this session has not been verified with a
second factor. Sign out and log back in to verify this session.
</Trans>
</p>
<Button className="w-full sm:w-auto sm:self-end" onClick={() => void onSignOut()}>
<Trans>Sign out</Trans>
</Button>
</div>
) : recoveryCodes ? (
<div className="flex flex-col gap-y-4">
<div>
<h2 className="font-medium text-lg">
<Trans>Save your recovery codes</Trans>
</h2>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Your recovery codes are listed below. Please store them in a safe place — they will not be shown
again.
</Trans>
</p>
</div>
<RecoveryCodeList recoveryCodes={recoveryCodes} />
<div className="flex flex-col gap-y-2 sm:flex-row sm:justify-end sm:gap-x-2">
<Button variant="secondary" onClick={onDownloadRecoveryCodes}>
<Trans>Download</Trans>
</Button>
<Button onClick={() => void onRecoveryCodesAcknowledged()}>
<Trans>I have saved my recovery codes</Trans>
</Button>
</div>
</div>
) : !setupData ? (
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
{hasSetupFailed ? (
<>
<p className="text-muted-foreground text-sm">
<Trans>We were unable to prepare two-factor authentication.</Trans>
</p>
<Button variant="secondary" onClick={() => void setupTwoFactor()}>
<Trans>Try again</Trans>
</Button>
</>
) : (
<>
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Preparing two-factor authentication...</Trans>
</p>
</>
)}
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onEnableSubmit)}>
<fieldset disabled={isEnabling} className="flex flex-col gap-y-4">
<p className="text-muted-foreground text-sm">
<Trans>
To enable two-factor authentication, scan the following QR code using your authenticator app.
</Trans>
</p>
<div
className="flex h-36 justify-center"
dangerouslySetInnerHTML={{
__html: renderSVG(setupData.uri),
}}
/>
<p className="text-muted-foreground text-sm">
<Trans>
If your authenticator app does not support QR codes, you can use the following code instead:
</Trans>
</p>
<p className="rounded-lg bg-muted/60 p-2 text-center font-mono text-muted-foreground tracking-widest">
{setupData.secret}
</p>
<FormField
name="token"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
<Trans>Token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={isEnabling} className="w-full sm:w-auto sm:self-end">
<Trans>Enable 2FA</Trans>
</Button>
</fieldset>
</form>
</Form>
)}
{(canSkip || !requiresRelogin) && (
<p className="mt-6 flex flex-col items-center gap-y-1 text-center text-muted-foreground text-sm">
{canSkip && !recoveryCodes && (
<Link to={returnTo} className="text-documenso-700 duration-200 hover:opacity-70">
<Trans>Skip for now</Trans>
</Link>
)}
{!requiresRelogin && (
<button
type="button"
className="text-documenso-700 duration-200 hover:opacity-70"
onClick={() => void onSignOut()}
>
<Trans>Sign out</Trans>
</button>
)}
</p>
)}
</div>
</div>
);
}
@@ -1,32 +0,0 @@
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Outlet } from 'react-router';
/**
* Onboarding routes require a session (each route's own loader asserts it)
* but deliberately live OUTSIDE the `_authenticated+` layout: that layout is
* the UX chokepoint for 2FA enforcement redirects, and remediation pages such
* as `/onboarding/2fa` must be exempt from it or the redirect would loop.
*/
export default function Layout() {
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div>
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<img
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
<div className="relative w-full">
<Outlet />
</div>
</div>
</main>
);
}
+1 -7
View File
@@ -1,10 +1,4 @@
import { createContext, useRouteLoaderData } from 'react-router';
/**
* Per-request CSP nonce. Set by the root route middleware, read with
* `context.get(nonceContext)` in loaders/actions and `entry.server`.
*/
export const nonceContext = createContext<string>('');
import { useRouteLoaderData } from 'react-router';
/**
* Returns the supplied CSP nonce only when rendering on the server.
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.18.0"
"version": "2.17.0"
}
-3
View File
@@ -8,7 +8,4 @@ export default {
// kept without a trailing slash so they match exactly, and so the bare
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
future: {
v8_middleware: true,
},
} satisfies Config;
@@ -1,7 +1,6 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
import { detectFieldsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-fields';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sValidator } from '@hono/standard-validator';
@@ -42,14 +41,6 @@ export const detectFieldsRoute = new Hono<HonoEnv>().post(
});
}
// 2FA enforcement: session-authenticated endpoint — instance assert +
// the owning organisation's policy for the envelope's team.
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [team.organisationId],
});
// Check if AI features are enabled for the team
const { aiFeaturesEnabled } = team.derivedSettings;
@@ -1,7 +1,6 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
import { detectRecipientsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-recipients';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sValidator } from '@hono/standard-validator';
@@ -42,14 +41,6 @@ export const detectRecipientsRoute = new Hono<HonoEnv>().post(
});
}
// 2FA enforcement: session-authenticated endpoint — instance assert +
// the owning organisation's policy for the envelope's team.
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [team.organisationId],
});
// Check if AI features are enabled for the team
const { aiFeaturesEnabled } = team.derivedSettings;
-62
View File
@@ -1,7 +1,6 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { prisma } from '@documenso/prisma';
@@ -29,17 +28,6 @@ export const filesRoute = new Hono<HonoEnv>()
*/
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
try {
// 2FA enforcement applies only when the request is session
// authenticated — presign-token access (embedding) is machine access
// and stays exempt. `resolveFileUploadUserId` prefers the session, so
// asserting on the session here cannot be bypassed by a session user.
const { user: sessionUser, session } = await getOptionalSession(c);
if (sessionUser && session) {
// No organisation scope: the upload creates unattached document data.
await assertTwoFactorEnforcementForSession({ user: sessionUser, session });
}
const userId = await resolveFileUploadUserId(c);
if (!userId) {
@@ -66,13 +54,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json(result);
} catch (error) {
console.error('Upload failed:', error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Upload failed' }, 500);
}
})
@@ -88,10 +69,6 @@ export const filesRoute = new Hono<HonoEnv>()
let userId = session.user?.id;
// Presign-token access (embedding) is machine access and exempt from
// 2FA enforcement; the assert below only applies to session auth.
const isPresignTokenAccess = Boolean(token);
if (token) {
const presignToken = await verifyEmbeddingPresignToken({
token,
@@ -109,11 +86,6 @@ export const filesRoute = new Hono<HonoEnv>()
id: envelopeId,
},
include: {
team: {
select: {
organisationId: true,
},
},
envelopeItems: {
where: {
id: envelopeItemId,
@@ -129,26 +101,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope not found' }, 404);
}
// 2FA enforcement (session auth only): instance assert + the owning
// organisation's policy for the envelope being accessed.
if (!isPresignTokenAccess && session.user && session.session) {
try {
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [envelope.team.organisationId],
});
} catch (error) {
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
throw error;
}
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
@@ -200,11 +152,6 @@ export const filesRoute = new Hono<HonoEnv>()
id: envelopeId,
},
include: {
team: {
select: {
organisationId: true,
},
},
envelopeItems: {
where: {
id: envelopeItemId,
@@ -226,15 +173,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope not found' }, 404);
}
// 2FA enforcement: this route is session-only, so both the instance
// assert and the owning organisation's policy apply. The thrown
// AppError is mapped to a 403 by the catch below.
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [envelope.team.organisationId],
});
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
@@ -1,6 +1,4 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError } from '@documenso/lib/errors/app-error';
import { assertTwoFactorEnforcementForSession } from '@documenso/lib/server-only/2fa/org-enforcement';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { sha256 } from '@documenso/lib/universal/crypto';
@@ -43,10 +41,6 @@ route.get(
let userId = session.user?.id;
// Presign-token access (embedding) is machine access and exempt from 2FA
// enforcement; the assert below only applies to session auth.
const isPresignTokenAccess = Boolean(presignToken);
// Check presignToken if provided
if (presignToken) {
const verifiedToken = await verifyEmbeddingPresignToken({
@@ -75,11 +69,6 @@ route.get(
type: true,
teamId: true,
templateType: true,
team: {
select: {
organisationId: true,
},
},
},
},
},
@@ -89,26 +78,6 @@ route.get(
return c.json({ error: 'Not found' }, 404);
}
// 2FA enforcement (session auth only): instance assert + the owning
// organisation's policy for the envelope being accessed.
if (!isPresignTokenAccess && session.user && session.session) {
try {
await assertTwoFactorEnforcementForSession({
user: session.user,
session: session.session,
organisationIds: [envelopeItem.envelope.team.organisationId],
});
} catch (error) {
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
throw error;
}
}
// Check whether the user has access to the document.
const hasAccess = await checkEnvelopeFileAccess({
userId,
+3 -2
View File
@@ -24,11 +24,12 @@ export const appContext = async (c: Context, next: Next) => {
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return await next();
return next();
}
// Add context to any pages you want here.
return await next();
return next();
};
const setAppContext = (c: Context, context: AppContext) => {
+23 -6
View File
@@ -1,16 +1,33 @@
import { getContext } from 'hono/context-storage';
import { RouterContextProvider } from 'react-router';
import type { AppLoadContext } from 'react-router';
import type { HonoEnv } from './router';
import { CSP_NONCE_KEY } from './security-headers';
/**
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
* `hono/context-storage` (enabled in `server/router.ts`).
* Augment React Router's `AppLoadContext` so loaders, actions, and
* `entry.server` can access fields by name without casts.
*/
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
declare module 'react-router' {
interface AppLoadContext {
/**
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
* so it can be threaded into `<ServerRouter nonce>` and root loader
* data, which then feeds `<Scripts>`, `<Links>`, etc.
*/
nonce: string;
}
}
/**
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
* production (`hono-react-router-adapter/node`).
*
* The Hono context isn't passed directly by the adapter, so we read it via
* `hono/context-storage`, which is enabled in `server/router.ts`.
*/
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
export const getLoadContext = (): AppLoadContext => {
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
return { nonce };
};
+1 -2
View File
@@ -5,7 +5,6 @@ import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import serverAdapter from 'hono-react-router-adapter/vite';
import type { AppLoadContext } from 'react-router';
import tailwindcss from 'tailwindcss';
import { defineConfig, normalizePath } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
@@ -54,7 +53,7 @@ export default defineConfig({
entry: 'server/router.ts',
getLoadContext: async () => {
const { getLoadContext } = await import('./server/load-context');
return getLoadContext() as unknown as AppLoadContext;
return getLoadContext();
},
exclude: [
// Spread the defaults but replace the /.css$/ rule so that Bull
+12 -25
View File
@@ -1,7 +1,7 @@
###########################
# BASE CONTAINER #
###########################
FROM node:24-alpine3.23 AS base
FROM node:22-alpine3.22 AS base
RUN apk add --no-cache openssl
RUN apk add --no-cache font-freefont
@@ -19,10 +19,7 @@ WORKDIR /app
COPY . .
# Install the exact turbo version resolved in the lockfile, without installing
# the rest of the dependency tree (prune must run before any npm ci).
RUN TURBO_VERSION="$(jq -r '.packages["node_modules/turbo"].version' package-lock.json)" \
&& npm install -g "turbo@${TURBO_VERSION}"
RUN npm install -g "turbo@^2.10.0"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
@@ -42,9 +39,9 @@ RUN apk add --no-cache make cmake g++ openssl bash
WORKDIR /app
# Disable husky from installing hooks
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
ENV NEXT_TELEMETRY_DISABLED=1
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
# Encryption keys
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
@@ -88,17 +85,17 @@ COPY --from=builder /app/out/full/ .
# Finally copy the turbo.json file so that we can run turbo commands
COPY turbo.json turbo.json
ENV NODE_OPTIONS="--max-old-space-size=8192"
RUN npm install -g "turbo@^2.10.0"
RUN npx turbo run build --filter=@documenso/remix...
RUN turbo run build --filter=@documenso/remix...
###########################
# RUNNER CONTAINER #
###########################
FROM base AS runner
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
# Telemetry credentials (baked into image at build time, can be disabled at runtime)
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
@@ -121,16 +118,7 @@ COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config
# Copy the patches across
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
# Strip build-time residue that ships as production dependencies but is never
# executed at runtime.
RUN rm -rf \
node_modules/react-email/dist/cli \
node_modules/esbuild \
node_modules/@esbuild \
node_modules/.bin/esbuild \
node_modules/.bin/email
RUN npm ci --only=production
# Automatically leverage output traces to reduce image size
# https://nodejs.org/docs/advanced-features/output-file-tracing
@@ -141,9 +129,8 @@ COPY --from=installer --chown=nodejs:nodejs /app/apps/remix/public ./apps/remix/
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/schema.prisma ./packages/prisma/schema.prisma
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/migrations ./packages/prisma/migrations
# Generate the prisma client again, this time only targeting the client generator
RUN npx prisma generate --schema ./packages/prisma/schema.prisma --generator client \
&& npm cache clean --force
# Generate the prisma client again
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
# Get the start script from docker/
+3691 -1535
View File
File diff suppressed because it is too large Load Diff
+6 -21
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.18.0",
"version": "2.17.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -41,25 +41,10 @@
"translate:extract": "lingui extract --clean",
"translate:compile": "lingui compile"
},
"packageManager": "npm@11.19.1",
"packageManager": "npm@11.11.0",
"engines": {
"npm": ">=11.17.0",
"node": ">=24.0.0"
},
"allowScripts": {
"@documenso/skia-canvas": true,
"@playwright/browser-chromium": true,
"@prisma/client": true,
"@prisma/engines": true,
"aws-crt": true,
"esbuild": true,
"fsevents": true,
"inngest-cli": true,
"prisma": true,
"@datadog/pprof": false,
"core-js": false,
"msgpackr-extract": false,
"protobufjs": false
"npm": ">=11.11.0",
"node": ">=22.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
@@ -82,7 +67,7 @@
"esbuild": "^0.28.1",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.44.0",
"inngest-cli": "^1.17.9",
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^9.0.0",
@@ -145,7 +130,7 @@
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"sharp": "0.35.4",
"sharp": "0.35.3",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
@@ -10,7 +10,7 @@ test.describe.configure({ mode: 'parallel' });
const nanoid = customAlphabet('1234567890abcdef', 10);
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organizations…';
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
@@ -411,7 +411,7 @@ test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', asyn
});
// They should NOT see the organisation settings heading for this org.
await expect(page.getByText('Organization Settings')).not.toBeVisible();
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
});
// ─── Access control: UI ──────────────────────────────────────────────────────
@@ -50,9 +50,9 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
});
// Verify we're on the admin organisation page
await expect(page.getByText(`Manage organization`)).toBeVisible();
await expect(page.getByText(`Manage organisation`)).toBeVisible();
await expect(page.getByLabel('Organization Name')).toHaveValue(organisation.name);
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
// Check that the organisation members table shows the correct roles
const ownerRow = page.getByRole('row', { name: ownerUser.email });
@@ -356,7 +356,7 @@ test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
});
// Should show 404 error
await expect(page.getByRole('heading', { name: 'Organization not found' })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
timeout: 10_000,
});
});
@@ -525,8 +525,8 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
// Should be able to access organisation settings
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeEnabled();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
// Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
@@ -1,193 +0,0 @@
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { waitForHydration } from '../fixtures/hydration';
import { signSignaturePad } from '../fixtures/signature';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const SEEDED_PASSWORD = 'password';
const NEW_PASSWORD = 'Test123!';
/**
* Seed a document requiring PASSWORD action auth for a recipient with an account,
* and sign the recipient in on the signing page.
*
* Action auth is gated behind the cfr21 claim flag at write time only, so seeding
* the auth options directly bypasses the gate the same way action-auth.spec.ts does.
*/
const seedPasswordActionAuthDocument = async () => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner,
teamId: team.id,
recipients: [recipient],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: [],
globalActionAuth: ['PASSWORD'],
}),
},
fields: [FieldType.SIGNATURE],
});
const { token, fields } = recipients[0];
const signatureField = fields.find((field) => field.type === FieldType.SIGNATURE);
if (!signatureField) {
throw new Error('Expected a signature field to be seeded');
}
return {
recipient,
signUrl: `/sign/${token}`,
signatureField,
};
};
test('[DOCUMENT_AUTH]: passwordless user is sent a setup link and can sign after setting a password', async ({
page,
}) => {
const { recipient, signUrl, signatureField } = await seedPasswordActionAuthDocument();
await apiSignin({
page,
email: recipient.email,
password: SEEDED_PASSWORD,
redirectPath: signUrl,
});
// Simulate an OAuth / passkey only account by removing the password after sign in.
await prisma.user.update({
where: { id: recipient.id },
data: { password: null },
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
await expect(page.getByText('Reauthentication is required to sign this field')).toBeVisible();
await expect(page.getByText('No password set')).toBeVisible();
// A bare session must not be able to set a password inline; it gets emailed a link instead.
await expect(page.getByLabel('New password')).not.toBeVisible();
await page.getByRole('button', { name: 'Send setup link' }).click();
await expect(page.getByText('Check your email')).toBeVisible();
const resetToken = await prisma.passwordResetToken.findFirstOrThrow({
where: { userId: recipient.id },
});
// Complete the emailed flow, which also invalidates all sessions.
await page.goto(`/reset-password/${resetToken.token}`);
// Filling controlled inputs before hydration gets reset by React.
await waitForHydration(page, 'input[name="password"]');
await page.getByLabel('Password', { exact: true }).fill(NEW_PASSWORD);
await page.getByLabel('Repeat Password').fill(NEW_PASSWORD);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
// Come back with the new password and the normal reauth form should now work.
await apiSignin({
page,
email: recipient.email,
password: NEW_PASSWORD,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('No password set')).not.toBeVisible();
await dialog.getByLabel('Password').fill(NEW_PASSWORD);
await dialog.getByRole('button', { name: 'Sign' }).click();
await expect(page.locator(`#field-${signatureField.id}`)).toHaveAttribute('data-inserted', 'true');
});
test('[DOCUMENT_AUTH]: user with a password sees the normal password reauth form', async ({ page }) => {
const { recipient, signUrl, signatureField } = await seedPasswordActionAuthDocument();
await apiSignin({
page,
email: recipient.email,
password: SEEDED_PASSWORD,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
await expect(page.getByText('Reauthentication is required to sign this field')).toBeVisible();
await expect(page.getByText('No password set')).not.toBeVisible();
const dialog = page.getByRole('dialog');
// Wrong password is rejected.
await dialog.getByLabel('Password').fill('wrong-password');
await dialog.getByRole('button', { name: 'Sign' }).click();
await expect(dialog.getByText('Unauthorized')).toBeVisible();
// Correct password signs the field.
await dialog.getByLabel('Password').fill(SEEDED_PASSWORD);
await dialog.getByRole('button', { name: 'Sign' }).click();
await expect(page.locator(`#field-${signatureField.id}`)).toHaveAttribute('data-inserted', 'true');
});
test('[DOCUMENT_AUTH]: passwordless user can request a setup link from security settings', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({
page,
email: user.email,
password: SEEDED_PASSWORD,
redirectPath: '/settings/profile',
});
await prisma.user.update({
where: { id: user.id },
data: { password: null },
});
await page.goto('/settings/security');
await expect(page.getByRole('heading', { name: 'Set a password' })).toBeVisible();
await expect(page.getByLabel('Current password')).not.toBeVisible();
await expect(page.getByLabel('New password')).not.toBeVisible();
// Clicking before hydration is a no-op, so retry until the sent state appears.
await expect(async () => {
await page.getByRole('button', { name: 'Send setup link' }).click();
await expect(page.getByRole('button', { name: 'Link sent' })).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 15_000 });
const resetToken = await prisma.passwordResetToken.findFirst({
where: { userId: user.id },
});
expect(resetToken).not.toBeNull();
});
@@ -408,7 +408,7 @@ test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) =
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
await expectToastTextToBeVisible(page, 'Documents canceled');
await expectToastTextToBeVisible(page, 'Documents cancelled');
// Selection clears after a successful cancel.
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
@@ -455,7 +455,7 @@ test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// Only one of the three was pending, so this is a partial result.
await expectToastTextToBeVisible(page, 'Documents partially canceled');
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: pending.id },
@@ -505,7 +505,7 @@ test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', as
// The server rejects the cancellation for a document the MEMBER does not own,
// so it reports zero cancelled (a partial result with the document in failedIds).
await expectToastTextToBeVisible(page, 'Documents partially canceled');
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
// The document remains pending.
const envelope = await prisma.envelope.findFirstOrThrow({
@@ -41,7 +41,7 @@ const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: s
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
if (reason) {
await page.getByPlaceholder('Add an optional reason for canceling this document').fill(reason);
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
}
await page.getByRole('button', { name: 'Cancel document' }).click();
@@ -58,13 +58,13 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
await expectToastTextToBeVisible(page, 'Document canceled');
await expectToastTextToBeVisible(page, 'Document cancelled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
// The cancelled document is still listed.
await selectDocumentStatusFilter(page, 'Canceled');
await selectDocumentStatusFilter(page, 'Cancelled');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
@@ -95,7 +95,7 @@ test('[DOCUMENTS]: cancelling a pending document retains it for recipients', asy
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document canceled');
await expectToastTextToBeVisible(page, 'Document cancelled');
await apiSignout({ page });
@@ -125,10 +125,10 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document canceled');
await expectToastTextToBeVisible(page, 'Document cancelled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await selectDocumentStatusFilter(page, 'Canceled');
await selectDocumentStatusFilter(page, 'Cancelled');
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
@@ -328,7 +328,7 @@ test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
await expectToastTextToBeVisible(page, 'Document canceled');
await expectToastTextToBeVisible(page, 'Document cancelled');
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
@@ -6,12 +6,6 @@ type LoginOptions = {
email?: string;
password?: string;
/**
* TOTP code for accounts with 2FA enabled. Sign-ins that pass a valid code
* create a session with `twoFactorVerified: true`.
*/
totpCode?: string;
/**
* Where to navigate after login.
*/
@@ -22,7 +16,6 @@ export const apiSignin = async ({
page,
email = 'example@documenso.com',
password = 'password',
totpCode,
redirectPath = '/',
}: LoginOptions) => {
const { request } = page.context();
@@ -33,7 +26,6 @@ export const apiSignin = async ({
data: {
email,
password,
totpCode,
csrfToken,
},
});
@@ -1,27 +0,0 @@
import type { Page } from '@playwright/test';
/**
* Wait for React to hydrate the element matching the given selector.
*
* Filling controlled inputs before hydration is racy since React resets them
* to their default values once it takes over the DOM. React attaches internal
* fiber keys to DOM nodes during hydration, so their presence is a reliable
* signal that the element is interactive.
*/
export const waitForHydration = async (page: Page, selector: string, timeout = 15_000) => {
await page.waitForSelector(selector, { timeout });
await page.waitForFunction(
(sel) => {
const element = document.querySelector(sel);
if (!element) {
return false;
}
return Object.keys(element).some((key) => key.startsWith('__reactFiber'));
},
selector,
{ timeout },
);
};
@@ -1,111 +0,0 @@
import crypto from 'node:crypto';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { prisma } from '@documenso/prisma';
import { base32 } from '@scure/base';
import { generateHOTP } from 'oslo/otp';
/**
* Must match the period used by `verifyTwoFactorAuthenticationToken` in
* `packages/lib/server-only/2fa/verify-2fa-token.ts`.
*/
const TOTP_PERIOD_MS = 30_000;
/**
* Enables 2FA for an existing user using the exact storage format the server
* writes in `setup-2fa.ts`/`enable-2fa.ts` (base32 TOTP secret + JSON backup
* codes, both symmetrically encrypted with the instance encryption key).
*
* No mocks: the server validates codes generated from this secret with the
* same OTP library used here.
*/
export const seedUserTwoFactorAuthentication = async ({ userId }: { userId: number }) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error('NEXT_PRIVATE_ENCRYPTION_KEY must be set to seed 2FA users');
}
const secret = crypto.randomBytes(10);
const backupCodes = Array.from({ length: 10 })
.fill(null)
.map(() => crypto.randomBytes(5).toString('hex'))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
await prisma.user.update({
where: {
id: userId,
},
data: {
twoFactorEnabled: true,
twoFactorSecret: symmetricEncrypt({
key,
data: base32.encode(new Uint8Array(secret)),
}),
twoFactorBackupCodes: symmetricEncrypt({
key,
data: JSON.stringify(backupCodes),
}),
},
});
return {
secret,
backupCodes,
};
};
/**
* Computes the TOTP code for the current time window, mirroring the server's
* verification (`generateHOTP` with a 30 second period).
*/
export const generateTotpCode = async ({ secret }: { secret: Buffer }) => {
return await generateHOTP(new Uint8Array(secret), Math.floor(Date.now() / TOTP_PERIOD_MS));
};
/**
* The server only accepts the code for the current 30s window (window = 1).
* If we are close to a window boundary, wait for the next window so the code
* cannot expire between generation and verification.
*/
export const waitForStableTotpWindow = async () => {
const remainingMs = TOTP_PERIOD_MS - (Date.now() % TOTP_PERIOD_MS);
if (remainingMs < 5_000) {
await new Promise((resolve) => {
setTimeout(resolve, remainingMs + 250);
});
}
};
type SeedOrganisationTwoFactorEnforcementOptions = {
organisationId: string;
twoFactorRequired?: boolean;
twoFactorGracePeriodDays?: number;
};
/**
* Directly seeds the organisation-level 2FA enforcement settings, bypassing
* the tRPC settings write path (which is covered by its own tests).
*/
export const seedOrganisationTwoFactorEnforcement = async ({
organisationId,
twoFactorRequired = true,
twoFactorGracePeriodDays = 0,
}: SeedOrganisationTwoFactorEnforcementOptions) => {
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
organisationGlobalSettings: {
update: {
twoFactorRequired,
twoFactorGracePeriodDays,
twoFactorEnforcedFrom: twoFactorRequired ? new Date() : null,
},
},
},
});
};
@@ -29,11 +29,11 @@ test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
await page.waitForURL(`/settings/organisations`);
await expectTextToBeVisible(page, 'No results found');
await page.getByRole('button', { name: 'Create organization' }).click();
await page.getByRole('button', { name: 'Create organisation' }).click();
await page.getByLabel('Organization Name*').fill('test');
await page.getByLabel('Organisation Name*').fill('test');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Your organization has been created').first()).toBeVisible();
await expect(page.getByText('Your organisation has been created').first()).toBeVisible();
await page.reload();
await page.getByRole('row').filter({ hasText: 'test' }).getByRole('link').nth(1).click();
@@ -53,12 +53,12 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
const updatedOrganisationId = `organisation-${Date.now()}`;
// Update team.
await page.getByLabel('Organization Name*').click();
await page.getByLabel('Organization Name*').clear();
await page.getByLabel('Organization Name*').fill(updatedOrganisationId);
await page.getByLabel('Organization URL*').click();
await page.getByLabel('Organization URL*').clear();
await page.getByLabel('Organization URL*').fill(updatedOrganisationId);
await page.getByLabel('Organisation Name*').click();
await page.getByLabel('Organisation Name*').clear();
await page.getByLabel('Organisation Name*').fill(updatedOrganisationId);
await page.getByLabel('Organisation URL*').click();
await page.getByLabel('Organisation URL*').clear();
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
await page.getByRole('button', { name: 'Save changes' }).click();
@@ -277,8 +277,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Create a custom group A with 3 members "ORGANISATION ADMIN" to check that they get the correct roles.
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
await page.getByRole('option', { name: 'Organization Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member1' }).click();
await page.getByRole('option', { name: 'Member2' }).click();
@@ -291,16 +291,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm org roles have been applied to these members.
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail3)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3)).toBeVisible();
// Test updating the group.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('link', { name: 'Manage' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
await page.getByRole('combobox').filter({ hasText: 'Organization Admin' }).click();
await page.getByRole('option', { name: 'Organization Member' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
await page.getByRole('option', { name: 'Organisation Member' }).click();
// Remove Member3 by clicking the X on its chip in the multiselect.
await page
.getByTestId('group-members-picker')
@@ -327,16 +327,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm admins still get admin roles.
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail3)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3)).toBeVisible();
// Create another custom group with 3 members with "ORGANISATION MEMBER" role.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
await page.getByRole('option', { name: 'Organization Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member4' }).click();
await page.getByRole('option', { name: 'Member5' }).click();
@@ -537,6 +537,6 @@ test('[ORGANISATIONS]: leave organisation', async ({ page }) => {
await page.getByRole('button', { name: 'Leave' }).click();
await page.getByRole('button', { name: 'Leave' }).click();
await expect(page.getByText('You have successfully left this organization').first()).toBeVisible();
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
await expect(page.getByText('No results found').first()).toBeVisible();
});
@@ -7,8 +7,8 @@ import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const BANNER_EXCEEDED_TEXT = 'Your organization has exceeded a fair use limit';
const BANNER_NEARING_TEXT = 'Your organization is approaching a fair use limit';
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
const BANNER_NEARING_TEXT = 'Your organisation is approaching a fair use limit';
type SeedQuotaStateOptions = {
organisationId: string;
@@ -162,7 +162,7 @@ test('[QUOTA BANNER]: is hidden for free-claim organisations', async ({ page })
});
// Anchor on a stable element so banner-absence is meaningful (page fully loaded).
await expect(page.getByLabel('Organization Name*')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
await expect(page.getByRole('button', { name: 'Learn more' })).toBeHidden();
@@ -176,7 +176,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organization' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
@@ -254,9 +254,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page
.getByTestId('inheritable-email-document-settings')
.getByRole('combobox')
.filter({ hasText: 'Inherit from organization' })
.filter({ hasText: 'Inherit from organisation' })
.click();
await page.getByRole('option', { name: 'Override organization settings' }).click();
await page.getByRole('option', { name: 'Override organisation settings' }).click();
// Update some email settings
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
@@ -308,8 +308,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organization settings' }).click();
await page.getByRole('option', { name: 'Inherit from organization' }).click();
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
@@ -1,272 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import {
generateTotpCode,
seedOrganisationTwoFactorEnforcement,
seedUserTwoFactorAuthentication,
waitForStableTotpWindow,
} from '../fixtures/two-factor';
test('[ORGANISATIONS]: joining succeeds then org context is blocked at 0-day grace', async ({ page }) => {
const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false });
// The owner must satisfy the policy themselves to use the org settings
// pages while enforcement is active with a 0-day grace period.
const { secret: ownerSecret } = await seedUserTwoFactorAuthentication({ userId: owner.id });
await seedOrganisationTwoFactorEnforcement({
organisationId: organisation.id,
twoFactorGracePeriodDays: 0,
});
const { user: member } = await seedUser({ isPersonalOrganisation: false });
await waitForStableTotpWindow();
await apiSignin({
page,
email: owner.email,
totpCode: await generateTotpCode({ secret: ownerSecret }),
redirectPath: `/o/${organisation.url}/settings/members`,
});
// Invite the member while the 0-day enforcement policy is already active.
await page.getByRole('button', { name: 'Invite member' }).click();
await page.getByRole('textbox', { name: 'Email address *' }).fill(member.email);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('tab', { name: 'Pending' }).click();
await expect(page.getByText(member.email)).toBeVisible();
// Joining is never blocked: the member accepts the invite without 2FA.
await apiSignout({ page });
await apiSignin({ page, email: member.email, redirectPath: `/settings/organisations` });
await page.getByRole('button', { name: 'View invites' }).click();
await page.getByRole('button', { name: 'Accept' }).click();
await expect(page.getByText('Invitation accepted').first()).toBeVisible();
const membership = await prisma.organisationMember.findFirst({
where: {
userId: member.id,
organisationId: organisation.id,
},
});
expect(membership).not.toBeNull();
// Access, however, is blocked immediately (0-day grace): the org context
// renders the 403 screen linking to forced enrolment.
await page.goto(`/o/${organisation.url}`);
await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible();
await expect(page.getByText('403 Forbidden')).toBeVisible();
const enrolmentLink = page.getByRole('link', { name: 'Set up two-factor authentication' });
await expect(enrolmentLink).toBeVisible();
await expect(enrolmentLink).toHaveAttribute('href', /\/onboarding\/2fa\?returnTo=/);
// Team contexts resolve to the owning organisation and are blocked too.
await page.goto(`/t/${team.url}/documents`);
await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible();
// The security boundary: a blocked org-scoped tRPC call returns 403.
const blockedResponse = await page
.context()
.request.get(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.get?input=${encodeURIComponent(
JSON.stringify({ json: { organisationReference: organisation.url } }),
)}`,
);
expect(blockedResponse.status()).toBe(403);
// The rest of the app stays usable: the member's own organisation is
// unaffected.
await page.goto(`/settings/organisations`);
await expect(page.getByText('Two-factor authentication required')).not.toBeVisible();
});
test('[ORGANISATIONS]: member with satisfied 2FA is unaffected by enforcement', async ({ page }) => {
const { organisation, team } = await seedUser({ isPersonalOrganisation: false });
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const [member] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Member 2FA',
organisationRole: 'MEMBER',
},
],
organisationId: organisation.id,
});
const { secret } = await seedUserTwoFactorAuthentication({ userId: member.id });
await seedOrganisationTwoFactorEnforcement({
organisationId: organisation.id,
twoFactorGracePeriodDays: 0,
});
await waitForStableTotpWindow();
// Signing in with a valid TOTP code marks the session as second-factor
// verified, which satisfies enforcement.
await apiSignin({
page,
email: memberEmail,
totpCode: await generateTotpCode({ secret }),
redirectPath: `/o/${organisation.url}`,
});
await expect(page.getByText(team.name).first()).toBeVisible();
await expect(page.getByText('Two-factor authentication required')).not.toBeVisible();
await page.goto(`/t/${team.url}/documents`);
await expect(page.getByText('Two-factor authentication required')).not.toBeVisible();
const allowedResponse = await page
.context()
.request.get(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.get?input=${encodeURIComponent(
JSON.stringify({ json: { organisationReference: organisation.url } }),
)}`,
);
expect(allowedResponse.status()).toBe(200);
});
test('[ORGANISATIONS]: blocked member can leave the organisation', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const [member] = await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Blocked Member',
organisationRole: 'MEMBER',
},
],
organisationId: organisation.id,
});
await seedOrganisationTwoFactorEnforcement({
organisationId: organisation.id,
twoFactorGracePeriodDays: 0,
});
await apiSignin({
page,
email: memberEmail,
redirectPath: `/o/${organisation.url}`,
});
// Confirm the member is blocked from the organisation context.
await expect(page.getByRole('heading', { name: 'Two-factor authentication required' })).toBeVisible();
// A member must always be able to walk away: `organisation.leave` is on
// the remediation allow-list, so leaving works while blocked.
await page.goto('/settings/organisations');
await page.getByRole('button', { name: 'Leave' }).click();
await page.getByRole('button', { name: 'Leave' }).click();
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
await expect(page.getByText('No results found').first()).toBeVisible();
const membership = await prisma.organisationMember.findFirst({
where: {
userId: member.id,
organisationId: organisation.id,
},
});
expect(membership).toBeNull();
});
test('[ORGANISATIONS]: admin with satisfied 2FA can enable and persist enforcement settings', async ({ page }) => {
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
const { secret } = await seedUserTwoFactorAuthentication({ userId: owner.id });
await waitForStableTotpWindow();
await apiSignin({
page,
email: owner.email,
totpCode: await generateTotpCode({ secret }),
redirectPath: `/o/${organisation.url}/settings/general`,
});
const requireSwitch = page.getByRole('switch', { name: 'Require two-factor authentication' });
await expect(requireSwitch).toBeVisible();
await expect(requireSwitch).not.toBeChecked();
await requireSwitch.click();
await page.getByLabel('Grace period (days)').fill('30');
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByText('Two-factor enforcement settings updated').first()).toBeVisible();
// Roundtrip: values persist across a reload.
await page.reload();
await expect(page.getByRole('switch', { name: 'Require two-factor authentication' })).toBeChecked();
await expect(page.getByLabel('Grace period (days)')).toHaveValue('30');
const settings = await prisma.organisation.findFirstOrThrow({
where: {
id: organisation.id,
},
include: {
organisationGlobalSettings: true,
},
});
expect(settings.organisationGlobalSettings.twoFactorRequired).toBe(true);
expect(settings.organisationGlobalSettings.twoFactorGracePeriodDays).toBe(30);
expect(settings.organisationGlobalSettings.twoFactorEnforcedFrom).not.toBeNull();
});
test('[ORGANISATIONS]: admin without 2FA cannot enable enforcement', async ({ page }) => {
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: owner.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
const requireSwitch = page.getByRole('switch', { name: 'Require two-factor authentication' });
await expect(requireSwitch).toBeVisible();
await requireSwitch.click();
await page.getByRole('button', { name: 'Update' }).click();
// Enable-time guard: the acting admin must already satisfy the policy
// being enabled.
await expect(
page.getByText('You must have two-factor authentication enabled and verified on this session').first(),
).toBeVisible();
const settings = await prisma.organisation.findFirstOrThrow({
where: {
id: organisation.id,
},
include: {
organisationGlobalSettings: true,
},
});
expect(settings.organisationGlobalSettings.twoFactorRequired).toBe(false);
});
@@ -248,7 +248,7 @@ test.describe('Unified Settings', () => {
// Wait for the organisation page to actually render — asserting straight after
// `waitForURL` can read the previous scope's still-mounted sidebar and pass falsely.
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organization Settings');
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation Settings');
await expect(trigger).toContainText(selected.name);
@@ -283,7 +283,7 @@ test.describe('Unified Settings', () => {
// Selecting the inherit option stages the field back to inherited.
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: /inherit from organization/i }).click();
await page.getByRole('option', { name: /inherit from organisation/i }).click();
await expect(langStatus).toHaveText(/inherited/i);
});
@@ -450,7 +450,7 @@ test.describe('Unified Settings', () => {
// An empty group would just look broken, so it explains itself directly under the switcher.
const emptyState = sidebar.getByTestId('unified-settings-organisation-empty-state');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText(/permission to manage this organization/i);
await expect(emptyState).toContainText(/permission to manage this organisation/i);
// Team and account pages remain navigable.
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
@@ -1,105 +0,0 @@
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { base32 } from '@scure/base';
import { generateHOTP } from 'oslo/otp';
import { apiSignin } from '../fixtures/authentication';
import { waitForHydration } from '../fixtures/hydration';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
/**
* Derive the current TOTP for a user the same way `verifyTwoFactorAuthenticationToken` does.
*/
const getCurrentTotpCode = async (userId: number) => {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
if (!DOCUMENSO_ENCRYPTION_KEY || !user.twoFactorSecret) {
throw new Error('Expected encryption key and 2FA secret');
}
const secret = Buffer.from(symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_KEY, data: user.twoFactorSecret })).toString(
'utf-8',
);
return await generateHOTP(base32.decode(secret), Math.floor(Date.now() / 30_000));
};
test('[USER] password update requires a 2FA code when 2FA is enabled', async ({ page }) => {
const oldPassword = 'password';
const newPassword = 'Test123!';
const { user } = await seedUser({ password: oldPassword });
// Sign in before enabling 2FA since apiSignin does not send a code.
await apiSignin({ page, email: user.email, password: oldPassword, redirectPath: '/settings/profile' });
await setupTwoFactorAuthentication({ user });
const userWithSecret = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
await enableTwoFactorAuthentication({ user: userWithSecret, code: await getCurrentTotpCode(user.id) });
await page.goto('/settings/security');
await waitForHydration(page, 'input[name="currentPassword"]');
await page.getByLabel('Current password').fill(oldPassword);
await page.getByLabel('New password').fill(newPassword);
await page.getByLabel('Repeat password').fill(newPassword);
await page.getByRole('button', { name: 'Update password' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Two-Factor Authentication')).toBeVisible();
// Empty code is caught client-side.
await dialog.getByRole('button', { name: 'Update password' }).click();
await expect(dialog.getByText('A code is required')).toBeVisible();
const codeInput = dialog.locator('input').first();
// Wrong code is rejected server-side and the dialog stays open.
await codeInput.fill('000000');
await dialog.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('The two factor code you provided is invalid');
await expect(dialog).toBeVisible();
// Correct code updates the password.
await codeInput.fill('');
await codeInput.fill(await getCurrentTotpCode(user.id));
await dialog.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('Password updated');
await expect(dialog).not.toBeVisible();
const updatedUser = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
expect(updatedUser.password).not.toBe(userWithSecret.password);
});
test('[USER] password update API rejects a missing 2FA code when 2FA is enabled', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email, redirectPath: '/settings/profile' });
await setupTwoFactorAuthentication({ user });
const userWithSecret = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
await enableTwoFactorAuthentication({ user: userWithSecret, code: await getCurrentTotpCode(user.id) });
const response = await page.request.post('/api/auth/email-password/update-password', {
data: { currentPassword: 'password', password: 'Test123!' },
});
expect(response.status()).toBe(400);
expect(await response.json()).toMatchObject({ code: 'TWO_FACTOR_MISSING_CREDENTIALS' });
const unchangedUser = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
expect(unchangedUser.password).toBe(userWithSecret.password);
});
@@ -1,64 +0,0 @@
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { UserSecurityAuditLogType } from '@prisma/client';
import { checkSessionValid } from '../fixtures/authentication';
import { seedUserTwoFactorAuthentication } from '../fixtures/two-factor';
test('[USER] backup code sign-in resets 2FA and lands on the re-enrolment page', async ({ page }) => {
const { user } = await seedUser();
const { backupCodes } = await seedUserTwoFactorAuthentication({ userId: user.id });
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
// The missing second factor opens the 2FA dialog.
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Two-Factor Authentication')).toBeVisible();
await dialog.getByRole('button', { name: 'Use Backup Code' }).click();
await dialog.getByLabel('Backup Code').fill(backupCodes[0]);
await dialog.getByRole('button', { name: 'Sign In' }).click();
// Backup codes are recovery, not sign-in: the server resets 2FA and the
// client is redirected to the forced re-enrolment page.
await page.waitForURL(/\/onboarding\/2fa/);
await expect(page.getByRole('heading', { name: 'Two-factor authentication' })).toBeVisible();
// Enforcement is not active for this user, so the page offers an explicit
// skip link instead of auto-redirecting away.
await expect(page.getByRole('link', { name: 'Skip for now' })).toBeVisible();
// The recovery sign-in still authorizes a session.
expect(await checkSessionValid(page)).toBe(true);
// The reset is atomic: 2FA disabled, secret and backup codes cleared.
const updatedUser = await prisma.user.findFirstOrThrow({
where: {
id: user.id,
},
});
expect(updatedUser.twoFactorEnabled).toBe(false);
expect(updatedUser.twoFactorSecret).toBeNull();
expect(updatedUser.twoFactorBackupCodes).toBeNull();
// The recovery reset is audit-logged.
const auditLog = await prisma.userSecurityAuditLog.findFirst({
where: {
userId: user.id,
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
},
});
expect(auditLog).not.toBeNull();
// A consumed backup code cannot be used to sign in again: 2FA is no longer
// enabled, so a plain password sign-in succeeds and re-enrolment starts
// from scratch.
});
+6 -71
View File
@@ -5,14 +5,13 @@ import { hc } from 'hono/client';
import superjson from 'superjson';
import type { AuthAppType } from '../server';
import type { SessionValidationResult } from '../server/lib/session/session';
import type { PartialAccount } from '../server/lib/utils/get-accounts';
import type { ActiveSession } from '../server/lib/utils/get-session';
import { handleSignInRedirect } from '../server/lib/utils/redirect';
import type { TSessionJsonResponse } from '../server/routes/session';
import type {
TDisableTwoFactorRequestSchema,
TEnableTwoFactorRequestSchema,
TVerifyTwoFactorChallengeRequestSchema,
TViewTwoFactorRecoveryCodesRequestSchema,
} from '../server/routes/two-factor.types';
import type {
@@ -30,8 +29,9 @@ type TEmailPasswordSignin = InferRequestType<AuthClientType['email-password']['a
redirectPath?: string;
};
// `redirectPath` is part of the request schema and validated server-side.
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'];
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
redirectPath?: string;
};
export class AuthClient {
public client: AuthClientType;
@@ -71,7 +71,7 @@ export class AuthClient {
const result = await response.json();
return superjson.deserialize<TSessionJsonResponse>(result);
return superjson.deserialize<SessionValidationResult>(result);
}
public async getSessions() {
@@ -146,20 +146,6 @@ export class AuthClient {
throw AppError.parseError(error);
}
const result = await response.json();
// The server overrides the redirect when the sign-in requires a
// follow-up page, e.g. a backup-code sign-in resets 2FA and lands on
// the re-enrolment page. The caller's redirect path is preserved as
// `returnTo` (validated by the target page before use).
if (result.redirectPath) {
const returnTo = data.redirectPath ? `?returnTo=${encodeURIComponent(data.redirectPath)}` : '';
handleSignInRedirect(`${result.redirectPath}${returnTo}`);
return;
}
handleSignInRedirect(data.redirectPath);
},
@@ -259,8 +245,6 @@ export class AuthClient {
throw AppError.parseError(error);
}
return response.json();
},
viewRecoveryCodes: async (data: TViewTwoFactorRecoveryCodesRequestSchema) => {
const response = await this.client['two-factor']['view-recovery-codes'].$post({ json: data });
@@ -273,51 +257,6 @@ export class AuthClient {
return response.json();
},
/**
* Check whether a pending 2FA challenge exists for this browser, so the
* /2fa-challenge page can bounce back to sign-in when there is none.
*/
getChallenge: async () => {
const response = await this.client['two-factor'].challenge.$get();
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
return response.json();
},
/**
* Verify the second factor for a pending 2FA challenge. Fetches a fresh
* CSRF token first since the challenge page is reached via a 302
* redirect, not the sign-in form.
*/
verifyChallenge: async (data: Omit<TVerifyTwoFactorChallengeRequestSchema, 'csrfToken'>) => {
const { csrfToken } = await this.client.csrf.$get().then(async (res) => res.json());
const response = await this.client['two-factor'].challenge.$post({
json: {
...data,
csrfToken,
},
});
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
const result = await response.json();
// The server returns the validated redirect path stored when the
// challenge was created (or the re-enrolment page after a backup-code
// recovery). Navigation goes through the same-origin redirect helper.
handleSignInRedirect(result.redirectPath);
},
};
public passkey = {
@@ -330,11 +269,7 @@ export class AuthClient {
throw AppError.parseError(error);
}
const result = await response.json();
// The server validates the requested redirect path and echoes back a
// safe same-origin path (falling back to `/`).
handleSignInRedirect(result.url);
handleSignInRedirect(data.redirectPath);
},
};
@@ -17,9 +17,6 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
// A pending 2FA challenge is missing, expired, or exhausted — the client
// must restart the sign-in flow.
TwoFactorChallengeExpired: 'TWO_FACTOR_CHALLENGE_EXPIRED',
SigninDisabled: 'SIGNIN_DISABLED',
SignupDisabled: 'SIGNUP_DISABLED',
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
@@ -11,7 +11,7 @@ import { generateSessionToken } from './session';
export const sessionCookieName = formatSecureCookieName('sessionId');
export const csrfCookieName = formatSecureCookieName('csrfToken');
export const getAuthSecret = () => {
const getAuthSecret = () => {
const authSecret = env('NEXTAUTH_SECRET');
if (!authSecret) {
+2 -33
View File
@@ -1,5 +1,4 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TSessionAuthMethod } from '@documenso/lib/types/session-auth-method';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { sha256 } from '@oslojs/crypto/sha2';
@@ -15,16 +14,7 @@ import { AUTH_SESSION_LIFETIME } from '../../config';
*/
export type SessionUser = Pick<
User,
| 'id'
| 'name'
| 'email'
| 'emailVerified'
| 'avatarImageId'
| 'twoFactorEnabled'
| 'twoFactorGraceStartedAt'
| 'roles'
| 'signature'
| 'disabled'
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled'
>;
export type SessionValidationResult =
@@ -45,25 +35,7 @@ export const generateSessionToken = (): string => {
return token;
};
export type CreateSessionOptions = {
/**
* The method used to authenticate this session.
*/
authMethod: TSessionAuthMethod;
/**
* Whether a second factor was passed during sign-in (TOTP/backup challenge
* or UV passkey).
*/
twoFactorVerified: boolean;
};
export const createSession = async (
token: string,
userId: number,
metadata: RequestMetadata,
options: CreateSessionOptions,
): Promise<Session> => {
export const createSession = async (token: string, userId: number, metadata: RequestMetadata): Promise<Session> => {
const hashedSessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: Session = {
@@ -75,8 +47,6 @@ export const createSession = async (
expiresAt: new Date(Date.now() + AUTH_SESSION_LIFETIME),
ipAddress: metadata.ipAddress ?? null,
userAgent: metadata.userAgent ?? null,
authMethod: options.authMethod,
twoFactorVerified: options.twoFactorVerified,
};
await prisma.session.create({
@@ -114,7 +84,6 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
emailVerified: true,
avatarImageId: true,
twoFactorEnabled: true,
twoFactorGraceStartedAt: true,
roles: true,
signature: true,
disabled: true,
+1 -24
View File
@@ -1,26 +1,12 @@
import { assertUserNotDisabledById } from '@documenso/lib/server-only/user/assert-user-not-disabled';
import type { TSessionAuthMethod } from '@documenso/lib/types/session-auth-method';
import type { Context } from 'hono';
import type { HonoAuthContext } from '../../types/context';
import { createSession, generateSessionToken } from '../session/session';
import { setSessionCookie } from '../session/session-cookies';
import { sweepPendingTwoFactorChallenge } from './two-factor-challenge';
type AuthorizeUser = {
userId: number;
/**
* The method the user authenticated with. Every sign-in route must pass
* this explicitly.
*/
authMethod: TSessionAuthMethod;
/**
* Whether a second factor was passed during this sign-in (inline TOTP on
* email/password login, or a UV passkey).
*/
twoFactorVerified: boolean;
};
/**
@@ -34,20 +20,11 @@ type AuthorizeUser = {
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
await assertUserNotDisabledById({ userId: user.userId });
// Any successful sign-in clears a pending 2FA challenge — an abandoned
// challenge must not outlive a login via another route. The challenge
// endpoint consumes its own token before calling `onAuthorize`, so this
// sweep finds nothing there (no recursion, no double-consumption).
await sweepPendingTwoFactorChallenge(c);
const metadata = c.get('requestMetadata');
const sessionToken = generateSessionToken();
await createSession(sessionToken, user.userId, metadata, {
authMethod: user.authMethod,
twoFactorVerified: user.twoFactorVerified,
});
await createSession(sessionToken, user.userId, metadata);
await setSessionCookie(c, sessionToken);
};
@@ -59,8 +59,6 @@ export const getActiveSessions = async (c: Context | Request): Promise<ActiveSes
createdAt: true,
ipAddress: true,
userAgent: true,
authMethod: true,
twoFactorVerified: true,
},
});
};
@@ -20,7 +20,6 @@ import type { OAuthClientOptions } from '../../config';
import { AuthenticationErrorCode } from '../errors/error-codes';
import { onAuthorize } from './authorizer';
import { getOpenIdConfiguration } from './open-id';
import { createTwoFactorChallenge } from './two-factor-challenge';
type HandleOAuthCallbackUrlOptions = {
c: Context;
@@ -51,7 +50,6 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
user: {
select: {
id: true,
twoFactorEnabled: true,
},
},
},
@@ -59,21 +57,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
// Directly log in user if account already exists.
if (existingAccount) {
// A 2FA-enabled user must pass a TOTP/backup challenge before any session
// exists — primary (OAuth) auth alone only earns a pending challenge.
if (existingAccount.user.twoFactorEnabled) {
await createTwoFactorChallenge(c, {
userId: existingAccount.user.id,
metadata: {
redirectPath,
authMethod: 'oauth',
},
});
return c.redirect('/2fa-challenge', 302);
}
await onAuthorize({ userId: existingAccount.user.id, authMethod: 'oauth', twoFactorVerified: false }, c);
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(redirectPath, 302);
}
@@ -85,37 +69,11 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
select: {
id: true,
emailVerified: true,
twoFactorEnabled: true,
},
});
// Handle existing user but no account.
if (userWithSameEmail) {
// Deferred account linking: when the target user has 2FA enabled, NO
// account mutation may happen before the code verifies. The entire link
// transaction below is deferred into the challenge metadata `action` and
// executed atomically with token consumption after the code passes.
//
// Access/ID tokens are intentionally NOT stored in the metadata — the
// deferred account row is created without them.
if (userWithSameEmail.twoFactorEnabled) {
await createTwoFactorChallenge(c, {
userId: userWithSameEmail.id,
metadata: {
redirectPath,
authMethod: 'oauth',
action: {
type: 'link-oauth-account',
provider: clientOptions.id,
providerAccountId: sub,
email,
},
},
});
return c.redirect('/2fa-challenge', 302);
}
await prisma.$transaction(async (tx) => {
await tx.account.create({
data: {
@@ -156,7 +114,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
}
});
await onAuthorize({ userId: userWithSameEmail.id, authMethod: 'oauth', twoFactorVerified: false }, c);
await onAuthorize({ userId: userWithSameEmail.id }, c);
return c.redirect(redirectPath, 302);
}
@@ -221,7 +179,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
console.error(err);
});
await onAuthorize({ userId: createdUser.id, authMethod: 'oauth', twoFactorVerified: false }, c);
await onAuthorize({ userId: createdUser.id }, c);
return c.redirect(redirectPath, 302);
};
@@ -12,7 +12,6 @@ import { AuthenticationErrorCode } from '../errors/error-codes';
import { onAuthorize } from './authorizer';
import { validateOauth } from './handle-oauth-callback-url';
import { getOrganisationAuthenticationPortalOptions } from './organisation-portal';
import { createTwoFactorChallenge } from './two-factor-challenge';
type HandleOAuthOrganisationCallbackUrlOptions = {
c: Context;
@@ -27,7 +26,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
organisationUrl: orgUrl,
});
const { email, name, sub } = await validateOauth({
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
c,
clientOptions: {
...clientOptions,
@@ -56,21 +55,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
// Directly log in user if account already exists.
if (existingAccount) {
// A 2FA-enabled user must pass a TOTP/backup challenge before any session
// exists — primary (org OIDC) auth alone only earns a pending challenge.
if (existingAccount.user.twoFactorEnabled) {
await createTwoFactorChallenge(c, {
userId: existingAccount.user.id,
metadata: {
redirectPath: `/o/${orgUrl}`,
authMethod: 'oauth',
},
});
return c.redirect('/2fa-challenge', 302);
}
await onAuthorize({ userId: existingAccount.user.id, authMethod: 'oauth', twoFactorVerified: false }, c);
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
}
@@ -118,16 +103,16 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
});
}
// Note: only the provider subject crosses into the verification token
// metadata — access/ID tokens are deliberately not persisted (see
// ZOrganisationAccountLinkMetadataSchema).
await sendOrganisationAccountLinkConfirmationEmail({
type: userToLink.emailVerified ? 'link' : 'create',
userId: userToLink.id,
organisationId: organisation.id,
organisationName: organisation.name,
oauthConfig: {
accessToken,
idToken,
providerAccountId: sub,
expiresAt: Math.floor(accessTokenExpiresAt.getTime() / 1000),
},
});
@@ -1,377 +0,0 @@
import { formatSecureCookieName } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TTwoFactorChallengeAction, TTwoFactorChallengeMetadata } from '@documenso/lib/types/two-factor-challenge';
import { ZTwoFactorChallengeMetadataSchema } from '@documenso/lib/types/two-factor-challenge';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
isChallengeExpired,
shouldConsumeChallengeAfterFailure,
TWO_FACTOR_CHALLENGE_LIFETIME_MS,
TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS,
TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER,
} from '@documenso/lib/utils/two-factor-challenge';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@prisma/client';
import { UserSecurityAuditLogType } from '@prisma/client';
import crypto from 'crypto';
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { AuthenticationErrorCode } from '../errors/error-codes';
import { getAuthSecret, sessionCookieOptions } from '../session/session-cookies';
/**
* Pending 2FA challenge plumbing for sign-in flows where the second factor
* arrives in a later request than primary authentication (OAuth/OIDC
* callbacks).
*
* The challenge is a random token stored in `VerificationToken` plus a signed
* HttpOnly cookie carrying that token. The token is NOT a second factor — it
* only carries "primary auth passed for user X" across the redirect, since no
* session may exist before the TOTP/backup code is verified. Code
* verification itself is the same `validateTwoFactorAuthentication` used by
* the email/password flow.
*/
const twoFactorChallengeCookieName = formatSecureCookieName('twoFactorChallenge');
export type PendingTwoFactorChallenge = {
id: number;
userId: number;
attempts: number;
metadata: TTwoFactorChallengeMetadata;
};
export type CreateTwoFactorChallengeOptions = {
userId: number;
metadata: TTwoFactorChallengeMetadata;
};
/**
* Issue a pending 2FA challenge for a user whose primary authentication has
* succeeded, and attach the signed challenge cookie to the response.
*
* The metadata is round-tripped through the strict schema so an invalid
* redirect path or a payload carrying unexpected keys (e.g. provider tokens)
* can never be persisted.
*/
export const createTwoFactorChallenge = async (c: Context, options: CreateTwoFactorChallengeOptions): Promise<void> => {
const metadata = ZTwoFactorChallengeMetadataSchema.parse(options.metadata);
const token = crypto.randomBytes(32).toString('hex');
await prisma.verificationToken.create({
data: {
identifier: TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER,
token,
expires: new Date(Date.now() + TWO_FACTOR_CHALLENGE_LIFETIME_MS),
metadata,
userId: options.userId,
},
});
await setSignedCookie(c, twoFactorChallengeCookieName, token, getAuthSecret(), {
...sessionCookieOptions,
maxAge: Math.floor(TWO_FACTOR_CHALLENGE_LIFETIME_MS / 1000),
});
};
export const clearTwoFactorChallengeCookie = (c: Context): void => {
deleteCookie(c, twoFactorChallengeCookieName, sessionCookieOptions);
};
/**
* Resolve the pending challenge for the current request, if any.
*
* Expired, exhausted, or malformed challenges are consumed and the cookie is
* cleared — callers uniformly receive `null` and should tell the client to
* restart sign-in.
*/
export const getPendingTwoFactorChallenge = async (c: Context): Promise<PendingTwoFactorChallenge | null> => {
const token = await getSignedCookie(c, getAuthSecret(), twoFactorChallengeCookieName);
if (!token) {
return null;
}
const row = await prisma.verificationToken.findFirst({
where: {
token,
identifier: TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER,
},
});
if (!row) {
clearTwoFactorChallengeCookie(c);
return null;
}
const metadataResult = ZTwoFactorChallengeMetadataSchema.safeParse(row.metadata);
const isUsable =
metadataResult.success &&
!isChallengeExpired({ expiresAt: row.expires, now: new Date() }) &&
!shouldConsumeChallengeAfterFailure(row.attempts);
if (!isUsable) {
// `deleteMany` so a concurrent consumption is a no-op instead of a P2025.
await prisma.verificationToken.deleteMany({
where: {
id: row.id,
},
});
clearTwoFactorChallengeCookie(c);
return null;
}
return {
id: row.id,
userId: row.userId,
attempts: row.attempts,
metadata: metadataResult.data,
};
};
export type RecordTwoFactorChallengeFailureOptions = {
challenge: PendingTwoFactorChallenge;
requestMetadata: RequestMetadata;
};
/**
* Record a failed code attempt against a pending challenge.
*
* Failed codes do not consume the token but atomically increment its attempt
* counter. The rate limiter fails open on DB errors, so this counter is the
* hard bound on guesses per challenge — once it reaches the cap the challenge
* is consumed and sign-in must restart.
*/
export const recordTwoFactorChallengeFailure = async (
c: Context,
{ challenge, requestMetadata }: RecordTwoFactorChallengeFailureOptions,
): Promise<{ isExhausted: boolean }> => {
// Conditional increment: only counts while under the cap so the counter
// cannot be raced past it.
const { count } = await prisma.verificationToken.updateMany({
where: {
id: challenge.id,
attempts: {
lt: TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS,
},
},
data: {
attempts: {
increment: 1,
},
},
});
await prisma.userSecurityAuditLog.create({
data: {
userId: challenge.userId,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
},
});
if (count === 0) {
// Already at the cap (or deleted) via concurrent requests.
await prisma.verificationToken.deleteMany({
where: {
id: challenge.id,
},
});
clearTwoFactorChallengeCookie(c);
return { isExhausted: true };
}
const updated = await prisma.verificationToken.findFirst({
where: {
id: challenge.id,
},
select: {
attempts: true,
},
});
const isExhausted = shouldConsumeChallengeAfterFailure(updated?.attempts ?? Number.MAX_SAFE_INTEGER);
if (isExhausted) {
await prisma.verificationToken.deleteMany({
where: {
id: challenge.id,
},
});
clearTwoFactorChallengeCookie(c);
}
return { isExhausted };
};
/**
* Consume a pending challenge on success.
*
* Uses `deleteMany` + count check so a concurrent replay of the same
* challenge errors cleanly instead of surfacing a P2025 as a 500.
*/
export const consumeTwoFactorChallenge = async (tx: Prisma.TransactionClient, challengeId: number): Promise<void> => {
const { count } = await tx.verificationToken.deleteMany({
where: {
id: challengeId,
},
});
if (count === 0) {
throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, {
message: 'The two factor challenge has already been consumed.',
statusCode: 401,
});
}
};
export type ExecuteTwoFactorChallengeActionOptions = {
action: TTwoFactorChallengeAction;
userId: number;
requestMetadata: RequestMetadata;
};
/**
* Execute the deferred OAuth account-link action after the code verified.
*
* The invariant this preserves: NO account mutation happens before the second
* factor passes. The entire link transaction the OAuth callback would have
* run inline (account row, email-verification/password clearing, link audit
* log) is recreated here, in the same transaction as token consumption,
* re-validating that the world has not changed since the callback.
*/
export const executeTwoFactorChallengeAction = async (
tx: Prisma.TransactionClient,
{ action, userId, requestMetadata }: ExecuteTwoFactorChallengeActionOptions,
): Promise<void> => {
const user = await tx.user.findFirst({
where: {
id: userId,
},
select: {
id: true,
email: true,
emailVerified: true,
disabled: true,
},
});
// Re-validate: the target user must still exist and must not be disabled.
if (!user || user.disabled) {
throw new AppError(AuthenticationErrorCode.AccountDisabled, {
message: 'Account is not eligible for linking.',
statusCode: 403,
});
}
// Re-validate: the email must be unchanged since the OAuth callback — a
// changed email means the provider account may no longer belong to this
// user.
if (user.email !== action.email) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Account email has changed since the sign-in was initiated.',
statusCode: 400,
});
}
const existingAccount = await tx.account.findFirst({
where: {
provider: action.provider,
providerAccountId: action.providerAccountId,
},
select: {
userId: true,
},
});
// Re-validate: the provider account must not already be linked. Linked to
// the same user is an idempotent no-op; linked to another user is a
// conflict.
if (existingAccount) {
if (existingAccount.userId !== user.id) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'This provider account is already linked to another user.',
statusCode: 400,
});
}
return;
}
// The deferred account row is created WITHOUT access/ID tokens — they are
// intentionally never stored in challenge metadata, so they are not
// available here. This is deliberate: challenge metadata sits in the
// database on the strength of primary auth alone.
await tx.account.create({
data: {
type: 'oauth',
provider: action.provider,
providerAccountId: action.providerAccountId,
userId: user.id,
},
});
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
},
});
// Mirrors the inline OAuth link path: if the user was unverified, the OAuth
// provider has now verified the email, and the password is removed since we
// cannot confirm it was set by the real owner of the email.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
},
});
}
};
/**
* Best-effort cleanup used by `onAuthorize`: any successful sign-in clears a
* pending challenge cookie and deletes its token — an abandoned challenge
* must not outlive a login via another route.
*
* The challenge endpoint itself consumes its own token BEFORE calling
* `onAuthorize`, so this sweep finds nothing to delete there and only clears
* the (already superseded) cookie.
*/
export const sweepPendingTwoFactorChallenge = async (c: Context): Promise<void> => {
try {
const token = await getSignedCookie(c, getAuthSecret(), twoFactorChallengeCookieName);
if (!token) {
return;
}
await prisma.verificationToken.deleteMany({
where: {
token,
identifier: TWO_FACTOR_CHALLENGE_TOKEN_IDENTIFIER,
},
});
clearTwoFactorChallengeCookie(c);
} catch {
// A failed sweep must never block a successful sign-in.
}
};
+163 -65
View File
@@ -7,9 +7,12 @@ import {
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa/is-2fa-availble';
import { resetTwoFactorAfterBackupCodeUse } from '@documenso/lib/server-only/2fa/reset-2fa-after-backup-code-use';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { verifyCaptchaToken } from '@documenso/lib/server-only/captcha/verify-captcha';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import {
@@ -18,11 +21,9 @@ import {
resendVerifyEmailRateLimit,
resetPasswordRateLimit,
signupRateLimit,
updatePasswordRateLimit,
verifyEmailRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
import { assertUserNotDisabled } from '@documenso/lib/server-only/user/assert-user-not-disabled';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
@@ -39,6 +40,7 @@ import { UserSecurityAuditLogType } from '@prisma/client';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { invalidateSessions } from '../lib/session/session';
@@ -133,16 +135,14 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
let isBackupCodeRecovery = false;
if (is2faEnabled) {
const validationResult = await validateTwoFactorAuthentication({
const isValid = await validateTwoFactorAuthentication({
backupCode,
totpCode,
user,
});
if (!validationResult.isValid) {
if (!isValid) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
@@ -154,8 +154,6 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
}
isBackupCodeRecovery = validationResult.method === 'backup';
}
if (!user.emailVerified) {
@@ -181,44 +179,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
// A rejected sign-in must never strip 2FA, so every sign-in guard runs
// before the recovery reset below: the disabled check here (onAuthorize
// re-checks it as defence in depth) and the unverified-email guard above.
assertUserNotDisabled(user);
// Backup codes are recovery, not sign-in: a successful backup-code
// sign-in atomically resets the user's 2FA configuration (conditional
// update — concurrent uses cannot double-spend) and the client is told to
// land on the re-enrolment page.
if (isBackupCodeRecovery) {
await resetTwoFactorAfterBackupCodeUse({ user, requestMetadata });
}
// The disabled check now lives inside `onAuthorize` so every sign-in path
// (password, passkey, OAuth, OIDC) shares the same enforcement.
//
// If 2FA is enabled we only reach this point after the inline TOTP/backup
// validation above has passed, so the session counts as second-factor
// verified. A backup code IS a second factor, so recovery sign-ins are
// verified too.
await onAuthorize(
{
userId: user.id,
authMethod: 'email-password',
twoFactorVerified: is2faEnabled,
},
c,
);
await onAuthorize({ userId: user.id }, c);
return c.json(
{
// Non-null when the server needs the client to land somewhere
// specific instead of its own redirect path. Currently only the
// post-recovery re-enrolment page.
redirectPath: isBackupCodeRecovery ? '/onboarding/2fa' : null,
},
201,
);
return c.text('', 201);
})
/**
* Signup endpoint.
@@ -283,7 +248,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Update password endpoint.
*/
.post('/update-password', sValidator('json', ZUpdatePasswordSchema), async (c) => {
const { password, currentPassword, totpCode, backupCode } = c.req.valid('json');
const { password, currentPassword } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
@@ -294,25 +259,10 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { session, user } = await getSession(c);
const updateLimitResult = await updatePasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: String(user.id),
});
const updateLimited = rateLimitResponse(c, updateLimitResult);
if (updateLimited) {
throw new HTTPException(429, {
res: updateLimited,
});
}
await updatePassword({
userId: user.id,
password,
currentPassword,
totpCode,
backupCode,
requestMetadata,
});
@@ -366,7 +316,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
// If email is verified, automatically authenticate user.
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
await onAuthorize({ userId, authMethod: 'email-password', twoFactorVerified: false }, c);
await onAuthorize({ userId }, c);
}
return c.json({
@@ -504,8 +454,156 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
}
return c.text('OK', 201);
});
})
/**
* Setup two factor authentication.
*/
.post('/2fa/setup', async (c) => {
const { user } = await getSession(c);
// Note: The duplicated `/2fa/*` endpoints previously mounted here have been
// consolidated into the `/two-factor` route (`./two-factor.ts`), which is the
// only set of 2FA endpoints the auth client calls.
const result = await setupTwoFactorAuthentication({
user,
});
return c.json({
success: true,
secret: result.secret,
uri: result.uri,
});
})
/**
* Enable two factor authentication.
*/
.post(
'/2fa/enable',
sValidator(
'json',
z.object({
code: z.string(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { code } = c.req.valid('json');
const result = await enableTwoFactorAuthentication({
user,
code,
requestMetadata,
});
return c.json({
success: true,
recoveryCodes: result.recoveryCodes,
});
},
)
/**
* Disable two factor authentication.
*/
.post(
'/2fa/disable',
sValidator(
'json',
z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { totpCode, backupCode } = c.req.valid('json');
await disableTwoFactorAuthentication({
user,
totpCode,
backupCode,
requestMetadata,
});
return c.text('OK', 201);
},
)
/**
* View backup codes.
*/
.post(
'/2fa/view-recovery-codes',
sValidator(
'json',
z.object({
token: z.string(),
}),
),
async (c) => {
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { token } = c.req.valid('json');
const backupCodes = await viewBackupCodes({
user,
token,
});
return c.json({
success: true,
backupCodes,
});
},
);
+3 -16
View File
@@ -6,7 +6,6 @@ import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/servi
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { getAuthenticatorOptions } from '@documenso/lib/utils/authenticator';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { UserSecurityAuditLogType } from '@prisma/client';
@@ -38,7 +37,7 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
});
}
const { csrfToken, credential, redirectPath } = c.req.valid('json');
const { csrfToken, credential } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
@@ -105,12 +104,6 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
// The library defaults this to true — stated explicitly because the
// resulting session counts as second-factor verified, which is only
// sound if the authenticator performed user verification (PIN or
// biometric). See the matching `userVerification: 'required'` in
// `create-passkey-signin-options.ts`.
requireUserVerification: true,
credential: {
id: isoBase64URL.fromBuffer(passkey.credentialId),
publicKey: new Uint8Array(passkey.credentialPublicKey),
@@ -141,17 +134,11 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
},
});
// A passkey is a trusted second factor (user verification enforced
// above), so the session counts as second-factor verified.
await onAuthorize({ userId: user.id, authMethod: 'passkey', twoFactorVerified: true }, c);
// Honor the client's redirect path only when it is a valid same-origin
// path.
const url = isValidReturnTo(redirectPath) ? (normalizeReturnTo(redirectPath) ?? '/') : '/';
await onAuthorize({ userId: user.id }, c);
return c.json(
{
url,
url: '/',
},
200,
);

Some files were not shown because too many files have changed in this diff Show More