mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 00:34:35 +10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef31a16b99 | ||
|
|
e73450dd08 | ||
|
|
fe2641ff57 | ||
|
|
5603a9e59d | ||
|
|
20ae849a1a | ||
|
|
6a99b40cba | ||
|
|
e1ad4a2c55 | ||
|
|
82918163c5 | ||
|
|
b97c22a607 | ||
|
|
389390c884 | ||
|
|
c5acba538e | ||
|
|
f5ab8a8ae3 | ||
|
|
3b20c2f960 | ||
|
|
5e8a434141 | ||
|
|
6a8bb4be04 | ||
|
|
df815e5b44 |
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+28
-5
@@ -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 && (
|
||||
|
||||
+63
@@ -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 }) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1507
-3684
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -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.
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user