Compare commits

..
Author SHA1 Message Date
Lucas Smith ef31a16b99 Merge branch 'main' into feat/instance-2fa 2026-09-15 14:42:25 +10:00
Lucas Smith e73450dd08 chore: upgrade deps (#3367) 2026-09-14 16:14:44 +10:00
Lucas Smith fe2641ff57 chore: tests 2026-09-14 14:16:23 +10:00
Christopher Ryan 5603a9e59d fix: preserve base path in email asset URLs (#3327) 2026-09-11 20:44:05 +10:00
Durgesh Shekhawat 20ae849a1a fix: use nuqs prevent infinite re-render loop (#2868) 2026-09-11 20:29:24 +10:00
Lucas Smith 6a99b40cba fix: improve action auth flow for passwordless users (#3358)
Selecting password auth failed with a generic "Unauthorized" for users
who signed up via OAuth or passkey, with no way to set one.

Detect the missing password and email the existing reset link from the
signing dialog and security settings. Require a 2FA code and rate limit
update-password.
2026-09-11 14:17:34 +10:00
Catalin Pit e1ad4a2c55 docs: add externalId to send-for-signature workflow example (#3325) 2026-09-10 14:41:05 +03:00
Konrad 82918163c5 fix(i18n): add missing plural in bulk template upload error handling (#3357) 2026-09-10 21:01:17 +10:00
Catalin Pit b97c22a607 fix: pass csp nonce to @hello-pangea/dnd style elements (#3354) 2026-09-10 15:29:41 +10:00
Lucas Smith 389390c884 v2.18.0 2026-09-09 11:34:10 +10:00
Ephraim Duncan c5acba538e chore(auth): remove commented account creation code (#3344) 2026-09-09 11:04:15 +10:00
Lucas Smith f5ab8a8ae3 fix: resolve build errors (#3352) 2026-09-09 11:04:01 +10:00
Ephraim Duncan 3b20c2f960 chore(ui): remove unused text fitting component (#3337) 2026-09-09 10:51:35 +10:00
Lucas Smith 5e8a434141 fix: use react router middleware (#3351) 2026-09-09 10:51:26 +10:00
David Nguyen 6a8bb4be04 fix: improve invalid bulk template upload error handling (#3326) 2026-09-08 21:03:02 +10:00
Lucas Smith df815e5b44 feat: add instance and org 2fa enrolment and enforcement 2026-09-08 14:12:25 +10:00
317 changed files with 11733 additions and 4877 deletions
+20
View File
@@ -30,6 +30,26 @@ 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
@@ -51,6 +51,7 @@ async function createAndSendDocument(
pdfBuffer: Buffer,
filename: string,
title: string,
externalId: string,
recipients: Recipient[],
): Promise<CreateAndSendResult> {
const recipientPayload = recipients.map((recipient, index) => ({
@@ -89,6 +90,7 @@ async function createAndSendDocument(
JSON.stringify({
type: 'DOCUMENT',
title,
externalId,
recipients: recipientPayload,
meta: {
subject: `Please sign: ${title}`,
@@ -145,6 +147,7 @@ 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' },
@@ -172,6 +175,7 @@ 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",
@@ -241,6 +245,8 @@ 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,6 +9,7 @@
"background-jobs",
"signing-certificate",
"telemetry",
"two-factor-enforcement",
"organisation-limits",
"advanced"
]
@@ -0,0 +1,54 @@
---
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
+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.0",
"next": "^16.3.3",
"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.0"
"next": "^16.3.3"
},
"devDependencies": {
"@types/node": "^20",
@@ -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 authentcation for this user.`,
() => msg`You are not authorized to reset two factor authentication for this user.`,
)
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
@@ -85,7 +85,8 @@ 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.
authentication for the user. Their two-factor enforcement grace period will restart from the moment of the
reset.
</Trans>
</AlertDescription>
</div>
@@ -108,7 +109,8 @@ 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.
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.
</Trans>
</AlertDescription>
</Alert>
@@ -1,4 +1,7 @@
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 {
@@ -15,9 +18,11 @@ 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 { Plural, 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';
@@ -29,6 +34,8 @@ 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 }>;
@@ -42,6 +49,9 @@ 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: {
@@ -51,6 +61,20 @@ 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`]);
@@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
};
const onSubmit = async (values: TBulkSendFormSchema) => {
setValidationError(null);
try {
const csv = await values.file.text();
await uploadBulkSend({
const result = 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);
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
const error = AppError.parseError(err);
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
}
};
return (
<Dialog>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
@@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValidationError(null);
onChange(file);
}
}}
@@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => onChange(null)}
onClick={() => {
setValidationError(null);
form.resetField('file');
}}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
@@ -218,6 +257,72 @@ 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"
@@ -240,7 +345,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Trans>Cancel</Trans>
</Button>
@@ -18,6 +18,8 @@ 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';
@@ -32,6 +34,7 @@ export interface ConfigureDocumentRecipientsProps {
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
const cspNonce = useCspNonce();
const $sensorApi = useRef<SensorAPI | null>(null);
@@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
/>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -1,5 +1,6 @@
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,
@@ -68,15 +69,23 @@ 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 {
await authClient.twoFactor.disable({ totpCode, backupCode });
const { orgEnforcementApplies } = await authClient.twoFactor.disable({ totpCode, backupCode });
toast({
title: _(msg`Two-factor authentication disabled`),
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.`,
),
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.`,
),
});
flushSync(() => {
@@ -84,7 +93,19 @@ export const DisableAuthenticatorAppDialog = () => {
});
await refreshSession();
} catch (_err) {
} 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;
}
toast({
title: _(msg`Unable to disable two-factor authentication`),
description: _(
@@ -1,6 +1,7 @@
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,
@@ -69,11 +70,18 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
setSetup2FAData(data);
} catch (err) {
const error = AppError.parseError(err);
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.`,
),
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.`,
),
variant: 'destructive',
});
}
@@ -0,0 +1,158 @@
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>
);
};
@@ -0,0 +1,282 @@
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>
);
};
@@ -0,0 +1,53 @@
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>
);
};
@@ -0,0 +1,61 @@
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>
);
};
+59 -18
View File
@@ -1,6 +1,6 @@
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } 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,20 +11,21 @@ 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 { z } from 'zod';
import type { z } from 'zod';
export const ZPasswordFormSchema = z
.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
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 type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
@@ -33,29 +34,51 @@ export type PasswordFormProps = {
user: SessionUser;
};
export const PasswordForm = ({ className }: PasswordFormProps) => {
export const PasswordForm = ({ className, user }: 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 ({ currentPassword, password }: TPasswordFormSchema) => {
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;
}
try {
await authClient.emailPassword.updatePassword({
currentPassword,
password,
totpCode: totpCode || undefined,
backupCode: backupCode || undefined,
});
form.reset();
setIsTwoFactorDialogOpen(false);
toast({
title: _(msg`Password updated`),
@@ -66,9 +89,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.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.`)
.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.`,
)
.otherwise(
() => msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
);
@@ -83,7 +111,12 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
return (
<Form {...form}>
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
{/* 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)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
@@ -140,6 +173,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
</Button>
</div>
</form>
<TwoFactorCodeDialog<TPasswordFormSchema>
open={isTwoFactorDialogOpen}
onOpenChange={setIsTwoFactorDialogOpen}
isSubmitting={isSubmitting}
submitLabel={<Trans>Update password</Trans>}
onSubmit={form.handleSubmit(onFormSubmit)}
/>
</Form>
);
};
@@ -0,0 +1,332 @@
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,5 +1,7 @@
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';
@@ -7,11 +9,13 @@ 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;
@@ -35,8 +39,12 @@ export const DocumentSigningAuthPassword = ({
}: DocumentSigningAuthPasswordProps) => {
const { t } = useLingui();
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
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 form = useForm<TPasswordAuthFormSchema>({
resolver: zodResolver(ZPasswordAuthFormSchema),
@@ -47,6 +55,10 @@ 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);
@@ -64,8 +76,6 @@ export const DocumentSigningAuthPassword = ({
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
@@ -79,9 +89,22 @@ 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}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{/* method="post" so a pre-hydration native submit can't leak the password into the URL. */}
<form method="post" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
{formErrorCode && (
@@ -0,0 +1,63 @@
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,6 +28,7 @@ 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 = {
@@ -42,6 +43,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const cspNonce = useCspNonce();
const navigate = useNavigate();
@@ -473,6 +475,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddFieldsFormPartial
@@ -45,6 +45,7 @@ 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 } =
@@ -52,6 +53,7 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const cspNonce = useCspNonce();
const { t } = useLingui();
const { toast } = useToast();
@@ -795,6 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -26,6 +26,7 @@ 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';
@@ -42,6 +43,7 @@ type LocalFile = {
export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const cspNonce = useCspNonce();
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
@@ -494,7 +496,7 @@ export const EnvelopeEditorUploadPage = () => {
{/* Uploaded Files List */}
<div className="mt-4">
<DragDropContext onDragEnd={onDragEnd}>
<DragDropContext nonce={cspNonce} onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div
@@ -25,6 +25,7 @@ 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;
@@ -38,6 +39,7 @@ 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();
@@ -339,6 +341,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
onSubmit={onAddTemplatePlaceholderFormSubmit}
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddTemplateFieldsFormPartial
@@ -0,0 +1,144 @@
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,6 +6,7 @@ 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';
@@ -100,6 +101,23 @@ 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 }) => (
+4 -3
View File
@@ -7,10 +7,11 @@ 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 { AppLoadContext, EntryContext } from 'react-router';
import type { EntryContext, RouterContextProvider } 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;
@@ -19,7 +20,7 @@ export default async function handleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
loadContext: RouterContextProvider,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
@@ -33,7 +34,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.nonce || undefined;
const nonce = loadContext.get(nonceContext) || undefined;
return new Promise((resolve, reject) => {
let shellRendered = false;
+13
View File
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,8 @@
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());
};
+19 -3
View File
@@ -3,8 +3,10 @@ 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';
@@ -23,13 +25,16 @@ 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 } from './utils/nonce';
import { nonce, nonceContext } from './utils/nonce';
export const middleware = [nonceMiddleware];
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
@@ -60,9 +65,19 @@ 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 = await getOrganisationSession({ userId: session.user.id });
[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 }),
]);
}
return data(
@@ -74,12 +89,13 @@ 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.nonce,
nonce: context.get(nonceContext),
session: session.isAuthenticated
? {
user: session.user,
session: session.session,
organisations: organisations || [],
twoFactorEnforcement,
}
: null,
publicEnv: createPublicEnv(),
@@ -1,31 +1,44 @@
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 { Link, Outlet, redirect } from 'react-router';
import { useEffect } from 'react';
import { Link, Outlet, redirect, useLocation, useNavigate } 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';
/**
* Don't revalidate (run the loader on sequential navigations)
*
* Update values via providers.
* Builds the enrolment redirect for an instance-blocked user, carrying the
* current path so they land back where they were after enrolling.
*/
export const shouldRevalidate = () => false;
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 async function loader({ request }: Route.LoaderArgs) {
const [session, banner] = await Promise.all([
@@ -37,6 +50,22 @@ 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,
};
@@ -45,7 +74,51 @@ export async function loader({ request }: Route.LoaderArgs) {
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
const { banner } = loaderData;
const { user, organisations } = useSession();
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 { layoutMode } = useChildRouteFlags();
@@ -80,6 +153,65 @@ 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
@@ -112,6 +244,8 @@ 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 { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getOptionalSession } 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,16 +20,18 @@ 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 getSession(request);
const { user } = await getOptionalSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
@@ -1,9 +1,7 @@
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 { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -24,32 +22,10 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
const { t } = useLingui();
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]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -59,8 +35,8 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by claim ID or name`}
className="mb-4"
/>
@@ -1,8 +1,6 @@
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 { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { EmailTransportCreateDialog } from '~/components/dialogs/email-transport-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,32 +9,10 @@ import { AdminEmailTransportsTable } from '~/components/tables/admin-email-trans
export default function AdminEmailTransportsPage() {
const { t } = useLingui();
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]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -46,8 +22,8 @@ export default function AdminEmailTransportsPage() {
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by name or from address`}
className="mb-4"
/>
@@ -1,4 +1,3 @@
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';
@@ -6,8 +5,9 @@ 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 { useEffect, useMemo, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import {
@@ -52,14 +52,17 @@ export default function OrganisationStats() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const [{ query: searchQuery }, setSearchFilters] = useQueryStates(
{
query: parseAsString.withDefault(''),
page: parseAsInteger,
},
{ shallow: false, limitUrlUpdates: debounce(500) },
);
const [displayMode, setDisplayMode] = useState<OrganisationStatsDisplayMode>('usage');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []);
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
@@ -71,29 +74,12 @@ export default function OrganisationStats() {
const claimOptions = claimsData?.data ?? [];
/**
* 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 onSearchQueryChange = (value: string) => {
void setSearchFilters({
query: value || null,
page: null,
});
};
const onPeriodChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
@@ -128,8 +114,8 @@ export default function OrganisationStats() {
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
placeholder={t`Search by organisation name, URL or ID`}
className="flex-1"
/>
@@ -1,8 +1,6 @@
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 { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@@ -10,32 +8,10 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
export default function Organisations() {
const { t } = useLingui();
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]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -43,8 +19,8 @@ export default function Organisations() {
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
className="mb-4"
/>
@@ -6,6 +6,7 @@ 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';
@@ -31,6 +32,8 @@ export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentPro
<AdminSiteBannerSection banner={banner} />
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
<AdminTwoFactorEnforcementSection />
</div>
</div>
);
@@ -1,3 +1,4 @@
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';
@@ -75,6 +76,11 @@ 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();
@@ -228,7 +234,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
</Accordion>
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{canResetTwoFactor && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
@@ -7,6 +7,7 @@ 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';
@@ -29,6 +30,19 @@ 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,10 +1,9 @@
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 { useEffect, useState } from 'react';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { Link, useLocation, useSearchParams } from 'react-router';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
@@ -15,35 +14,16 @@ import { OrganisationMembersDataTable } from '~/components/tables/organisation-m
export default function TeamsSettingsMembersPage() {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(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
@@ -57,8 +37,8 @@ export default function TeamsSettingsMembersPage() {
<div>
<div className="my-4 flex flex-row items-center justify-between space-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={_(msg`Search`)}
/>
@@ -1,8 +1,6 @@
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 { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,27 +9,10 @@ import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-t
export default function OrganisationSettingsTeamsPage() {
const { t } = useLingui();
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]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -40,8 +21,8 @@ export default function OrganisationSettingsTeamsPage() {
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,6 +1,8 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { prisma } from '@documenso/prisma';
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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -12,6 +14,7 @@ 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';
@@ -24,33 +27,10 @@ export function meta() {
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
// 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);
const authMethods = await getUserAuthMethods({ userId: user.id });
return {
// providers,
hasEmailPasswordAccount,
hasEmailPasswordAccount: authMethods.includes(UserAuthMethod.PASSWORD),
};
}
@@ -60,14 +40,36 @@ 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,11 +1,9 @@
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 { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamGroupCreateDialog } from '~/components/dialogs/team-group-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -16,34 +14,12 @@ 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] = 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 [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const everyoneGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
@@ -61,8 +37,8 @@ export default function TeamsSettingsGroupsPage() {
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,8 +1,6 @@
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 { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamMemberCreateDialog } from '~/components/dialogs/team-member-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,32 +9,10 @@ import { TeamMembersTable } from '~/components/tables/team-members-table';
export default function TeamsSettingsMembersPage() {
const { t } = useLingui();
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]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -45,8 +21,8 @@ export default function TeamsSettingsMembersPage() {
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,4 +1,3 @@
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';
@@ -20,7 +19,8 @@ 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 { useEffect, useMemo, useState } from 'react';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useMemo } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
@@ -51,13 +51,14 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
const { toast } = useToast();
const { pathname } = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const parsedSearchParams = WebhookSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
@@ -81,26 +82,6 @@ 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,
@@ -252,8 +233,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
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by ID`}
/>
@@ -0,0 +1,244 @@
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,6 +32,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
organisation: {
select: {
name: true,
organisationGlobalSettings: {
select: {
twoFactorRequired: true,
twoFactorGracePeriodDays: true,
},
},
},
},
},
@@ -71,6 +77,10 @@ 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,
@@ -78,6 +88,8 @@ 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;
}
@@ -141,6 +153,8 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
organisationName={data.organisationName}
userExists={data.userExists}
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
organisationTwoFactorRequired={data.organisationTwoFactorRequired}
organisationTwoFactorGracePeriodDays={data.organisationTwoFactorGracePeriodDays}
/>
);
}
@@ -151,6 +165,8 @@ type PendingInvitationProps = {
organisationName: string;
userExists: boolean;
isSessionUserTheInvitedUser: boolean;
organisationTwoFactorRequired: boolean;
organisationTwoFactorGracePeriodDays: number;
};
type InvitationResult = 'idle' | 'accepted' | 'declined';
@@ -163,6 +179,8 @@ const PendingInvitation = ({
organisationName,
userExists,
isSessionUserTheInvitedUser,
organisationTwoFactorRequired,
organisationTwoFactorGracePeriodDays,
}: PendingInvitationProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -289,6 +307,24 @@ 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,6 +86,12 @@ export async function loader({ params }: Route.LoaderArgs) {
name: true,
url: true,
avatarImageId: true,
organisationGlobalSettings: {
select: {
twoFactorRequired: true,
twoFactorGracePeriodDays: true,
},
},
},
});
@@ -107,6 +113,10 @@ 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;
}
@@ -266,6 +276,26 @@ 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`}
@@ -137,6 +137,9 @@ 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
@@ -0,0 +1,385 @@
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>
);
}
@@ -0,0 +1,32 @@
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>
);
}
+7 -1
View File
@@ -1,4 +1,10 @@
import { useRouteLoaderData } from 'react-router';
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>('');
/**
* 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.17.0"
"version": "2.18.0"
}
+3
View File
@@ -8,4 +8,7 @@ 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,6 +1,7 @@
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';
@@ -41,6 +42,14 @@ 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,6 +1,7 @@
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';
@@ -41,6 +42,14 @@ 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,6 +1,7 @@
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';
@@ -28,6 +29,17 @@ 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) {
@@ -54,6 +66,13 @@ 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);
}
})
@@ -69,6 +88,10 @@ 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,
@@ -86,6 +109,11 @@ export const filesRoute = new Hono<HonoEnv>()
id: envelopeId,
},
include: {
team: {
select: {
organisationId: true,
},
},
envelopeItems: {
where: {
id: envelopeItemId,
@@ -101,6 +129,26 @@ 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) {
@@ -152,6 +200,11 @@ export const filesRoute = new Hono<HonoEnv>()
id: envelopeId,
},
include: {
team: {
select: {
organisationId: true,
},
},
envelopeItems: {
where: {
id: envelopeItemId,
@@ -173,6 +226,15 @@ 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,4 +1,6 @@
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';
@@ -41,6 +43,10 @@ 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({
@@ -69,6 +75,11 @@ route.get(
type: true,
teamId: true,
templateType: true,
team: {
select: {
organisationId: true,
},
},
},
},
},
@@ -78,6 +89,26 @@ 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,
+2 -3
View File
@@ -24,12 +24,11 @@ export const appContext = async (c: Context, next: Next) => {
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return next();
return await next();
}
// Add context to any pages you want here.
return next();
return await next();
};
const setAppContext = (c: Context, context: AppContext) => {
+6 -23
View File
@@ -1,33 +1,16 @@
import { getContext } from 'hono/context-storage';
import type { AppLoadContext } from 'react-router';
import { RouterContextProvider } from 'react-router';
import type { HonoEnv } from './router';
import { CSP_NONCE_KEY } from './security-headers';
/**
* Augment React Router's `AppLoadContext` so loaders, actions, and
* `entry.server` can access fields by name without casts.
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
* `hono/context-storage` (enabled in `server/router.ts`).
*/
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;
}
}
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
/**
* 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`.
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
*/
export const getLoadContext = (): AppLoadContext => {
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
return { nonce };
};
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
+2 -1
View File
@@ -5,6 +5,7 @@ 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';
@@ -53,7 +54,7 @@ export default defineConfig({
entry: 'server/router.ts',
getLoadContext: async () => {
const { getLoadContext } = await import('./server/load-context');
return getLoadContext();
return getLoadContext() as unknown as AppLoadContext;
},
exclude: [
// Spread the defaults but replace the /.css$/ rule so that Bull
+1507 -3684
View File
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.17.0",
"version": "2.18.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -46,6 +46,21 @@
"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
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@types/react": "^19.2.17",
@@ -67,7 +82,7 @@
"esbuild": "^0.28.1",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.17.9",
"inngest-cli": "^1.44.0",
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^9.0.0",
@@ -130,7 +145,7 @@
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"sharp": "0.35.3",
"sharp": "0.35.4",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
@@ -0,0 +1,193 @@
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();
});
@@ -6,6 +6,12 @@ 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.
*/
@@ -16,6 +22,7 @@ export const apiSignin = async ({
page,
email = 'example@documenso.com',
password = 'password',
totpCode,
redirectPath = '/',
}: LoginOptions) => {
const { request } = page.context();
@@ -26,6 +33,7 @@ export const apiSignin = async ({
data: {
email,
password,
totpCode,
csrfToken,
},
});
@@ -0,0 +1,27 @@
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 },
);
};
@@ -0,0 +1,111 @@
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,
},
},
},
});
};
@@ -0,0 +1,272 @@
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);
});
@@ -0,0 +1,105 @@
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);
});
@@ -0,0 +1,64 @@
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.
});
+71 -6
View File
@@ -5,13 +5,14 @@ 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 {
@@ -29,9 +30,8 @@ type TEmailPasswordSignin = InferRequestType<AuthClientType['email-password']['a
redirectPath?: string;
};
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
redirectPath?: string;
};
// `redirectPath` is part of the request schema and validated server-side.
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'];
export class AuthClient {
public client: AuthClientType;
@@ -71,7 +71,7 @@ export class AuthClient {
const result = await response.json();
return superjson.deserialize<SessionValidationResult>(result);
return superjson.deserialize<TSessionJsonResponse>(result);
}
public async getSessions() {
@@ -146,6 +146,20 @@ 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);
},
@@ -245,6 +259,8 @@ 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 });
@@ -257,6 +273,51 @@ 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 = {
@@ -269,7 +330,11 @@ export class AuthClient {
throw AppError.parseError(error);
}
handleSignInRedirect(data.redirectPath);
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);
},
};
@@ -17,6 +17,9 @@ 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');
const getAuthSecret = () => {
export const getAuthSecret = () => {
const authSecret = env('NEXTAUTH_SECRET');
if (!authSecret) {
+33 -2
View File
@@ -1,4 +1,5 @@
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';
@@ -14,7 +15,16 @@ import { AUTH_SESSION_LIFETIME } from '../../config';
*/
export type SessionUser = Pick<
User,
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled'
| 'id'
| 'name'
| 'email'
| 'emailVerified'
| 'avatarImageId'
| 'twoFactorEnabled'
| 'twoFactorGraceStartedAt'
| 'roles'
| 'signature'
| 'disabled'
>;
export type SessionValidationResult =
@@ -35,7 +45,25 @@ export const generateSessionToken = (): string => {
return token;
};
export const createSession = async (token: string, userId: number, metadata: RequestMetadata): Promise<Session> => {
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> => {
const hashedSessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: Session = {
@@ -47,6 +75,8 @@ export const createSession = async (token: string, userId: number, metadata: Req
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({
@@ -84,6 +114,7 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
emailVerified: true,
avatarImageId: true,
twoFactorEnabled: true,
twoFactorGraceStartedAt: true,
roles: true,
signature: true,
disabled: true,
+24 -1
View File
@@ -1,12 +1,26 @@
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;
};
/**
@@ -20,11 +34,20 @@ 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);
await createSession(sessionToken, user.userId, metadata, {
authMethod: user.authMethod,
twoFactorVerified: user.twoFactorVerified,
});
await setSessionCookie(c, sessionToken);
};
@@ -59,6 +59,8 @@ export const getActiveSessions = async (c: Context | Request): Promise<ActiveSes
createdAt: true,
ipAddress: true,
userAgent: true,
authMethod: true,
twoFactorVerified: true,
},
});
};
@@ -20,6 +20,7 @@ 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;
@@ -50,6 +51,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
user: {
select: {
id: true,
twoFactorEnabled: true,
},
},
},
@@ -57,7 +59,21 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
// Directly log in user if account already exists.
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
// 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);
return c.redirect(redirectPath, 302);
}
@@ -69,11 +85,37 @@ 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: {
@@ -114,7 +156,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
}
});
await onAuthorize({ userId: userWithSameEmail.id }, c);
await onAuthorize({ userId: userWithSameEmail.id, authMethod: 'oauth', twoFactorVerified: false }, c);
return c.redirect(redirectPath, 302);
}
@@ -179,7 +221,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
console.error(err);
});
await onAuthorize({ userId: createdUser.id }, c);
await onAuthorize({ userId: createdUser.id, authMethod: 'oauth', twoFactorVerified: false }, c);
return c.redirect(redirectPath, 302);
};
@@ -12,6 +12,7 @@ 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;
@@ -26,7 +27,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
organisationUrl: orgUrl,
});
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
const { email, name, sub } = await validateOauth({
c,
clientOptions: {
...clientOptions,
@@ -55,7 +56,21 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
// Directly log in user if account already exists.
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
// 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);
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
}
@@ -103,16 +118,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),
},
});
@@ -0,0 +1,377 @@
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.
}
};
+65 -163
View File
@@ -7,12 +7,9 @@ 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 { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { resetTwoFactorAfterBackupCodeUse } from '@documenso/lib/server-only/2fa/reset-2fa-after-backup-code-use';
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 {
@@ -21,9 +18,11 @@ 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';
@@ -40,7 +39,6 @@ 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';
@@ -135,14 +133,16 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
let isBackupCodeRecovery = false;
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({
const validationResult = await validateTwoFactorAuthentication({
backupCode,
totpCode,
user,
});
if (!isValid) {
if (!validationResult.isValid) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
@@ -154,6 +154,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
}
isBackupCodeRecovery = validationResult.method === 'backup';
}
if (!user.emailVerified) {
@@ -179,11 +181,44 @@ 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.
await onAuthorize({ userId: user.id }, c);
//
// 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,
);
return c.text('', 201);
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,
);
})
/**
* Signup endpoint.
@@ -248,7 +283,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Update password endpoint.
*/
.post('/update-password', sValidator('json', ZUpdatePasswordSchema), async (c) => {
const { password, currentPassword } = c.req.valid('json');
const { password, currentPassword, totpCode, backupCode } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
@@ -259,10 +294,25 @@ 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,
});
@@ -316,7 +366,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 }, c);
await onAuthorize({ userId, authMethod: 'email-password', twoFactorVerified: false }, c);
}
return c.json({
@@ -454,156 +504,8 @@ 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);
});
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,
});
},
);
// 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.
+16 -3
View File
@@ -6,6 +6,7 @@ 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';
@@ -37,7 +38,7 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
});
}
const { csrfToken, credential } = c.req.valid('json');
const { csrfToken, credential, redirectPath } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
@@ -104,6 +105,12 @@ 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),
@@ -134,11 +141,17 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
},
});
await onAuthorize({ userId: user.id }, c);
// 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) ?? '/') : '/';
return c.json(
{
url: '/',
url,
},
200,
);
+18 -1
View File
@@ -1,9 +1,20 @@
import { getTwoFactorEnforcementStatus } from '@documenso/lib/server-only/2fa/get-two-factor-enforcement-status';
import type { TTwoFactorEnforcementStatus } from '@documenso/lib/utils/two-factor';
import { Hono } from 'hono';
import superjson from 'superjson';
import type { SessionValidationResult } from '../lib/session/session';
import { getActiveSessions, getOptionalSession } from '../lib/utils/get-session';
/**
* The payload consumed by the client session provider. The instance 2FA
* enforcement status rides along with the session so the client can expose it
* without any extra queries.
*/
export type TSessionJsonResponse = SessionValidationResult & {
twoFactorEnforcement: TTwoFactorEnforcementStatus;
};
export const sessionRoute = new Hono()
.get('/session', async (c) => {
const session: SessionValidationResult = await getOptionalSession(c);
@@ -18,5 +29,11 @@ export const sessionRoute = new Hono()
.get('/session-json', async (c) => {
const session: SessionValidationResult = await getOptionalSession(c);
return c.json(superjson.serialize(session));
const twoFactorEnforcement: TTwoFactorEnforcementStatus = session.isAuthenticated
? await getTwoFactorEnforcementStatus({ user: session.user, session: session.session })
: { required: false };
const response: TSessionJsonResponse = { ...session, twoFactorEnforcement };
return c.json(superjson.serialize(response));
});
+263 -3
View File
@@ -1,18 +1,36 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
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 { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import {
twoFactorChallengeIpRateLimit,
twoFactorChallengeUserRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { getCsrfCookie } from '../lib/session/session-cookies';
import { onAuthorize } from '../lib/utils/authorizer';
import { getSession } from '../lib/utils/get-session';
import {
clearTwoFactorChallengeCookie,
consumeTwoFactorChallenge,
executeTwoFactorChallengeAction,
getPendingTwoFactorChallenge,
recordTwoFactorChallengeFailure,
} from '../lib/utils/two-factor-challenge';
import type { HonoAuthContext } from '../types/context';
import {
ZDisableTwoFactorRequestSchema,
ZEnableTwoFactorRequestSchema,
ZVerifyTwoFactorChallengeRequestSchema,
ZViewTwoFactorRecoveryCodesRequestSchema,
} from './two-factor.types';
@@ -40,7 +58,7 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
.post('/enable', sValidator('json', ZEnableTwoFactorRequestSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getSession(c);
const { user: sessionUser, session } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
@@ -63,6 +81,7 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
const result = await enableTwoFactorAuthentication({
user,
code,
sessionId: session.id,
requestMetadata,
});
@@ -99,14 +118,24 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
const { totpCode, backupCode } = c.req.valid('json');
await disableTwoFactorAuthentication({
const { orgEnforcementApplies } = await disableTwoFactorAuthentication({
user,
totpCode,
backupCode,
requestMetadata,
});
return c.text('OK', 201);
// `orgEnforcementApplies` lets the client warn that org/team context
// access will block at the org 2FA deadline (immediately if already
// past). The disable itself is allowed — only instance enforcement
// refuses it server-side.
return c.json(
{
success: true,
orgEnforcementApplies,
},
201,
);
})
/**
@@ -143,4 +172,235 @@ export const twoFactorRoute = new Hono<HonoAuthContext>()
success: true,
backupCodes,
});
})
/**
* Lightweight pending-challenge validity check for the /2fa-challenge page.
*
* Unauthenticated by design — the signed challenge cookie is the proof that
* primary authentication passed. Resolving the challenge also garbage
* collects expired/exhausted tokens, so an invalid state reports `false`
* and the page sends the user back to sign-in.
*/
.get('/challenge', async (c) => {
const requestMetadata = c.get('requestMetadata');
// IP-based limit before any challenge resolution.
const ipLimitResult = await twoFactorChallengeIpRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const ipLimited = rateLimitResponse(c, ipLimitResult);
if (ipLimited) {
throw new HTTPException(429, {
res: ipLimited,
});
}
const challenge = await getPendingTwoFactorChallenge(c);
return c.json({
valid: challenge !== null,
});
})
/**
* Verify the second factor for a pending 2FA challenge (OAuth/OIDC
* sign-ins where the code arrives in a later request than primary auth).
*
* Unauthenticated by design: no session may exist before the code is
* verified, so the signed HttpOnly challenge cookie is the proof of primary
* authentication. The pending token is NOT a second factor itself — it only
* carries "primary auth passed for user X" across the OAuth redirect; the
* code is verified against the user's stored TOTP secret / backup codes via
* `validateTwoFactorAuthentication`, identical to email/password login.
*
* No captcha here: this is the continuation of a sign-in that already
* passed the provider's and our own abuse controls at the primary auth
* step.
*/
.post('/challenge', sValidator('json', ZVerifyTwoFactorChallengeRequestSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { totpCode, backupCode, csrfToken } = c.req.valid('json');
// IP-based limit BEFORE challenge resolution so hammering without a valid
// cookie never reaches the database token lookup.
const ipLimitResult = await twoFactorChallengeIpRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const ipLimited = rateLimitResponse(c, ipLimitResult);
if (ipLimited) {
throw new HTTPException(429, {
res: ipLimited,
});
}
const csrfCookieToken = await getCsrfCookie(c);
if (!csrfCookieToken || csrfToken !== csrfCookieToken) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid CSRF token',
statusCode: 400,
});
}
const challenge = await getPendingTwoFactorChallenge(c);
if (!challenge) {
throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, {
message: 'No pending two factor challenge. Restart the sign-in flow.',
statusCode: 401,
});
}
// Per-user limit only after the cookie resolved to a user.
const userLimitResult = await twoFactorChallengeUserRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: `user:${challenge.userId}`,
});
const userLimited = rateLimitResponse(c, userLimitResult);
if (userLimited) {
throw new HTTPException(429, {
res: userLimited,
});
}
const user = await prisma.user.findFirst({
where: {
id: challenge.userId,
},
select: {
id: true,
email: true,
disabled: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
// The challenge is moot if the user disappeared or no longer has 2FA
// enabled — consume it and force a fresh sign-in.
if (!user || !user.twoFactorEnabled) {
await prisma.verificationToken.deleteMany({
where: {
id: challenge.id,
},
});
clearTwoFactorChallengeCookie(c);
throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, {
message: 'The two factor challenge is no longer valid. Restart the sign-in flow.',
statusCode: 401,
});
}
// A rejected sign-in must never mutate the account, so a disabled user is
// refused BEFORE code validation — otherwise a valid backup code would
// run the recovery reset (stripping 2FA) even though `onAuthorize` would
// refuse the session afterwards. The challenge is consumed so it cannot
// be retried.
if (user.disabled) {
await prisma.verificationToken.deleteMany({
where: {
id: challenge.id,
},
});
clearTwoFactorChallengeCookie(c);
throw new AppError(AuthenticationErrorCode.AccountDisabled, {
message: 'Account disabled',
statusCode: 403,
});
}
const validationResult = await validateTwoFactorAuthentication({
totpCode,
backupCode,
user,
});
if (!validationResult.isValid) {
// Failed codes do not consume the token but atomically increment its
// attempt counter (audit-logged). The rate limiter fails open on DB
// errors, so the counter is the hard bound — exhaustion consumes the
// challenge.
const { isExhausted } = await recordTwoFactorChallengeFailure(c, {
challenge,
requestMetadata,
});
if (isExhausted) {
throw new AppError(AuthenticationErrorCode.TwoFactorChallengeExpired, {
message: 'Too many failed attempts. Restart the sign-in flow.',
statusCode: 401,
});
}
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode, {
message: 'Invalid two factor code',
statusCode: 400,
});
}
const isBackupCodeRecovery = validationResult.method === 'backup';
// Token consumption, the deferred link action (if any) and the
// backup-code recovery reset all commit atomically — a concurrent replay
// of the same challenge fails the consumption count check cleanly.
await prisma.$transaction(async (tx) => {
await consumeTwoFactorChallenge(tx, challenge.id);
if (challenge.metadata.action) {
await executeTwoFactorChallengeAction(tx, {
action: challenge.metadata.action,
userId: challenge.userId,
requestMetadata,
});
}
// Backup codes are recovery, not sign-in: a successful backup-code
// challenge atomically resets 2FA so the user re-enrols.
if (isBackupCodeRecovery) {
await resetTwoFactorAfterBackupCodeUse({
user,
requestMetadata,
tx,
});
}
});
// The session is created with the primary auth method stored at challenge
// creation, and counts as second-factor verified.
await onAuthorize(
{
userId: challenge.userId,
authMethod: challenge.metadata.authMethod,
twoFactorVerified: true,
},
c,
);
clearTwoFactorChallengeCookie(c);
// A backup-code recovery lands on the re-enrolment page, carrying the
// original destination as its returnTo.
const redirectPath = isBackupCodeRecovery
? `/onboarding/2fa?returnTo=${encodeURIComponent(challenge.metadata.redirectPath)}`
: challenge.metadata.redirectPath;
return c.json(
{
redirectPath,
},
201,
);
});
@@ -18,3 +18,17 @@ export const ZViewTwoFactorRecoveryCodesRequestSchema = z.object({
});
export type TViewTwoFactorRecoveryCodesRequestSchema = z.infer<typeof ZViewTwoFactorRecoveryCodesRequestSchema>;
/**
* Mirrors the email/password sign-in shape (`ZSignInSchema`) for the second
* factor: one of `totpCode` or `backupCode`, plus the CSRF token the client
* fetched before submitting (the challenge page is reached via a 302, not the
* sign-in form, so it fetches its own).
*/
export const ZVerifyTwoFactorChallengeRequestSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
csrfToken: z.string().trim(),
});
export type TVerifyTwoFactorChallengeRequestSchema = z.infer<typeof ZVerifyTwoFactorChallengeRequestSchema>;
@@ -70,6 +70,8 @@ export type TResendVerifyEmailSchema = z.infer<typeof ZResendVerifyEmailSchema>;
export const ZUpdatePasswordSchema = z.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TUpdatePasswordSchema = z.infer<typeof ZUpdatePasswordSchema>;
+6
View File
@@ -3,6 +3,12 @@ import { z } from 'zod';
export const ZPasskeyAuthorizeSchema = z.object({
csrfToken: z.string().min(1),
credential: z.string().min(1),
/**
* Optional client redirect path, validated server-side with
* `isValidReturnTo`/`normalizeReturnTo` before being echoed back.
*/
redirectPath: z.string().optional(),
});
export type TPasskeyAuthorizeSchema = z.infer<typeof ZPasskeyAuthorizeSchema>;
@@ -5,11 +5,12 @@ import {
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
import { jobs } from '@documenso/lib/jobs/client';
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@prisma/client';
import { OrganisationGroupType, UserSecurityAuditLogType } from '@prisma/client';
export interface LinkOrganisationAccountOptions {
token: string;
@@ -23,8 +24,10 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
});
}
// Delete the token since it contains unnecessary sensitive data.
const verificationToken = await prisma.verificationToken.delete({
// Read WITHOUT consuming: the token must stay retryable until membership
// creation succeeds. Consumption happens atomically with the membership
// creation below.
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
@@ -33,13 +36,9 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
user: {
select: {
id: true,
email: true,
emailVerified: true,
accounts: {
select: {
provider: true,
providerAccountId: true,
},
},
disabled: true,
},
},
},
@@ -73,11 +72,47 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
const user = verificationToken.user;
// Never link into (or activate membership for) a disabled account.
if (user.disabled) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Account is disabled',
});
}
// The confirmation is only valid for the email address it was sent to. An
// email change between token issuance and confirmation invalidates it.
if (user.email.toLowerCase() !== tokenMetadata.data.email.toLowerCase()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({
type: 'id',
organisationId: tokenMetadata.data.organisationId,
});
const { providerAccountId } = tokenMetadata.data.oauthConfig;
// Ownership conflict check: the provider subject must not already belong to
// a different user. `createMany skipDuplicates` below would silently skip
// in that case, which would confirm a link that never happened.
const existingProviderAccount = await prisma.account.findFirst({
where: {
provider: clientOptions.id,
providerAccountId,
},
select: {
userId: true,
},
});
if (existingProviderAccount && existingProviderAccount.userId !== user.id) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'This identity provider account is already linked to another user',
});
}
const organisationMember = await prisma.organisationMember.findFirst({
where: {
userId: user.id,
@@ -85,32 +120,34 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
},
});
const oauthConfig = tokenMetadata.data.oauthConfig;
const isProviderAccountLinked = existingProviderAccount !== null;
const userAlreadyLinked = user.accounts.find(
(account) => account.provider === clientOptions.id && account.providerAccountId === oauthConfig.providerAccountId,
);
if (organisationMember && userAlreadyLinked) {
return;
}
// Link the user if not linked yet.
if (!userAlreadyLinked) {
// Link the provider account idempotently. `createMany` + `skipDuplicates`
// (unique on provider + providerAccountId) can never clobber an existing
// row and is safe under concurrent confirmation attempts. The account row
// is intentionally created WITHOUT access/ID tokens — they are never stored
// in the token metadata, and the portal re-authenticates against the IdP on
// every sign-in.
if (!isProviderAccountLinked) {
await prisma.$transaction(async (tx) => {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
},
const createdAccounts = await tx.account.createMany({
data: [
{
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId,
userId: user.id,
},
],
skipDuplicates: true,
});
// A concurrent confirmation created the row first — nothing to do, and
// the audit/verification side effects below belong to that request.
if (createdAccounts.count === 0) {
return;
}
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
@@ -139,15 +176,66 @@ export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrgani
});
}
// Only add the user to the organisation if they are not already a member.
// Done outside the above transaction to avoid nested transactions and
// holding connections during the job trigger network I/O.
if (!organisationMember) {
await addUserToOrganisation({
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
// Create the membership and consume the verification token ATOMICALLY. The
// `deleteMany` count check means a concurrent confirmation loses cleanly
// (no double membership, no P2025 500), while any failure before commit
// leaves the token intact and retryable.
await prisma.$transaction(async (tx) => {
const deletedTokens = await tx.verificationToken.deleteMany({
where: {
id: verificationToken.id,
},
});
if (deletedTokens.count !== 1) {
throw new AppError('ALREADY_USED');
}
if (organisationMember) {
return;
}
const organisationGroupToUse = organisation.groups.find(
(group) =>
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
group.organisationRole === organisation.organisationAuthenticationPortal.defaultOrganisationRole,
);
if (!organisationGroupToUse) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Organisation group not found',
});
}
await tx.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
groupId: organisationGroupToUse.id,
},
},
},
});
});
// Best-effort notification AFTER the confirmation is committed — a failing
// email job must not fail (or roll back) the confirmation itself.
if (!organisationMember) {
try {
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId: tokenMetadata.data.organisationId,
memberUserId: user.id,
},
});
} catch (error) {
// Todo: (RR7) Add logging.
console.error('Failed to trigger organisation member joined email', error);
}
}
};
@@ -14,7 +14,7 @@ import crypto from 'crypto';
import { DateTime } from 'luxon';
import { createElement } from 'react';
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
export type SendOrganisationAccountLinkConfirmationEmailProps = Omit<TOrganisationAccountLinkMetadata, 'email'> & {
organisationName: string;
};
@@ -69,6 +69,9 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({
type,
userId,
organisationId,
// The address this confirmation is being sent to — asserted unchanged
// at confirmation time.
email: user.email,
oauthConfig,
} satisfies TOrganisationAccountLinkMetadata,
userId,
@@ -1,6 +1,7 @@
import { Plural, Trans } from '@lingui/react/macro';
import { Heading, Img, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
export type TemplateAccessAuth2FAProps = {
documentTitle: string;
@@ -18,13 +19,9 @@ export const TemplateAccessAuth2FA = ({
expiresInMinutes,
assetBaseUrl = 'http://localhost:3002',
}: TemplateAccessAuth2FAProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<div>
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
<Img src={getEmailAssetUrl(assetBaseUrl, 'static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
<Section className="mt-8">
<Heading className="text-center font-semibold text-foreground text-lg">
@@ -1,5 +1,6 @@
import { Img, Link } from '../components';
import { useBranding } from '../providers/branding';
import { getEmailAssetUrl } from '../utils/asset-url';
import { getSafeBrandingUrl } from '../utils/branding-url';
export type TemplateBrandingLogoProps = {
@@ -20,7 +21,7 @@ export const TemplateBrandingLogo = ({ assetBaseUrl, className = 'mb-4 h-6' }: T
const hasCustomBrandingLogo = branding.brandingEnabled && Boolean(branding.brandingLogo);
if (!hasCustomBrandingLogo) {
const documensoLogoUrl = new URL('/static/logo.png', assetBaseUrl).toString();
const documensoLogoUrl = getEmailAssetUrl(assetBaseUrl, 'static/logo.png');
return <Img src={documensoLogoUrl} alt="Documenso Logo" className={className} />;
}
@@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Button, Column, Img, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentCompletedProps {
@@ -16,10 +17,6 @@ export const TemplateDocumentCompleted = ({
assetBaseUrl,
customBody,
}: TemplateDocumentCompletedProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@@ -29,7 +26,7 @@ export const TemplateDocumentCompleted = ({
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img
src={getAssetUrl('/static/completed.png')}
src={getEmailAssetUrl(assetBaseUrl, 'static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
@@ -51,7 +48,11 @@ export const TemplateDocumentCompleted = ({
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href={downloadLink}
>
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
<Img
src={getEmailAssetUrl(assetBaseUrl, 'static/download.png')}
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
alt=""
/>
<Trans>Download</Trans>
</Button>
</Section>
@@ -1,4 +1,5 @@
import { Column, Img, Row, Section } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
export interface TemplateDocumentImageProps {
assetBaseUrl: string;
@@ -6,17 +7,13 @@ export interface TemplateDocumentImageProps {
}
export const TemplateDocumentImage = ({ assetBaseUrl, className }: TemplateDocumentImageProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Section className={className}>
<Row className="table-fixed">
<Column />
<Column>
<Img className="mx-auto h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
<Img className="mx-auto h-42" src={getEmailAssetUrl(assetBaseUrl, 'static/document.png')} alt="Documenso" />
</Column>
<Column />
@@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Column, Img, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentPendingProps {
@@ -9,10 +10,6 @@ export interface TemplateDocumentPendingProps {
}
export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: TemplateDocumentPendingProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@@ -21,7 +18,11 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" alt="" />
<Img
src={getEmailAssetUrl(assetBaseUrl, 'static/clock.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
<Trans>Waiting for others</Trans>
</Text>
</Column>
@@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Column, Img, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentRecipientSignedProps {
@@ -16,10 +17,6 @@ export const TemplateDocumentRecipientSigned = ({
recipientEmail,
assetBaseUrl,
}: TemplateDocumentRecipientSignedProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
const recipientReference = recipientName || recipientEmail;
return (
@@ -31,7 +28,7 @@ export const TemplateDocumentRecipientSigned = ({
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img
src={getAssetUrl('/static/completed.png')}
src={getEmailAssetUrl(assetBaseUrl, 'static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
@@ -2,6 +2,7 @@ import { env } from '@documenso/lib/utils/env';
import { Trans } from '@lingui/react/macro';
import { Button, Column, Img, Link, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentSelfSignedProps {
@@ -14,10 +15,6 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@@ -27,7 +24,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img
src={getAssetUrl('/static/completed.png')}
src={getEmailAssetUrl(assetBaseUrl, 'static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
@@ -56,7 +53,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
>
<Img
src={getAssetUrl('/static/user-plus.png')}
src={getEmailAssetUrl(assetBaseUrl, 'static/user-plus.png')}
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
alt=""
/>
@@ -67,7 +64,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href="https://documenso.com/pricing"
>
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
<Img
src={getEmailAssetUrl(assetBaseUrl, 'static/review.png')}
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
alt=""
/>
<Trans>View plans</Trans>
</Button>
</Section>
@@ -1,4 +1,5 @@
import { Img } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
export interface TemplateImageProps {
assetBaseUrl: string;
@@ -7,11 +8,7 @@ export interface TemplateImageProps {
}
export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} alt="" />;
return <Img className={className} src={getEmailAssetUrl(assetBaseUrl, `static/${staticAsset}`)} alt="" />;
};
export default TemplateImage;
@@ -5,6 +5,7 @@ import { Body, Container, Head, Html, Img, Preview, Section } from '../component
import type { TemplateAdminUserCreatedProps } from '../template-components/template-admin-user-created';
import { TemplateAdminUserCreated } from '../template-components/template-admin-user-created';
import { TemplateFooter } from '../template-components/template-footer';
import { getEmailAssetUrl } from '../utils/asset-url';
export const AdminUserCreatedTemplate = ({
resetPasswordLink,
@@ -14,10 +15,6 @@ export const AdminUserCreatedTemplate = ({
const previewText = msg`Set your password for Documenso`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
@@ -27,7 +24,7 @@ export const AdminUserCreatedTemplate = ({
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
<Img src={getEmailAssetUrl(assetBaseUrl, 'static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
<TemplateAdminUserCreated resetPasswordLink={resetPasswordLink} assetBaseUrl={assetBaseUrl} />
</Section>
+37 -14
View File
@@ -1,3 +1,4 @@
import type { TPasswordChangeSource } from '@documenso/lib/jobs/definitions/emails/send-password-reset-success-email';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
@@ -8,16 +9,19 @@ import { TemplateFooter } from '../template-components/template-footer';
import type { TemplateResetPasswordProps } from '../template-components/template-reset-password';
import { TemplateResetPassword } from '../template-components/template-reset-password';
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps> & {
source?: TPasswordChangeSource;
};
export const ResetPasswordTemplate = ({
userName = 'Lucas Smith',
userEmail = 'lucas@documenso.com',
assetBaseUrl = 'http://localhost:3002',
source = 'RESET',
}: ResetPasswordTemplateProps) => {
const { _ } = useLingui();
const previewText = msg`Password Reset Successful`;
const previewText = source === 'RESET' ? msg`Password Reset Successful` : msg`Your password was changed`;
return (
<Html>
@@ -46,18 +50,37 @@ export const ResetPasswordTemplate = ({
</Trans>
</Text>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>We've changed your password as you asked. You can now sign in with your new password.</Trans>
</Text>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>
Didn't request a password change? We are here to help you secure your account, just{' '}
<Link className="font-normal text-primary" href="mailto:hi@documenso.com">
contact us
</Link>
.
</Trans>
</Text>
{source === 'RESET' ? (
<>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>We've changed your password as you asked. You can now sign in with your new password.</Trans>
</Text>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>
Didn't request a password change? We are here to help you secure your account, just{' '}
<Link className="font-normal text-primary" href="mailto:hi@documenso.com">
contact us
</Link>
.
</Trans>
</Text>
</>
) : (
<>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>Your password was just changed from your account security settings.</Trans>
</Text>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>
If this was you, no action is needed. If it wasn't, reset your password immediately and{' '}
<Link className="font-normal text-primary" href="mailto:hi@documenso.com">
contact us
</Link>
.
</Trans>
</Text>
</>
)}
</Section>
</Container>
+17
View File
@@ -0,0 +1,17 @@
/**
* Resolve a static email asset path against the asset base URL.
*
* The base is normalised to end with a trailing slash and the path is
* normalised to have no leading slash, so a sub-path in the base URL
* (e.g. "/ESign") is preserved. Passing a root-absolute path straight to
* `new URL()` would otherwise replace the base pathname entirely.
*
* `getEmailAssetUrl('https://host/ESign', 'static/logo.png')` -> `https://host/ESign/static/logo.png`
* `getEmailAssetUrl('https://host/ESign/', '/static/logo.png')` -> `https://host/ESign/static/logo.png`
*/
export const getEmailAssetUrl = (assetBaseUrl: string, path: string): string => {
const base = assetBaseUrl.endsWith('/') ? assetBaseUrl : `${assetBaseUrl}/`;
const relativePath = path.startsWith('/') ? path.slice(1) : path;
return new URL(relativePath, base).toString();
};
@@ -0,0 +1,31 @@
import { authClient } from '@documenso/auth/client';
import { useMutation } from '@tanstack/react-query';
import { AppError } from '../../errors/app-error';
import { useSession } from '../providers/session';
export type UsePasswordSetupRequestOptions = {
onSuccess?: () => void;
onError?: (errorCode: string) => void;
};
/**
* Sends the signed in user the standard password reset email so they can set a
* password via a verified link, rather than letting a bare session mint one.
*/
export const usePasswordSetupRequest = ({ onSuccess, onError }: UsePasswordSetupRequestOptions = {}) => {
const { user } = useSession();
const { mutate, isPending, isSuccess, error } = useMutation({
mutationFn: async () => authClient.emailPassword.forgotPassword({ email: user.email }),
onSuccess,
onError: (err) => onError?.(AppError.parseError(err).code),
});
return {
requestSetupLink: () => mutate(),
isPending,
isSuccess,
errorCode: error ? AppError.parseError(error).code : null,
};
};
@@ -8,11 +8,19 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { useLocation } from 'react-router';
import { SKIP_QUERY_BATCH_META } from '../../constants/trpc';
import type { TTwoFactorEnforcementStatus } from '../../utils/two-factor';
export type AppSession = {
session: Session;
user: SessionUser;
organisations: TGetOrganisationSessionResponse;
/**
* Instance-wide 2FA enforcement status for the current user + session,
* computed server-side. Layout components read this to derive banners and
* the enforcement redirect client-side without extra queries.
*/
twoFactorEnforcement: TTwoFactorEnforcementStatus;
};
interface SessionProviderProps {
@@ -94,6 +102,7 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro
session: newSession.session,
user: newSession.user,
organisations,
twoFactorEnforcement: newSession.twoFactorEnforcement,
});
}, []);

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