Compare commits

..

5 Commits

Author SHA1 Message Date
ephraimduncan ae07df6061 feat(team): add team analytics dashboard
Add a team document-usage dashboard at /t/:teamUrl/analytics for team admins and managers,
behind the NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED rollout flag (enabled by default, set to
"false" to gate it off).

Backend:
- getTeamAnalytics Kysely query over team-produced documents across all folders, with exact
  COUNT(*) (no STATS_COUNT_CAP). Each metric uses its own date axis: Sent/Draft/Pending by
  createdAt, Completed by Envelope.completedAt, Declined by the DOCUMENT_RECIPIENT_REJECTED
  audit-log timestamp.
- resolveAnalyticsPeriod turns calendar presets into half-open [start, end) ranges in the
  viewer's timezone, falling back to UTC.
- team.getAnalytics tRPC route gated to ADMIN/MANAGER.

Frontend:
- Standalone /t/:teamUrl/analytics route whose loader gates the flag and role, silently
  redirecting members to documents.
- Headline metrics and compact stat tiles, a member multiselect filter, a calendar-preset
  period selector, and an empty state.
- Role- and flag-gated nav entries in the desktop and mobile navigation.

Tests:
- Unit tests for the period resolver (timezone and preset boundaries).
- Integration/E2E tests for the query semantics (date axes, audit-log decline, all-folders
  aggregation, sender attribution), access control, filters and the empty state.
2026-05-29 13:52:29 +00:00
Lucas Smith 22ceff43e3 feat: admin-configurable email blocklist (#2884) 2026-05-29 01:12:55 +10:00
Lucas Smith a84da2f2c7 chore: disabled account enforcement (#2882) 2026-05-28 22:19:13 +10:00
Lucas Smith 7e8da85bd8 feat: block disposable email signups (#2883)
Reject disposable / throwaway email providers (mailinator, yopmail,
10minutemail, ...) across all signup paths: email/password, Google,
Microsoft, personal OIDC and organisation OIDC. Backed by the
mailchecker package (offline, ~55k domains, subdomain-aware).

Exposes a SIGNUP_DISPOSABLE_EMAIL error code so the signup form and
SSO redirect alert can show a dedicated message instead of the
generic 'signup disabled' one.
2026-05-28 21:15:27 +09:00
David Nguyen d304d8720c fix: add temp email rate limit (#2879) 2026-05-28 17:09:09 +10:00
69 changed files with 1793 additions and 1871 deletions
@@ -0,0 +1,75 @@
---
date: 2026-05-29
title: Team Analytics Dashboard
---
> Source: issue #242 (documenso/backlog-internal, "Team signing analytics/dashboard"). Redo of stale PR #1976 (`feat/team-dashboard`, closed DIRTY) — build fresh on `main`; do NOT resurrect that branch or its parallel `analytics/` module. Scope locked via interview 2026-05-29.
## V1 decisions (locked)
| Topic | Decision |
|---|---|
| Goal | Team **document usage** dashboard. NOT signature / recipient / user metrics; no "cumulative active users". |
| Headline | **Documents Sent** (non-draft) + **Completed**. Raw counts only — no completion-rate or derived metrics, no period-over-period deltas. |
| State tiles | Draft · Pending · Completed · **Declined (= `REJECTED`)**. "Voided" dropped (no `VOIDED` status exists). Render as a **row of compact stat tiles** (big number + small label). |
| Attribution | By document **owner** (`Envelope.userId`, reuse `senderIds`). Full per-member, **no** privacy guardrail. |
| Member control | **Filter only** — multiselect of team members + easy "All"; every number reflects the selected subset. Reuse `documents-table-sender-filter`. |
| Time filter | **Calendar presets** (This week / This month / This quarter / This year, Last month, …). Default **This month / last 30 days**. **No per-bucket breakdown, no trend chart** — one total per metric for the range. Boundaries in the **viewer's local timezone**. |
| Folder scope | Aggregate **all folders**. Folder filter control → V2. |
| Counts | **Exact** — drop `STATS_COUNT_CAP` on the analytics path. |
| Freshness | **Live** on every load (no cache in V1). |
| Number format | Full, thousands-separated (`1,234`). |
| Inbox | **Excluded** — dashboard = docs the team PRODUCES, not receives. |
| Placement | Standalone top-level route `/t/:teamUrl/analytics` + **primary nav entry**. |
| Access | `ADMIN` + `MANAGER` only. `MEMBER`**hide nav + silent redirect** to documents (no 403, no existence leak). |
| Rollout | Behind a **feature flag**, then open to all teams. No plan/tier gating. |
| Empty state | Friendly empty state with CTA to send the first document. |
| Export | Out of V1 (gold-plating). |
| Deferred → V2 | Folder filter, personal/individual analytics, org-wide cross-team rollup, trend charts, period deltas, derived metrics. |
## Metric semantics — READ THIS
**Event model, bucketed by status date.** Each number counts documents that ENTERED that state during the selected period, each on its OWN date axis:
- **Documents Sent** = non-draft, `createdAt` ∈ period. *(createdAt is the sent-date proxy — see Risks.)*
- **Pending** = currently `PENDING`, `createdAt` ∈ period (sent in period, still pending).
- **Completed** = status `COMPLETED`, **`Envelope.completedAt`** ∈ period.
- **Declined** = status `REJECTED`, rejection time ∈ period — sourced from **`DocumentAuditLog` `DOCUMENT_RECIPIENT_REJECTED`** (there is no `Envelope.rejectedAt`).
- **Draft** = currently `DRAFT`, `createdAt` ∈ period (informational; excluded from "Sent").
⚠️ **Tiles are independent activity counts on different date axes → they do NOT sum to "Documents Sent."** A doc sent in April but completed in May lands in May's Completed, not April's. The UI must NOT present the tiles as if they add up to the headline (no "x of y" framing, no stacked-total visuals).
## Backend
- **New dedicated analytics query** (e.g. `packages/lib/server-only/team/get-team-analytics.ts`). **Do NOT call `getStats` directly** — its semantics diverge (root-folder-only, `STATS_COUNT_CAP`-capped, every status bucketed by `createdAt`). **Reuse its *patterns*:** Kysely builder, team `visibilityFilter` / `teamDeletedFilter`, `senderIds`, `EnvelopeType.DOCUMENT`, `deletedAt IS NULL`.
- Per-metric windows: `createdAt` for Sent/Pending/Draft; `Envelope.completedAt` for Completed; join `DocumentAuditLog` (`type = DOCUMENT_RECIPIENT_REJECTED`, by `envelopeId`, earliest timestamp) for Declined. Exact `COUNT(*)` — no cap.
- Period: resolve `[start, end)` from preset + **viewer timezone** (client sends IANA zone/offset; default UTC if absent). Use Luxon (already imported in `get-stats.ts`).
- tRPC: new `team.getAnalytics` (team-router per-file pattern, `packages/trpc/server/team-router/`). Input `{ teamId, period | { from, to }, senderIds[] }`. **Gate `ADMIN`/`MANAGER`** via team role (`getTeamById``currentTeamRole`); deny `MEMBER`.
## Frontend
- Route `apps/remix/app/routes/_authenticated+/t.$teamUrl+/analytics._index.tsx`. Loader resolves team + role; `MEMBER``redirect` to `/t/:teamUrl/documents`. Behind feature flag (hidden + redirect when off).
- Components (tight, glanceable): headline (Documents Sent, Completed) + compact tile row (Draft/Pending/Completed/Declined); member multiselect (reuse `documents-table-sender-filter`, "All"); calendar-preset period selector; empty state.
- Nav entry in `apps/remix/app/components/general/menu-switcher.tsx`, gated by role + flag.
## Design
1. **Match existing Documenso UI** — Shadcn + Tailwind cards/typography from documents index & admin stats; reuse primitives, don't invent.
2. **Consult the `uidotsh` (`/ui`) skill for EVERY design decision.** No layout/spacing/component/visual choice ships without first fetching `uidotsh://ui`, routing to the matching subskill, and loading its design-guideline files before writing markup. Subskills: `ideas` (tile-row layout options), `design` (`design-guidelines.md`), `finalize` = `componentize` + `canonicalize-tailwind`, `add-dark-mode`, `make-responsive`.
## Approach order
Per ElTimuro: **iterate on the UI first, then finalize the backend.** Stand up the page + filters against a thin query, agree the tile layout via `uidotsh`, then lock the analytics query (date axes, audit-log join, exact counts).
## Verification
- Unit (`get-team-analytics`): doc sent-April/completed-May counts in May's Completed only (not April); Declined dated from audit log; all-folders aggregation; exact counts beyond the old cap; `senderIds` attribution; timezone-correct period boundaries.
- Unit (route/router): `ADMIN`/`MANAGER` allowed, `MEMBER` denied/redirected; flag off → nav hidden + redirect.
- E2E (Playwright, `@documenso/app-tests`): admin sees dashboard; member redirected away; member-filter and period-preset changes move the numbers; new team shows empty state.
## Risks / open
1. **Sent-date proxy:** `createdAt` ≠ true send time for draft-then-sent docs. More accurate = `DocumentAuditLog DOCUMENT_SENT`. V1 uses `createdAt` (no extra join); revisit if attribution looks wrong.
2. **Audit-log join cost** for Declined on large teams (live + uncapped). Acceptable for V1; caching/precompute is the V2 lever if it bites.
3. **Non-summing tiles** can confuse stakeholders — needs clear labels/tooltip; confirm framing with ElTimuro on the UI pass.
4. **Timezone plumbing:** client must send its zone and the server must bucket in it. Confirm no existing team-timezone setting should take precedence.
@@ -1,289 +0,0 @@
---
date: 2026-02-02
title: Support For External 2fa Codes
---
## Objective
Enable organizations to enforce a second factor for document signing while keeping delivery fully external (for example customer-owned SMS), with strong recipient/session binding and auditable controls.
## Problem Context
- Many legacy organizations still rely on SMS for second-factor delivery.
- Their users cannot realistically migrate to authenticator apps or passkeys yet.
- Operating first-party SMS infrastructure in Documenso is costly, risky, and outside core scope.
- Customers need an API-first integration path that fits existing notification infrastructure and compliance controls.
## Proposed Solution
Introduce external 2FA codes for signing:
1. A trusted backend service requests a one-time signing token via API.
2. The customer delivers that token to the signer through their own existing channel (for example SMS).
3. The signer enters the token in the signing flow.
4. Documenso validates the submitted token, then issues a short-lived session-bound verification proof.
5. Signature completion is allowed only when the proof is present and valid for that recipient signing session.
## Decisions Captured In Interview
- Enforcement scope: template-level default with per-recipient override.
- Issuer trust boundary: scoped machine API keys with explicit permission.
- Token lifecycle: newest token immediately revokes prior active token for same recipient/document.
- Brute-force control: token-scoped hard attempt cap.
- Security defaults: TTL 10 minutes, max 5 attempts.
- Verification unlock: session-bound proof (not global recipient unlock).
- Issuance contract: idempotent-ish reissue behavior with explicit structured denial reasons.
- Audit privacy: never log token/code material; log identifiers and reason codes only.
- Missing token at signing time: block with actionable state.
- Rollback behavior: feature-flag off for new sessions only.
- Resend/recovery in v1: support-owned reissue guidance only (no signer self-serve trigger).
- Workspace policy controls in v1: no per-workspace TTL/attempt overrides.
- Session proof TTL in v1: 10 minutes.
## Scope
### In Scope
- API endpoint to issue short-lived signing 2FA tokens for eligible recipients.
- Secure storage/verification mechanism (hashed token + expiry + attempt tracking).
- Signing UI step to collect token before signature submission.
- Standard operating flow: token is generated via API and entered by the recipient in the UI.
- Verification endpoint/path integrated into signing completion checks.
- Audit logging for token issuance and verification attempts.
- Template policy defaults with per-recipient override support.
- Session-bound verification proof issuance after successful code validation.
- Feature-flagged rollout controls at workspace/organization scope.
### Out of Scope
- Native SMS sending/providers inside Documenso.
- New authenticator/passkey implementation.
- Cross-channel delivery guarantees (owned by customer infrastructure).
- UI-only token generation as the primary flow in this phase.
- Fully configurable TTL/attempt policy per workspace in v1.
- Customer callback/webhook resend orchestration in v1.
- Signer-triggered self-serve reissue controls in v1.
## Functional Requirements
- Token is recipient-bound and document/session-bound.
- Token cannot be shared across recipients or recipient roles.
- A recipient token only authorizes signature actions for that same recipient identity.
- If the same human is represented by multiple recipient records, each recipient record still requires its own token.
- Token has strict TTL of 10 minutes and single-use semantics.
- Token verification fails on expiry, mismatch, too many attempts, or reuse.
- Endpoint access is restricted to scoped API clients with explicit issuance permission.
- Clear, localized user errors for invalid/expired tokens.
- Max 5 verification attempts per token; on cap reached, token becomes unusable and signer must use a newly issued token.
- Issuing a new token revokes any existing active token for the same recipient/document pair.
- Successful verification creates a short-lived session-bound proof; only that session can complete signature.
- If 2FA is required but no valid token has been issued yet, signing must be blocked with actionable guidance.
## Non-Functional Requirements
- Verification and consumption path must be atomic and race-safe under concurrent requests.
- Error responses must use stable machine-readable reason codes for customer integrations.
- p95 verification latency should remain within existing signing guardrail budget (target: <= 300 ms server-side).
- Security controls and audit logging must not expose token/code values in logs, traces, or analytics payloads.
## Policy Model
- Default requirement is configured at template/workflow level.
- Sender can override requirement per recipient before send.
- Effective policy is materialized on recipient/document at send time to avoid template drift during in-flight signing.
- Feature flag gates enforcement by workspace/organization for rollout and rollback.
## API Contract
### Token Issuance Endpoint
- Auth: scoped API key with dedicated permission (for example `signing_2fa:issue`).
- Input: recipient/document context and optional idempotency metadata.
- Behavior:
- Eligible recipient: always issues a fresh token and revokes prior active token.
- Ineligible/forbidden state: returns structured 4xx with explicit reason code.
- Never returns previously generated plaintext token; token is visible exactly once at issuance.
- Output:
- Plaintext token (single response only).
- Metadata for integration handling (expiresAt, ttlSeconds, attemptLimit, issuedAt).
### Verification Endpoint
- Input: token submission from signing UI bound to current signing session context.
- Behavior:
- Valid token: atomically consumes token and issues session-bound verification proof.
- Invalid token: increments attempts and returns reason code.
- Expired/revoked/consumed/capped: returns denial reason without revealing sensitive internals.
- Output:
- Success: verification state for current session.
- Failure: localized user-safe message + machine reason code.
### Resend/Reissue Behavior (v1)
- No signer-triggered callback/webhook or self-serve reissue endpoint in v1.
- If token is missing/expired/revoked/capped, signer sees actionable guidance to contact sender/support.
- Reissue remains an API-key-initiated operation from trusted customer backend only.
### Suggested Reason Codes
- `TWO_FA_NOT_REQUIRED`
- `TWO_FA_NOT_ISSUED`
- `TWO_FA_TOKEN_INVALID`
- `TWO_FA_TOKEN_EXPIRED`
- `TWO_FA_TOKEN_REVOKED`
- `TWO_FA_TOKEN_CONSUMED`
- `TWO_FA_ATTEMPT_LIMIT_REACHED`
- `TWO_FA_ISSUER_FORBIDDEN`
- `TWO_FA_RECIPIENT_INELIGIBLE`
## Data Model
Create `signing_two_factor_tokens` (name indicative):
- `id`
- `recipientId`
- `documentId`
- `tokenHash`
- `tokenSalt` (or use KDF settings sufficient to avoid raw-secret recovery)
- `expiresAt`
- `consumedAt` nullable
- `revokedAt` nullable
- `attempts` default 0
- `attemptLimit` default 5
- `issuedByApiKeyId` (or actor reference)
- `createdAt`
Optional companion table/entity for session proof:
- `signing_session_2fa_proofs`
- `sessionId`
- `recipientId`
- `documentId`
- `verifiedAt`
- `expiresAt`
Constraints and indexes:
- Index on (`recipientId`, `documentId`, `expiresAt`).
- At most one active token per (`recipientId`, `documentId`) enforced by transactional revoke-on-issue.
- Guard against lost-update on attempts and consume via row lock or atomic update conditions.
## Signing UX
- Insert 2FA code step before signature commit when effective policy requires it.
- UX states:
- Waiting for code input.
- Invalid code (remaining attempts shown where safe).
- Expired/revoked/attempt cap reached with clear next-step copy.
- Not issued yet state with actionable guidance.
- Recovery copy in v1 must direct signer to sender/support (no in-product resend action).
- Localization required for all user-facing errors.
- Accessibility: input labeling, error announcement, keyboard submission, mobile-friendly numeric entry.
- Session-bound proof behavior must be transparent to user (no global unlock across devices/tabs).
## Security Requirements
- Never persist plaintext token; store salted hash only.
- Rate-limit issuance and verification attempts.
- Invalidate previous active token immediately when a new token is issued.
- Emit security/audit events with actor, recipient, document, timestamp, and reason codes.
- Prevent token leakage in logs, telemetry, and error payloads.
- Use constant-time comparison and hardened random token generation.
- Enforce short proof lifetime for verified session to reduce replay window.
- Set proof TTL to 10 minutes in v1.
## Observability And Audit
Emit events for:
- `2fa_token_issued`
- `2fa_token_issue_denied`
- `2fa_token_verify_succeeded`
- `2fa_token_verify_failed`
- `2fa_token_consumed`
- `2fa_token_revoked`
Event fields:
- `workspaceId`, `documentId`, `recipientId`
- `actorType` (api_key, signer_session, system)
- `actorId` (where applicable)
- `reasonCode`
- `ipHash`, `userAgentHash` (if available)
- `timestamp`
Metrics and alerts:
- Issuance success/failure rates.
- Verification success/failure rate split by reason code.
- Attempt-limit-hit rate.
- p95 verification latency.
- Alert on unusual spikes in invalid attempts per recipient/document/workspace.
## Implementation Plan
1. Domain model
- Add signing 2FA token entity/table and session-proof persistence.
2. Token issuance API
- Add authenticated route for scoped API keys; issue fresh token, revoke prior active.
3. Verification logic
- Validate token state, increment attempts atomically, consume on success, mint session proof.
4. Signing flow integration
- Add UI token prompt and backend guard requiring valid session proof.
5. Observability
- Add reason-coded events and dashboards/alerts.
6. Controls
- Add rate limits, attempt cap (5), revoke-on-reissue, and feature flag checks.
7. Testing
- Unit tests for generation/verification edge cases.
- Integration tests for API and signing flow.
- Concurrency tests for double-submit and parallel verification.
## Testing Matrix
- Token issuance for eligible/ineligible recipients.
- Reissue revokes previous token immediately.
- Verification success path creates session-bound proof.
- Verification fails on mismatch, expiry, revoked, consumed, cap reached.
- Attempt counter increments correctly under concurrent requests.
- Signature blocked when proof absent or expired.
- Recipient A token rejected for recipient B (including same human/multiple recipient records).
- Feature flag off: new sessions bypass external 2FA requirement.
- Audit events emitted with expected reason codes and no token material.
## Acceptance Criteria
- External system can request a token for an eligible signer through API.
- Signer cannot complete signing without valid token when policy requires 2FA.
- A token issued for recipient A is always rejected for recipient B, including when both recipients map to the same underlying person.
- Valid token allows signing exactly once within TTL.
- Expired/reused/invalid tokens are rejected with clear errors.
- No Documenso-owned SMS infrastructure is introduced.
- Audit trail captures issuance and verification outcomes.
- Default policy can be set at template level with per-recipient override at send time.
- New token issuance revokes prior active token for same recipient/document.
- Max 5 failed attempts per token is enforced.
- Successful verification unlocks only the active signing session.
- If no token has been issued yet, signer is blocked with actionable guidance.
## Rollout Strategy
- Ship behind feature flag (workspace-level or organization-level).
- Enable first for pilot customers in regulated domains.
- Monitor verification failure rates and support feedback.
- Gradually expand availability once stable.
- Rollback path: disable flag for new sessions only; preserve already verified in-flight sessions.
## Risks and Mitigations
- Brute-force attempts -> enforce attempt caps, lockouts, and rate limits.
- Delivery delays in customer SMS systems -> allow controlled token re-issue.
- Support burden from expiry confusion -> clear UX copy and resend guidance.
- Concurrency race on consume/attempt updates -> use transactional atomic updates and dedicated tests.
- Misconfigured API clients -> explicit permission scopes and structured denial reasons.
- Forensic gaps vs privacy over-collection -> reason-coded audits with hashed network metadata only.
## Open Questions
- None for v1 scope.
- v1.1 exploration candidate: customer-controlled signer-triggered callback/reissue flow with abuse protections.
+2
View File
@@ -160,6 +160,8 @@ NEXT_PRIVATE_REDIS_PREFIX="documenso"
NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Team analytics dashboard kill-switch. Enabled by default; set to "false" to hide it during rollout.
NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED=
# OPTIONAL: Set to "true" to disable all signup methods (email, Google, Microsoft, OIDC, including the organisation OIDC portal).
NEXT_PUBLIC_DISABLE_SIGNUP=
# OPTIONAL: Set to "true" to disable email/password signup only.
@@ -49,6 +49,7 @@ export const ZSignUpFormSchema = z
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
SIGNUP_DISPOSABLE_EMAIL: msg`Disposable email addresses are not allowed. Please sign up with a permanent email address.`,
[AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
};
@@ -0,0 +1,171 @@
import {
SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
type TSiteSettingsEmailBlocklistSchema,
} from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
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 { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
const ZEmailBlocklistFormSchema = z.object({
enabled: z.boolean(),
domains: z.string(),
});
type TEmailBlocklistFormSchema = z.infer<typeof ZEmailBlocklistFormSchema>;
/**
* Splits a comma-separated string into a normalised list of domains.
* Normalisation (trim, lowercase, strip leading "@", dedupe) is applied
* server-side by the schema as well — this is for display consistency.
*/
const parseDomainsInput = (value: string): string[] => {
return Array.from(
new Set(
value
.split(',')
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
.filter((entry) => entry.length > 0),
),
);
};
type AdminEmailBlocklistSectionProps = {
emailBlocklist: TSiteSettingsEmailBlocklistSchema | undefined;
};
export const AdminEmailBlocklistSection = ({ emailBlocklist }: AdminEmailBlocklistSectionProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TEmailBlocklistFormSchema>({
resolver: zodResolver(ZEmailBlocklistFormSchema),
defaultValues: {
enabled: emailBlocklist?.enabled ?? false,
domains: (emailBlocklist?.data?.domains ?? []).join(', '),
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBlocklistUpdate = async ({ enabled, domains }: TEmailBlocklistFormSchema) => {
try {
const parsedDomains = parseDomainsInput(domains);
await updateSiteSetting({
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
enabled,
data: {
domains: parsedDomains,
},
});
// Reflect the normalised value back in the form.
form.reset({
enabled,
domains: parsedDomains.join(', '),
});
toast({
title: _(msg`Email Blocklist Updated`),
description: _(msg`The email blocklist has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the email blocklist. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Email Blocklist</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Block signups from additional email domains on top of the bundled disposable email list. Subdomains are
matched automatically (e.g. blocking "bad.com" also blocks "foo.bad.com").
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBlocklistUpdate)}>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="mt-4" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="domains"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Blocked Domains</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" placeholder="bad.com, spam.net, throwaway.io" {...field} />
</FormControl>
<FormDescription>
<Trans>Comma-separated list of email domains to block from signing up.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Blocklist</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -0,0 +1,197 @@
import {
SITE_SETTINGS_BANNER_ID,
type TSiteSettingsBannerSchema,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
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 { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { useCspNonce } from '~/utils/nonce';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
type AdminSiteBannerSectionProps = {
banner: TSiteSettingsBannerSchema | undefined;
};
export const AdminSiteBannerSection = ({ banner }: AdminSiteBannerSectionProps) => {
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to display important
information to your users.
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -0,0 +1,65 @@
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
const DEFAULT_PERIOD = 'month';
const PERIOD_OPTIONS = [
{ value: 'week', label: msg`This week` },
{ value: 'month', label: msg`This month` },
{ value: 'quarter', label: msg`This quarter` },
{ value: 'year', label: msg`This year` },
{ value: 'lastMonth', label: msg`Last month` },
{ value: 'last7Days', label: msg`Last 7 days` },
{ value: 'last30Days', label: msg`Last 30 days` },
] as const;
export const AnalyticsPeriodSelector = () => {
const { _ } = useLingui();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const period = useMemo(() => {
const parsed = ZAnalyticsPeriodSchema.safeParse(searchParams?.get('period') ?? DEFAULT_PERIOD);
return parsed.success ? parsed.data : DEFAULT_PERIOD;
}, [searchParams]);
const onPeriodChange = (newPeriod: string) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('period', newPeriod);
if (newPeriod === DEFAULT_PERIOD) {
params.delete('period');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (
<Select value={period} onValueChange={onPeriodChange}>
<SelectTrigger className="max-w-[200px] text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{PERIOD_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{_(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
@@ -1,5 +1,7 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -45,7 +47,7 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
return [];
}
return [
const links = [
{
href: `/t/${teamUrl}/documents`,
label: msg`Documents`,
@@ -55,6 +57,19 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
label: msg`Templates`,
},
];
if (
currentTeam &&
IS_TEAM_ANALYTICS_ENABLED() &&
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
) {
links.push({
href: `/t/${currentTeam.url}/analytics`,
label: msg`Analytics`,
});
}
return links;
}, [currentTeam, organisations]);
return (
@@ -1,7 +1,9 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -57,7 +59,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
];
}
return [
const links = [
{
href: `/t/${teamUrl}/documents`,
text: t`Documents`,
@@ -66,6 +68,20 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: `/t/${teamUrl}/templates`,
text: t`Templates`,
},
];
if (
currentTeam &&
IS_TEAM_ANALYTICS_ENABLED() &&
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
) {
links.push({
href: `/t/${currentTeam.url}/analytics`,
text: t`Analytics`,
});
}
links.push(
{
href: '/inbox',
text: t`Inbox`,
@@ -74,7 +90,9 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: '/settings/profile',
text: t`Settings`,
},
];
);
return links;
}, [currentTeam, organisations]);
return (
@@ -13,7 +13,6 @@ import { match, P } from 'ts-pattern';
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
import { DocumentSigningAuthExternal2FA } from './document-signing-auth-external-2fa';
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
@@ -59,8 +58,15 @@ export const DocumentSigningAuthDialog = ({
return;
}
// Reset selected auth type when dialog closes
if (!value) {
setSelectedAuthType(validAuthTypes.length === 1 ? validAuthTypes[0] : null);
setSelectedAuthType(() => {
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
return null;
});
}
onOpenChange(value);
@@ -117,7 +123,6 @@ export const DocumentSigningAuthDialog = ({
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => <Trans>Verification code</Trans>)
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
.exhaustive()}
</div>
@@ -127,9 +132,6 @@ export const DocumentSigningAuthDialog = ({
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Use your passkey for authentication</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>Enter your 2FA code</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
<Trans>Enter the verification code provided to you</Trans>
))
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
.exhaustive()}
</div>
@@ -167,13 +169,6 @@ export const DocumentSigningAuthDialog = ({
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuthExternal2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
</DialogContent>
@@ -1,223 +0,0 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { SIGNING_2FA_VERIFY_REASON_CODES } from '@documenso/lib/constants/document-auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
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';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthExternal2FAProps = {
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const ZExternal2FAFormSchema = z.object({
code: z
.string()
.length(6, { message: 'Code must be exactly 6 digits' })
.regex(/^\d{6}$/, { message: 'Code must contain only digits' }),
});
type TExternal2FAFormSchema = z.infer<typeof ZExternal2FAFormSchema>;
export const DocumentSigningAuthExternal2FA = ({
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentSigningAuthExternal2FAProps) => {
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const [formError, setFormError] = useState<string | null>(null);
const statusQuery = trpc.envelope.signing2fa.getStatus.useQuery(
{ token: recipient.token },
{ enabled: open },
);
const verifyMutation = trpc.envelope.signing2fa.verify.useMutation();
const form = useForm<TExternal2FAFormSchema>({
resolver: zodResolver(ZExternal2FAFormSchema),
defaultValues: {
code: '',
},
});
const onFormSubmit = async ({ code }: TExternal2FAFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
setFormError(null);
await verifyMutation.mutateAsync({
token: recipient.token,
code,
});
await onReauthFormSubmit({
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED) {
setFormError('Too many failed attempts. Please request a new code.');
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED) {
setFormError('The code has expired. Please request a new code.');
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED) {
setFormError('No code has been issued yet. Please contact the document sender.');
} else {
setFormError('Invalid code. Please try again.');
}
await statusQuery.refetch();
form.reset({ code: '' });
} finally {
setIsCurrentlyAuthenticating(false);
}
};
useEffect(() => {
form.reset({ code: '' });
setFormError(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const attemptsRemaining = statusQuery.data?.attemptsRemaining ?? null;
const hasActiveToken = statusQuery.data?.hasActiveToken ?? false;
const hasValidProof = statusQuery.data?.hasValidProof ?? false;
if (hasValidProof) {
return (
<div className="space-y-4">
<Alert>
<AlertDescription>
<Trans>Your identity has already been verified. You can proceed to sign.</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button
type="button"
onClick={async () => {
await onReauthFormSubmit({
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
});
onOpenChange(false);
}}
>
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</div>
);
}
if (!hasActiveToken && !statusQuery.isLoading) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertTitle>
<Trans>Verification code required</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
A verification code is required to sign this document. Please contact the document
sender to request your code.
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>Enter the 6-digit verification code that was provided to you.</Trans>
</p>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Verification code</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>
)}
/>
{attemptsRemaining !== null && attemptsRemaining > 0 && (
<p className="text-xs text-muted-foreground">
<Trans>{attemptsRemaining} attempts remaining</Trans>
</p>
)}
{formError && (
<Alert variant="destructive">
<AlertTitle>
<Trans>Verification failed</Trans>
</AlertTitle>
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>Verify</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -61,13 +61,13 @@ export const useRequiredDocumentSigningAuthContext = () => {
return context;
};
export type DocumentSigningAuthProviderProps = {
export interface DocumentSigningAuthProviderProps {
documentAuthOptions: Envelope['authOptions'];
recipient: SigningAuthRecipient;
isDirectTemplate?: boolean;
user?: SessionUser | null;
children: React.ReactNode;
};
}
export const DocumentSigningAuthProvider = ({
documentAuthOptions: initialDocumentAuthOptions,
@@ -169,12 +169,12 @@ export const DocumentSigningAuthProvider = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [passkeyData.passkeys]);
const authMethodsRequiringLogin = derivedRecipientActionAuth?.filter(
(method) => method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
);
// Assume that a user must be logged in for any auth requirements.
const isAuthRedirectRequired = Boolean(
authMethodsRequiringLogin && authMethodsRequiringLogin.length > 0 && user?.email !== recipient.email,
derivedRecipientActionAuth &&
derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
user?.email !== recipient.email,
);
const refetchPasskeys = async () => {
@@ -106,12 +106,8 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
}))
.with(undefined, () => undefined)
.with(
P.union(
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
),
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
() => 'NOT_SUPPORTED' as const,
)
.exhaustive();
@@ -1,210 +1,36 @@
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { SITE_SETTINGS_EMAIL_BLOCKLIST_ID } from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section';
import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/site-settings';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export async function loader() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
const settings = await getSiteSettings();
return { banner };
const banner = settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID);
const emailBlocklist = settings.find((setting) => setting.id === SITE_SETTINGS_EMAIL_BLOCKLIST_ID);
return { banner, emailBlocklist };
}
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;
export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentProps) {
const { banner, emailBlocklist } = loaderData;
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<SettingsHeader title={_(msg`Site Settings`)} subtitle={_(msg`Manage your site settings here`)} />
<div className="mt-8">
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to display important
information to your users.
</Trans>
</p>
<div className="mt-8 space-y-12">
<AdminSiteBannerSection banner={banner} />
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
</div>
</div>
);
@@ -0,0 +1,174 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { canExecuteTeamAction, formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
import { Link, redirect, useSearchParams } from 'react-router';
import { z } from 'zod';
import { AnalyticsPeriodSelector } from '~/components/general/analytics-period-selector';
import { CardMetric } from '~/components/general/metric-card';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/analytics._index';
export function meta() {
return appMetaTags(msg`Analytics`);
}
export async function loader({ request, params }: Route.LoaderArgs) {
// Behind a rollout flag: silently send everyone back to documents when off.
if (!IS_TEAM_ANALYTICS_ENABLED()) {
throw redirect(formatDocumentsPath(params.teamUrl));
}
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
// Admins and managers only. Members are silently redirected (no existence leak).
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
throw redirect(formatDocumentsPath(params.teamUrl));
}
}
const ZSearchParamsSchema = z.object({
period: ZAnalyticsPeriodSchema.optional().catch(undefined),
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
export default function TeamAnalyticsPage() {
const { _ } = useLingui();
const team = useCurrentTeam();
const [searchParams] = useSearchParams();
const { period, senderIds } = useMemo(
() => ZSearchParamsSchema.parse(Object.fromEntries(searchParams.entries())),
[searchParams],
);
const timezone = useMemo(() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return undefined;
}
}, []);
const { data, isLoading } = trpc.team.getAnalytics.useQuery({
teamId: team.id,
period,
timezone,
senderIds,
});
const analytics = data ?? {
sent: 0,
draft: 0,
pending: 0,
completed: 0,
declined: 0,
};
const hasActivity =
analytics.sent > 0 ||
analytics.draft > 0 ||
analytics.pending > 0 ||
analytics.completed > 0 ||
analytics.declined > 0;
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<h2 className="font-semibold text-4xl">
<Trans>Analytics</Trans>
</h2>
</div>
<div className="-m-1 flex flex-wrap items-center gap-x-4 gap-y-6 overflow-hidden p-1">
<DocumentsTableSenderFilter teamId={team.id} />
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<AnalyticsPeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
{isLoading ? (
<SpinnerBox className="py-32" />
) : hasActivity ? (
<div data-testid="team-analytics-content">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div data-testid="metric-sent" className="contents">
<CardMetric title={_(msg`Documents Sent`)} value={analytics.sent} />
</div>
<div data-testid="metric-completed-headline" className="contents">
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 lg:grid-cols-4">
<CardMetric title={_(msg`Draft`)} value={analytics.draft} />
<CardMetric title={_(msg`Pending`)} value={analytics.pending} />
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
<CardMetric title={_(msg`Declined`)} value={analytics.declined} />
</div>
<p className="mt-3 max-w-3xl text-muted-foreground text-xs">
<Trans>
Each tile counts documents that entered that state during the selected period, on its own date. They are
independent activity counts and do not add up to Documents Sent.
</Trans>
</p>
</div>
) : (
<div
data-testid="team-analytics-empty"
className="flex flex-col items-center justify-center rounded-lg border border-border border-dashed py-20 text-center"
>
<h3 className="font-semibold text-foreground text-lg">
<Trans>No analytics to show yet</Trans>
</h3>
<p className="mt-2 max-w-md text-muted-foreground text-sm">
<Trans>
There's no document activity for the selected period. Send your first document to start tracking your
team's usage here.
</Trans>
</p>
<Button asChild className="mt-6">
<Link to={formatDocumentsPath(team.url)}>
<Trans>Send a document</Trans>
</Link>
</Button>
</div>
)}
</div>
</div>
);
}
@@ -150,7 +150,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
.with('EXTERNAL_TWO_FACTOR_AUTH', () => _(msg`External Two-Factor Re-Authentication`))
.with('PASSWORD', () => _(msg`Password Re-Authentication`))
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => _(msg`Email`))
+10
View File
@@ -22308,6 +22308,15 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mailchecker": {
"version": "6.0.20",
"resolved": "https://registry.npmjs.org/mailchecker/-/mailchecker-6.0.20.tgz",
"integrity": "sha512-mZ3kmtfXzGj06prtNm6d8an7D++Kf1G4jEkPZ1QQyhknYNLkmGoMtfaNPNHJU6E8J+Bm3AcZlIIfq5D6L4MS2g==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/map-stream": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
@@ -30939,6 +30948,7 @@
"konva": "^10.0.9",
"kysely": "0.29.2",
"luxon": "^3.7.2",
"mailchecker": "^6.0.20",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
"p-map": "^7.0.4",
@@ -0,0 +1,209 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getTeamAnalytics } from '@documenso/lib/server-only/team/get-team-analytics';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
seedCompletedDocument,
seedDraftDocument,
seedPendingDocument,
seedTeamDocuments,
} from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
// Fixed, "now"-independent windows so the date-axis assertions are deterministic.
const SENT_IN_APRIL = new Date('2026-04-10T12:00:00.000Z');
const ACTIONED_IN_MAY = new Date('2026-05-10T12:00:00.000Z');
const APRIL = {
periodStart: new Date('2026-04-01T00:00:00.000Z'),
periodEnd: new Date('2026-05-01T00:00:00.000Z'),
};
const MAY = {
periodStart: new Date('2026-05-01T00:00:00.000Z'),
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
};
// ─── Query semantics (no browser / dev server) ───────────────────────────────
test('[ANALYTICS]: a completed document is counted by completedAt, not createdAt', async () => {
const { team, owner } = await seedTeam();
// Sent in April, completed in May — the document lands on two different axes.
await seedCompletedDocument(owner, team.id, [], {
createDocumentOptions: {
createdAt: SENT_IN_APRIL,
completedAt: ACTIONED_IN_MAY,
},
});
const april = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...APRIL });
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
// Created (and non-draft) in April → counts as Sent in April only.
expect(april.sent).toBe(1);
expect(april.completed).toBe(0);
// Completed in May → counts as Completed in May only, never as Sent in May.
expect(may.sent).toBe(0);
expect(may.completed).toBe(1);
});
test('[ANALYTICS]: declined documents are dated from the rejection audit log', async () => {
const { team, owner } = await seedTeam();
// Document was created in April but only rejected in May.
const rejected = await seedBlankDocument(owner, team.id, {
createDocumentOptions: {
status: DocumentStatus.REJECTED,
createdAt: SENT_IN_APRIL,
},
});
await prisma.documentAuditLog.create({
data: {
envelopeId: rejected.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
createdAt: ACTIONED_IN_MAY,
data: {},
},
});
const april = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...APRIL });
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
// Rejection happened in May, so April (the creation month) records no decline.
expect(april.declined).toBe(0);
expect(may.declined).toBe(1);
});
test('[ANALYTICS]: counts attribute by owner and aggregate across all folders', async () => {
const { team, owner, organisation } = await seedTeam({ createTeamMembers: 1 });
const member = organisation.members[1].user;
const folder = await seedBlankFolder(owner, team.id);
// Owner: one pending in the root folder, one pending nested in a folder.
await seedPendingDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
});
await seedPendingDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY, folderId: folder.id },
});
// Member: one pending in the root folder.
await seedPendingDocument(member, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
});
// All folders are aggregated: the nested document is included.
const everyone = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
expect(everyone.pending).toBe(3);
// Attribution by owner via senderIds.
const ownerOnly = await getTeamAnalytics({
userId: owner.id,
teamId: team.id,
senderIds: [owner.id],
...MAY,
});
expect(ownerOnly.pending).toBe(2);
const memberOnly = await getTeamAnalytics({
userId: owner.id,
teamId: team.id,
senderIds: [member.id],
...MAY,
});
expect(memberOnly.pending).toBe(1);
});
test('[ANALYTICS]: "Documents Sent" excludes drafts but counts every other status', async () => {
const { team, owner } = await seedTeam();
await seedDraftDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
});
await seedPendingDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
});
await seedCompletedDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY, completedAt: ACTIONED_IN_MAY },
});
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
expect(may.draft).toBe(1);
expect(may.pending).toBe(1);
expect(may.completed).toBe(1);
// Sent = non-draft created in the period (pending + completed), drafts excluded.
expect(may.sent).toBe(2);
});
// ─── Access control + dashboard UI (requires the running dev server) ──────────
test('[ANALYTICS]: a team admin sees the dashboard and filters move the numbers', async ({ page }) => {
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamOwner.email,
redirectPath: `/t/${team.url}/analytics`,
});
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
await expect(page.getByTestId('team-analytics-content')).toBeVisible();
// teamMember1 (1 completed) + teamMember2 (2 pending) = 3 non-draft documents sent.
await expect(page.getByTestId('metric-sent')).toContainText('3');
// Filtering to teamMember2 narrows the sent count to their 2 pending documents.
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
await expect(page.getByTestId('metric-sent')).toContainText('2');
});
test('[ANALYTICS]: a team member is redirected away from the dashboard', async ({ page }) => {
const { team } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await apiSignin({
page,
email: member.email,
redirectPath: `/t/${team.url}/analytics`,
});
// The loader silently redirects members back to documents (no 403, no leak).
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
expect(page.url()).toContain(`/t/${team.url}/documents`);
expect(page.url()).not.toContain('/analytics');
await apiSignout({ page });
});
test('[ANALYTICS]: a team with no document activity shows the empty state', async ({ page }) => {
const { team, owner } = await seedTeam();
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/analytics`,
});
await expect(page.getByTestId('team-analytics-empty')).toBeVisible();
await expect(page.getByRole('link', { name: 'Send a document' })).toBeVisible();
await expect(page.getByTestId('team-analytics-content')).toHaveCount(0);
});
@@ -18,6 +18,7 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
SignupDisabled: 'SIGNUP_DISABLED',
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
// IncorrectPassword: 'INCORRECT_PASSWORD',
+2 -1
View File
@@ -14,7 +14,7 @@ import { AUTH_SESSION_LIFETIME } from '../../config';
*/
export type SessionUser = Pick<
User,
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature'
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled'
>;
export type SessionValidationResult =
@@ -86,6 +86,7 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
twoFactorEnabled: true,
roles: true,
signature: true,
disabled: true,
},
},
},
@@ -1,3 +1,4 @@
import { assertUserNotDisabledById } from '@documenso/lib/server-only/user/assert-user-not-disabled';
import type { Context } from 'hono';
import type { HonoAuthContext } from '../../types/context';
@@ -10,8 +11,15 @@ type AuthorizeUser = {
/**
* Handles creating a session.
*
* Refuses to issue a session for a disabled account. This is the single
* chokepoint shared by every sign-in path (email/password, passkey, OAuth,
* OIDC, organisation OIDC), so the guard belongs here rather than in each
* caller.
*/
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
await assertUserNotDisabledById({ userId: user.userId });
const metadata = c.get('requestMetadata');
const sessionToken = generateSessionToken();
@@ -1,6 +1,11 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import {
isDisposableEmail,
isEmailDomainAllowedForSignup,
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
@@ -132,6 +137,17 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(errorUrl.toString(), 302);
}
// Reject disposable / throwaway email providers for new SSO users.
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
return c.redirect(errorUrl.toString(), 302);
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
@@ -1,6 +1,7 @@
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
import { prisma } from '@documenso/prisma';
@@ -74,6 +75,17 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
return c.redirect(errorUrl.toString(), 302);
}
// Reject disposable / throwaway email providers for new SSO users.
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl));
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
return c.redirect(errorUrl.toString(), 302);
}
userToLink = await prisma.user.create({
data: {
email: email,
+16 -7
View File
@@ -1,4 +1,8 @@
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import {
isDisposableEmail,
isEmailDomainAllowedForSignup,
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
@@ -18,6 +22,7 @@ import {
signupRateLimit,
verifyEmailRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
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';
@@ -167,12 +172,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
if (user.disabled) {
throw new AppError('ACCOUNT_DISABLED', {
message: 'Account disabled',
});
}
// 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);
return c.text('', 201);
@@ -214,6 +215,14 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
throw new AppError(AuthenticationErrorCode.SignupDisposableEmail, {
statusCode: 400,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
+9
View File
@@ -15,6 +15,15 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
/**
* Team analytics dashboard rollout flag.
*
* Acts as a kill-switch: enabled by default and disabled only when the env var
* is explicitly set to "false". This keeps the feature available to all teams
* while leaving a single lever to gate it off during rollout.
*/
export const IS_TEAM_ANALYTICS_ENABLED = () => env('NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED') !== 'false';
export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
+49
View File
@@ -1,3 +1,4 @@
import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env';
@@ -121,6 +122,54 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
return allowedDomains.includes(emailDomain);
};
/**
* Check if the given email belongs to a known disposable / throwaway provider
* (e.g. mailinator, yopmail, 10minutemail, ...).
*
* Backed by the `mailchecker` package which bundles a static list of 55k+
* disposable domains. The check is offline and synchronous.
*
* Matching also covers subdomains (e.g. `foo.mailinator.com` resolves to
* `mailinator.com`).
*
* An optional `additionalBlockedDomains` list can be supplied to layer
* admin-configured custom domains on top of the bundled list. These are
* matched with the same subdomain-walking behaviour and are expected to be
* pre-normalised (trimmed + lowercased) by the caller.
*
* Returns `true` when the email is disposable and should be rejected.
* Email format validation is intentionally NOT performed here — that is
* handled by Zod upstream.
*/
export const isDisposableEmail = (email: string, additionalBlockedDomains: string[] = []): boolean => {
const domain = email.toLowerCase().split('@').pop();
if (!domain) {
return false;
}
const blacklist = MailChecker.blacklist();
const blocklist = new Set(additionalBlockedDomains);
let currentDomain: string | undefined = domain;
while (currentDomain) {
if (blacklist.has(currentDomain) || blocklist.has(currentDomain)) {
return true;
}
const nextDot = currentDomain.indexOf('.');
if (nextDot === -1) {
break;
}
currentDomain = currentDomain.slice(nextDot + 1);
}
return false;
};
/**
* Check if signup is enabled for the given provider.
* The master switch takes precedence over the per-provider flags.
-13
View File
@@ -26,21 +26,8 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
key: DocumentAuth.PASSWORD,
value: msg`Require password`,
},
[DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH]: {
key: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
value: msg`Require external 2FA`,
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: msg`None (Overrides global settings)`,
},
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
export const SIGNING_2FA_VERIFY_REASON_CODES = {
TWO_FA_TOKEN_INVALID: 'TWO_FA_TOKEN_INVALID',
TWO_FA_TOKEN_EXPIRED: 'TWO_FA_TOKEN_EXPIRED',
TWO_FA_TOKEN_REVOKED: 'TWO_FA_TOKEN_REVOKED',
TWO_FA_TOKEN_CONSUMED: 'TWO_FA_TOKEN_CONSUMED',
TWO_FA_ATTEMPT_LIMIT_REACHED: 'TWO_FA_ATTEMPT_LIMIT_REACHED',
TWO_FA_NOT_ISSUED: 'TWO_FA_NOT_ISSUED',
} as const;
@@ -16,6 +16,7 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { assertOrgEmailSendAllowed } from '../../../server-only/email/assert-org-email-send-allowed';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
@@ -83,14 +84,15 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
return;
}
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const customEmail = envelope?.documentMeta;
const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK;
@@ -162,6 +164,22 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
});
if (isRecipientEmailValidForSending(recipient)) {
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
if (!sendCheck.allowed) {
// TEMPORARY: silent drop on rate-limit hit. Job is consumed and NOT retried.
io.logger.warn({
msg: 'Recipient signing email dropped: org rate limit exceeded',
organisationId,
recipientId: recipient.id,
envelopeId: envelope.id,
reason: sendCheck.reason,
resetsAt: sendCheck.resetsAt,
});
return;
}
await io.runTask('send-signing-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
+1
View File
@@ -51,6 +51,7 @@
"konva": "^10.0.9",
"kysely": "0.29.2",
"luxon": "^3.7.2",
"mailchecker": "^6.0.20",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
"p-map": "^7.0.4",
@@ -115,28 +115,11 @@ export const completeDocumentWithToken = async ({
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
if (derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH)) {
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId: token,
envelopeId: envelope.id,
expiresAt: { gt: new Date() },
},
});
if (!validProof) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'External 2FA verification required before completing document',
statusCode: 403,
});
}
}
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
if (!accessAuthOptions) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
@@ -31,7 +31,6 @@ type IsRecipientAuthorizedOptions = {
* using the user ID.
*/
authOptions?: TDocumentAuthMethods;
recipientToken?: string;
};
const getUserByEmail = async (email: string) => {
@@ -57,7 +56,6 @@ export const isRecipientAuthorized = async ({
recipient,
userId,
authOptions,
recipientToken,
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
@@ -165,21 +163,6 @@ export const isRecipientAuthorized = async ({
password,
});
})
.with({ type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, async () => {
if (!recipientToken) {
return false;
}
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId: recipientToken,
envelopeId: recipient.envelopeId,
expiresAt: { gt: new Date() },
},
});
return !!validProof;
})
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
return true;
})
@@ -2,6 +2,7 @@ import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -26,8 +27,10 @@ import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ResendDocumentOptions = {
@@ -47,9 +50,14 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
id: true,
email: true,
name: true,
disabled: true,
},
});
// Refuse to resend on behalf of a disabled account. Guards
// document.redistribute / envelope.redistribute and the API v1 equivalent.
assertUserNotDisabled(user);
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
@@ -120,14 +128,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
return envelope;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail, organisationId } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
await Promise.all(
recipientsToRemind.map(async (recipient) => {
@@ -200,6 +209,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
}),
]);
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
if (!sendCheck.allowed) {
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message: 'Organisation email send rate limit exceeded',
userMessage: 'Email send rate limit reached. Please try again in a few minutes.',
});
}
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
@@ -39,6 +39,7 @@ import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type SendDocumentOptions = {
@@ -50,6 +51,11 @@ export type SendDocumentOptions = {
};
export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => {
// Refuse to send on behalf of a disabled account. Guards distribute /
// redistribute / template-use routes, the bulk-send job, and direct
// templates that auto-send on creation.
await assertUserNotDisabledById({ userId });
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
@@ -11,7 +11,6 @@ export type ValidateFieldAuthOptions = {
field: Field;
userId?: number;
authOptions?: TRecipientActionAuth;
recipientToken?: string;
};
/**
@@ -25,7 +24,6 @@ export const validateFieldAuth = async ({
field,
userId,
authOptions,
recipientToken,
}: ValidateFieldAuthOptions) => {
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
@@ -38,7 +36,6 @@ export const validateFieldAuth = async ({
recipient,
userId,
authOptions,
recipientToken,
});
if (!isValid) {
@@ -0,0 +1,42 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import {
recipientEmailRateLimit1d,
recipientEmailRateLimit5m,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
type AssertOrgEmailSendAllowedOptions = {
organisationId: string;
};
type Result = { allowed: true } | { allowed: false; reason: '5m' | '1d'; resetsAt: Date };
/**
* TEMPORARY: rate-limit unsolicited recipient emails per organisation.
*
* Two layered windows: 100/5m and 1000/1d, both keyed to org id. Returns a
* result object so callers can choose to silently drop (job path) or throw
* (sync path).
*
* Remove this helper and all callers when the comprehensive abuse-prevention
* design lands. See .agents/plans/sharp-gold-wave-email-abuse-prevention.md
*/
export const assertOrgEmailSendAllowed = async (options: AssertOrgEmailSendAllowedOptions): Promise<Result> => {
// Self-hosted instances are not behind the SES cap.
if (!IS_BILLING_ENABLED()) {
return { allowed: true };
}
const ip = `org:${options.organisationId}`;
const fiveMinResult = await recipientEmailRateLimit5m.check({ ip });
if (fiveMinResult.isLimited) {
return { allowed: false, reason: '5m', resetsAt: fiveMinResult.reset };
}
const dailyResult = await recipientEmailRateLimit1d.check({ ip });
if (dailyResult.isLimited) {
return { allowed: false, reason: '1d', resetsAt: dailyResult.reset };
}
return { allowed: true };
};
@@ -66,6 +66,7 @@ export type EmailContextResponse = {
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
claims: OrganisationClaim;
organisationId: string;
organisationType: OrganisationType;
senderEmail: {
name: string;
@@ -164,6 +165,7 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
),
settings: organisation.organisationGlobalSettings,
claims,
organisationId: organisation.id,
organisationType: organisation.type,
};
};
@@ -208,6 +210,7 @@ const handleTeamEmailContext = async (teamId: number) => {
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
settings: teamSettings,
claims,
organisationId: organisation.id,
organisationType: organisation.type,
};
};
@@ -37,6 +37,7 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti
import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
@@ -116,6 +117,11 @@ export const createEnvelope = async ({
internalVersion,
bypassDefaultRecipients = false,
}: CreateEnvelopeOptions) => {
// Refuse to create on behalf of a disabled account. Guards every route that
// funnels through here (document.create, envelope.use, template create,
// embedding template/document create, API v1) and the seed/job paths.
await assertUserNotDisabledById({ userId });
const {
type,
title,
@@ -177,7 +177,6 @@ export const signFieldWithToken = async ({
field,
userId,
authOptions,
recipientToken: token,
});
const documentMeta = await prisma.documentMeta.findFirst({
@@ -101,7 +101,6 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Re-Authentication`))
.with('EXTERNAL_TWO_FACTOR_AUTH', () => i18n._(msg`External Two-Factor Re-Authentication`))
.with('PASSWORD', () => i18n._(msg`Password Re-Authentication`))
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
@@ -97,3 +97,17 @@ export const fileUploadRateLimit = createRateLimit({
max: 20,
window: '1m',
});
// ---- Recipient email send (TEMPORARY: per-org abuse-prevention stopgap) ----
export const recipientEmailRateLimit5m = createRateLimit({
action: 'email.send.recipient.5m',
max: 100,
window: '5m',
});
export const recipientEmailRateLimit1d = createRateLimit({
action: 'email.send.recipient.1d',
max: 1500,
window: '1d',
});
@@ -1,105 +0,0 @@
import { prisma } from '@documenso/prisma';
import { DocumentAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
export type GetSigningTwoFactorStatusOptions = {
recipientId: number;
envelopeId: string;
sessionId: string;
};
export type SigningTwoFactorStatus = {
required: boolean;
hasActiveToken: boolean;
hasValidProof: boolean;
tokenExpiresAt: Date | null;
proofExpiresAt: Date | null;
attemptsRemaining: number | null;
};
const NOT_REQUIRED_STATUS: SigningTwoFactorStatus = {
required: false,
hasActiveToken: false,
hasValidProof: false,
tokenExpiresAt: null,
proofExpiresAt: null,
attemptsRemaining: null,
};
export const getSigningTwoFactorStatus = async ({
recipientId,
envelopeId,
sessionId,
}: GetSigningTwoFactorStatusOptions): Promise<SigningTwoFactorStatus> => {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: {
authOptions: true,
recipients: {
where: { id: recipientId },
select: {
authOptions: true,
},
},
},
});
if (!envelope || envelope.recipients.length === 0) {
return NOT_REQUIRED_STATUS;
}
const [recipient] = envelope.recipients;
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const required = derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH);
if (!required) {
return NOT_REQUIRED_STATUS;
}
const now = new Date();
const [activeToken, validProof] = await Promise.all([
prisma.signingTwoFactorToken.findFirst({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
select: {
expiresAt: true,
attempts: true,
attemptLimit: true,
},
}),
prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId,
recipientId,
envelopeId,
expiresAt: { gt: now },
},
select: {
expiresAt: true,
},
}),
]);
return {
required: true,
hasActiveToken: !!activeToken,
hasValidProof: !!validProof,
tokenExpiresAt: activeToken?.expiresAt ?? null,
proofExpiresAt: validProof?.expiresAt ?? null,
attemptsRemaining: activeToken
? Math.max(0, activeToken.attemptLimit - activeToken.attempts)
: null,
};
};
@@ -1,176 +0,0 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { DocumentAuth } from '../../types/document-auth';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { generateSigningTwoFactorToken, generateTokenSalt, hashToken } from './token-utils';
const TOKEN_TTL_MINUTES = 10;
const DEFAULT_ATTEMPT_LIMIT = 5;
export const SIGNING_2FA_REASON_CODES = {
TWO_FA_NOT_REQUIRED: 'TWO_FA_NOT_REQUIRED',
TWO_FA_RECIPIENT_INELIGIBLE: 'TWO_FA_RECIPIENT_INELIGIBLE',
TWO_FA_ISSUER_FORBIDDEN: 'TWO_FA_ISSUER_FORBIDDEN',
} as const;
export type IssueSigningTwoFactorTokenOptions = {
recipientId: number;
envelopeId: string;
apiTokenId: number;
};
export const issueSigningTwoFactorToken = async ({
recipientId,
envelopeId,
apiTokenId,
}: IssueSigningTwoFactorTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT,
},
include: {
recipients: {
where: {
id: recipientId,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
statusCode: 404,
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document must be in PENDING status`,
statusCode: 400,
});
}
if (envelope.recipients.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for this document',
statusCode: 404,
});
}
const [recipient] = envelope.recipients;
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const requiresExternal2FA = derivedRecipientActionAuth.includes(
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
);
if (!requiresExternal2FA) {
await throwIssuanceDenied({
envelopeId,
recipient,
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_NOT_REQUIRED,
});
}
if (recipient.signingStatus === 'SIGNED') {
await throwIssuanceDenied({
envelopeId,
recipient,
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_RECIPIENT_INELIGIBLE,
});
}
const plaintextToken = generateSigningTwoFactorToken();
const salt = generateTokenSalt();
const tokenHash = hashToken(plaintextToken, salt);
const expiresAt = new Date(Date.now() + TOKEN_TTL_MINUTES * 60 * 1000);
const result = await prisma.$transaction(async (tx) => {
await tx.signingTwoFactorToken.updateMany({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
},
data: {
status: 'REVOKED',
revokedAt: new Date(),
},
});
const newToken = await tx.signingTwoFactorToken.create({
data: {
recipientId,
envelopeId,
tokenHash,
tokenSalt: salt,
expiresAt,
attemptLimit: DEFAULT_ATTEMPT_LIMIT,
issuedByApiTokenId: apiTokenId,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: newToken.id,
},
}),
});
return newToken;
});
return {
token: plaintextToken,
tokenId: result.id,
expiresAt: result.expiresAt,
ttlSeconds: TOKEN_TTL_MINUTES * 60,
attemptLimit: result.attemptLimit,
issuedAt: result.createdAt,
};
};
const throwIssuanceDenied = async ({
envelopeId,
recipient,
reasonCode,
}: {
envelopeId: string;
recipient: { id: number; email: string; name: string | null };
reasonCode: string;
}) => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name ?? '',
reasonCode,
},
}),
});
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: reasonCode,
statusCode: 400,
});
};
@@ -1,30 +0,0 @@
import crypto from 'crypto';
const TOKEN_LENGTH = 6;
const SALT_LENGTH = 32;
const HASH_ITERATIONS = 100000;
const HASH_KEY_LENGTH = 64;
const HASH_DIGEST = 'sha512';
export const generateSigningTwoFactorToken = (): string => {
const bytes = crypto.randomBytes(4);
const num = bytes.readUInt32BE(0) % 10 ** TOKEN_LENGTH;
return num.toString().padStart(TOKEN_LENGTH, '0');
};
export const generateTokenSalt = (): string => {
return crypto.randomBytes(SALT_LENGTH).toString('hex');
};
export const hashToken = (token: string, salt: string): string => {
return crypto
.pbkdf2Sync(token, salt, HASH_ITERATIONS, HASH_KEY_LENGTH, HASH_DIGEST)
.toString('hex');
};
export const verifyTokenHash = (token: string, salt: string, expectedHash: string): boolean => {
const hash = hashToken(token, salt);
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expectedHash, 'hex'));
};
@@ -1,245 +0,0 @@
import { prisma } from '@documenso/prisma';
import { SIGNING_2FA_VERIFY_REASON_CODES } from '../../constants/document-auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { verifyTokenHash } from './token-utils';
export { SIGNING_2FA_VERIFY_REASON_CODES };
const PROOF_TTL_MINUTES = 10;
export type VerifySigningTwoFactorTokenOptions = {
recipientId: number;
envelopeId: string;
token: string;
sessionId: string;
};
export const verifySigningTwoFactorToken = async ({
recipientId,
envelopeId,
token: plaintextToken,
sessionId,
}: VerifySigningTwoFactorTokenOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelopeId,
},
select: {
id: true,
email: true,
name: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
const activeToken = await prisma.signingTwoFactorToken.findFirst({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
},
orderBy: {
createdAt: 'desc',
},
});
if (!activeToken) {
await throwVerificationError({
envelopeId,
recipient,
tokenId: 'none',
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED,
attemptsUsed: 0,
attemptLimit: 0,
errorCode: AppErrorCode.INVALID_REQUEST,
statusCode: 400,
});
return;
}
if (activeToken.expiresAt < new Date()) {
await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'EXPIRED',
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED,
attemptsUsed: activeToken.attempts,
attemptLimit: activeToken.attemptLimit,
errorCode: AppErrorCode.EXPIRED_CODE,
statusCode: 400,
});
return;
}
if (activeToken.attempts >= activeToken.attemptLimit) {
await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'REVOKED',
revokedAt: new Date(),
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED,
attemptsUsed: activeToken.attempts,
attemptLimit: activeToken.attemptLimit,
errorCode: AppErrorCode.TOO_MANY_REQUESTS,
statusCode: 429,
});
return;
}
const isValid = verifyTokenHash(plaintextToken, activeToken.tokenSalt, activeToken.tokenHash);
if (!isValid) {
const updatedToken = await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
attempts: { increment: 1 },
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_INVALID,
attemptsUsed: updatedToken.attempts,
attemptLimit: updatedToken.attemptLimit,
errorCode: AppErrorCode.INVALID_REQUEST,
statusCode: 400,
});
return;
}
const proofExpiresAt = new Date(Date.now() + PROOF_TTL_MINUTES * 60 * 1000);
const result = await prisma.$transaction(async (tx) => {
await tx.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'CONSUMED',
consumedAt: new Date(),
attempts: { increment: 1 },
},
});
const proof = await tx.signingSessionTwoFactorProof.upsert({
where: {
sessionId_recipientId_envelopeId: {
sessionId,
recipientId,
envelopeId,
},
},
create: {
sessionId,
recipientId,
envelopeId,
expiresAt: proofExpiresAt,
},
update: {
verifiedAt: new Date(),
expiresAt: proofExpiresAt,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: activeToken.id,
},
}),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: activeToken.id,
},
}),
});
return proof;
});
return {
verified: true,
proofId: result.id,
expiresAt: result.expiresAt,
};
};
type ThrowVerificationErrorOptions = {
envelopeId: string;
recipient: { id: number; email: string; name: string };
tokenId: string;
reasonCode: string;
attemptsUsed: number;
attemptLimit: number;
errorCode: AppErrorCode;
statusCode: number;
};
const throwVerificationError = async ({
envelopeId,
recipient,
tokenId,
reasonCode,
attemptsUsed,
attemptLimit,
errorCode,
statusCode,
}: ThrowVerificationErrorOptions): Promise<never> => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId,
reasonCode,
attemptsUsed,
attemptLimit,
},
}),
});
throw new AppError(errorCode, {
message: reasonCode,
statusCode,
});
};
@@ -0,0 +1,31 @@
import { prisma } from '@documenso/prisma';
import { SITE_SETTINGS_EMAIL_BLOCKLIST_ID, ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist';
/**
* Returns the list of admin-configured email domains that should be treated as
* disposable / blocked, in addition to the bundled `mailchecker` list.
*
* Returns an empty array when the setting has not been configured, is
* disabled, or fails to parse — so a misconfigured setting can never block
* signups outright.
*/
export const getEmailBlocklistDomains = async (): Promise<string[]> => {
const setting = await prisma.siteSettings.findFirst({
where: {
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
},
});
if (!setting || !setting.enabled) {
return [];
}
const parsed = ZSiteSettingsEmailBlocklistSchema.safeParse(setting);
if (!parsed.success) {
return [];
}
return parsed.data.data.domains;
};
@@ -1,9 +1,14 @@
import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner';
import { ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist';
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
export const ZSiteSettingSchema = z.union([ZSiteSettingsBannerSchema, ZSiteSettingsTelemetrySchema]);
export const ZSiteSettingSchema = z.union([
ZSiteSettingsBannerSchema,
ZSiteSettingsEmailBlocklistSchema,
ZSiteSettingsTelemetrySchema,
]);
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_EMAIL_BLOCKLIST_ID = 'email.blocklist-domains';
/**
* Normalises a single domain entry: trims whitespace, lowercases, strips
* a leading "@" if present (so users can paste either "bad.com" or "@bad.com").
*/
const normaliseDomain = (value: string): string => value.trim().toLowerCase().replace(/^@/, '');
const ZBlocklistDomainsSchema = z
.array(z.string())
.transform((values) => Array.from(new Set(values.map(normaliseDomain).filter((value) => value.length > 0))));
export const ZSiteSettingsEmailBlocklistSchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_EMAIL_BLOCKLIST_ID),
data: z
.object({
domains: ZBlocklistDomainsSchema.default([]),
})
.optional()
.default({
domains: [],
}),
});
export type TSiteSettingsEmailBlocklistSchema = z.infer<typeof ZSiteSettingsEmailBlocklistSchema>;
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveAnalyticsPeriod } from './analytics-period';
const iso = (date: Date) => date.toISOString();
describe('resolveAnalyticsPeriod', () => {
// Friday, 2026-05-15. May 2026 is EDT (UTC-4) in the US.
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-15T12:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('resolves "month" to the current calendar month, half-open, in UTC by default', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'month' });
expect(iso(start)).toBe('2026-05-01T00:00:00.000Z');
expect(iso(end)).toBe('2026-06-01T00:00:00.000Z');
});
it('anchors month boundaries to the viewer timezone', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'America/New_York' });
// Local midnight in EDT is 04:00 UTC.
expect(iso(start)).toBe('2026-05-01T04:00:00.000Z');
expect(iso(end)).toBe('2026-06-01T04:00:00.000Z');
});
it('shifts the window for a zone ahead of UTC', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'Asia/Tokyo' });
// JST is UTC+9, so local midnight is the previous day at 15:00 UTC.
expect(iso(start)).toBe('2026-04-30T15:00:00.000Z');
expect(iso(end)).toBe('2026-05-31T15:00:00.000Z');
});
it('falls back to UTC for an invalid timezone', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'Not/AZone' });
expect(iso(start)).toBe('2026-05-01T00:00:00.000Z');
expect(iso(end)).toBe('2026-06-01T00:00:00.000Z');
});
it('resolves "lastMonth" contiguous with the start of the current month', () => {
const lastMonth = resolveAnalyticsPeriod({ period: 'lastMonth' });
const thisMonth = resolveAnalyticsPeriod({ period: 'month' });
expect(iso(lastMonth.start)).toBe('2026-04-01T00:00:00.000Z');
expect(iso(lastMonth.end)).toBe('2026-05-01T00:00:00.000Z');
expect(iso(lastMonth.end)).toBe(iso(thisMonth.start));
});
it('resolves "quarter" to the current calendar quarter', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'quarter' });
expect(iso(start)).toBe('2026-04-01T00:00:00.000Z');
expect(iso(end)).toBe('2026-07-01T00:00:00.000Z');
});
it('resolves "year" to the current calendar year', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'year' });
expect(iso(start)).toBe('2026-01-01T00:00:00.000Z');
expect(iso(end)).toBe('2027-01-01T00:00:00.000Z');
});
it('resolves "last30Days" as a trailing 30-day window including today', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'last30Days' });
expect(iso(start)).toBe('2026-04-16T00:00:00.000Z');
expect(iso(end)).toBe('2026-05-16T00:00:00.000Z');
expect(end.getTime() - start.getTime()).toBe(30 * 24 * 60 * 60 * 1000);
});
it('resolves "last7Days" as a trailing 7-day window including today', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'last7Days' });
expect(iso(start)).toBe('2026-05-09T00:00:00.000Z');
expect(iso(end)).toBe('2026-05-16T00:00:00.000Z');
expect(end.getTime() - start.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
});
it('resolves "week" to a 7-day ISO week starting Monday', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'week' });
// 2026-05-15 is a Friday; the ISO week starts Monday 2026-05-11.
expect(iso(start)).toBe('2026-05-11T00:00:00.000Z');
expect(iso(end)).toBe('2026-05-18T00:00:00.000Z');
expect(end.getTime() - start.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
});
});
@@ -0,0 +1,84 @@
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { z } from 'zod';
/**
* Calendar period presets for the team analytics dashboard. Each resolves to a
* half-open `[start, end)` instant range in the viewer's timezone.
*
* Pure (zod + luxon, no Prisma) so it can be unit-tested without a database and
* shared with the request schema without pulling server-only code anywhere it
* should not go.
*/
export const ZAnalyticsPeriodSchema = z.enum([
'week',
'month',
'quarter',
'year',
'lastMonth',
'last7Days',
'last30Days',
]);
export type AnalyticsPeriod = z.infer<typeof ZAnalyticsPeriodSchema>;
export const DEFAULT_ANALYTICS_PERIOD: AnalyticsPeriod = 'month';
export type AnalyticsPeriodRange = {
start: Date;
end: Date;
};
/**
* Resolve a calendar preset into a half-open `[start, end)` instant range,
* anchored to the viewer's timezone.
*
* Falls back to UTC when the zone is missing or invalid so boundaries never
* silently drift to the server's local zone.
*/
export const resolveAnalyticsPeriod = ({
period,
timezone,
}: {
period: AnalyticsPeriod;
timezone?: string;
}): AnalyticsPeriodRange => {
const zoned = DateTime.now().setZone(timezone ?? 'utc');
const now = zoned.isValid ? zoned : DateTime.now().setZone('utc');
const { start, end } = match(period)
.with('week', () => ({
start: now.startOf('week'),
end: now.startOf('week').plus({ weeks: 1 }),
}))
.with('month', () => ({
start: now.startOf('month'),
end: now.startOf('month').plus({ months: 1 }),
}))
.with('quarter', () => ({
start: now.startOf('quarter'),
end: now.startOf('quarter').plus({ quarters: 1 }),
}))
.with('year', () => ({
start: now.startOf('year'),
end: now.startOf('year').plus({ years: 1 }),
}))
.with('lastMonth', () => ({
start: now.startOf('month').minus({ months: 1 }),
end: now.startOf('month'),
}))
.with('last7Days', () => ({
start: now.startOf('day').minus({ days: 6 }),
end: now.startOf('day').plus({ days: 1 }),
}))
.with('last30Days', () => ({
start: now.startOf('day').minus({ days: 29 }),
end: now.startOf('day').plus({ days: 1 }),
}))
.exhaustive();
return {
start: start.toJSDate(),
end: end.toJSDate(),
};
};
@@ -0,0 +1,167 @@
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import { DocumentStatus, EnvelopeType, TeamMemberRole } from '@prisma/client';
import type { ExpressionBuilder, SelectQueryBuilder } from 'kysely';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { getTeamById } from './get-team';
// Kysely query builder type for Envelope queries.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
// Expression builder scoped to the Envelope table context.
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
export type GetTeamAnalyticsOptions = {
userId: number;
teamId: number;
periodStart: Date;
periodEnd: Date;
senderIds?: number[];
};
export type TeamAnalytics = {
sent: number;
draft: number;
pending: number;
completed: number;
declined: number;
};
/**
* Compute team document-usage analytics for a `[periodStart, periodEnd)` range.
*
* Each metric counts documents that ENTERED its state during the period, on its
* own date axis (see the Documenso team analytics spec):
*
* - `sent` — non-draft documents created in the period.
* - `draft` — documents still in draft, created in the period.
* - `pending` — documents still pending, created in the period.
* - `completed` — completed documents whose `completedAt` falls in the period.
* - `declined` — rejected documents with a `DOCUMENT_RECIPIENT_REJECTED` audit
* log entry in the period (there is no `Envelope.rejectedAt`).
*
* The tiles do NOT sum to `sent`: a document sent in one month but completed the
* next lands in that next month's `completed`, never the first month's `sent`.
*
* Scope mirrors the established team document patterns (`EnvelopeType.DOCUMENT`,
* `deletedAt IS NULL`, team `visibilityFilter`) but is limited to documents the
* team PRODUCES (`teamId` + owner attribution). Inbox / documents received via a
* team email are intentionally excluded. All folders are aggregated. Counts are
* exact `COUNT(*)` — the `STATS_COUNT_CAP` used by `getStats` is not applied.
*/
export const getTeamAnalytics = async ({
userId,
teamId,
periodStart,
periodEnd,
senderIds,
}: GetTeamAnalyticsOptions): Promise<TeamAnalytics> => {
const user = await prisma.user.findFirstOrThrow({
where: { id: userId },
select: { id: true, email: true },
});
const team = await getTeamById({ userId, teamId });
const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER;
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole];
// Visibility: the viewer can see documents within their allowed visibilities,
// documents they own, or documents they are a recipient of.
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
eb.or([
eb(
'Envelope.visibility',
'in',
allowedVisibilities.map((visibility) => sql.lit(visibility)),
),
eb('Envelope.userId', '=', user.id),
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.email', '=', user.email)
.select(sql.lit(1).as('one')),
),
]);
// Base query: team-produced, non-deleted documents across all folders.
const buildBaseQuery = (): EnvelopeQueryBuilder => {
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
.selectFrom('Envelope')
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.where('Envelope.teamId', '=', team.id)
.where('Envelope.deletedAt', 'is', null)
.where(visibilityFilter);
if (senderIds && senderIds.length > 0) {
qb = qb.where('Envelope.userId', 'in', senderIds);
}
return qb;
};
const countEnvelopes = async (qb: EnvelopeQueryBuilder): Promise<number> => {
const result = await qb.select(({ fn }) => fn.count<number>('Envelope.id').as('count')).executeTakeFirstOrThrow();
return Number(result.count ?? 0);
};
// Documents Sent: any non-draft document created in the period.
const sentQuery = buildBaseQuery()
.where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT))
.where('Envelope.createdAt', '>=', periodStart)
.where('Envelope.createdAt', '<', periodEnd);
// Draft: created in the period, still a draft.
const draftQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT))
.where('Envelope.createdAt', '>=', periodStart)
.where('Envelope.createdAt', '<', periodEnd);
// Pending: created in the period, still pending.
const pendingQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
.where('Envelope.createdAt', '>=', periodStart)
.where('Envelope.createdAt', '<', periodEnd);
// Completed: completed in the period (completedAt is a distinct date axis).
const completedQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED))
.where('Envelope.completedAt', '>=', periodStart)
.where('Envelope.completedAt', '<', periodEnd);
// Declined: rejected documents whose rejection was logged in the period.
const declinedQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED))
.where((eb) =>
eb.exists(
eb
.selectFrom('DocumentAuditLog')
.whereRef('DocumentAuditLog.envelopeId', '=', 'Envelope.id')
.where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED)
.where('DocumentAuditLog.createdAt', '>=', periodStart)
.where('DocumentAuditLog.createdAt', '<', periodEnd)
.select(sql.lit(1).as('one')),
),
);
const [sent, draft, pending, completed, declined] = await Promise.all([
countEnvelopes(sentQuery),
countEnvelopes(draftQuery),
countEnvelopes(pendingQuery),
countEnvelopes(completedQuery),
countEnvelopes(declinedQuery),
]);
return {
sent,
draft,
pending,
completed,
declined,
};
};
@@ -0,0 +1,48 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
/**
* Throws if the supplied user object is disabled.
*
* Synchronous variant for hot paths where the `disabled` field has already
* been loaded (e.g. TRPC middleware where the user comes from the session
* query or API token lookup).
*/
export const assertUserNotDisabled = (user: { disabled: boolean }): void => {
if (user.disabled) {
throw new AppError('ACCOUNT_DISABLED', {
message: 'Account disabled',
statusCode: 403,
});
}
};
export type AssertUserNotDisabledByIdOptions = {
userId: number;
};
/**
* Throws if the user with the given id does not exist or is disabled.
*
* Used as a defence-in-depth guard for sign-in chokepoints and server-side
* actions that should not be performed on behalf of a disabled account
* (e.g. creating or sending documents). It deliberately re-queries from the
* database rather than relying on cached context so a freshly-disabled user
* cannot continue to act through a stale session or token.
*/
export const assertUserNotDisabledById = async ({ userId }: AssertUserNotDisabledByIdOptions): Promise<void> => {
const user = await prisma.user.findFirst({
where: { id: userId },
select: { disabled: true },
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
statusCode: 404,
});
}
assertUserNotDisabled(user);
};
-67
View File
@@ -54,14 +54,6 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
// External signing 2FA events.
'EXTERNAL_2FA_TOKEN_ISSUED',
'EXTERNAL_2FA_TOKEN_ISSUE_DENIED',
'EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED',
'EXTERNAL_2FA_TOKEN_VERIFY_FAILED',
'EXTERNAL_2FA_TOKEN_CONSUMED',
'EXTERNAL_2FA_TOKEN_REVOKED',
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@@ -718,59 +710,6 @@ export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.objec
}),
});
const ZExternal2FARecipientDataSchema = z.object({
recipientId: z.number(),
recipientEmail: z.string(),
recipientName: z.string(),
});
export const ZDocumentAuditLogEventExternal2FATokenIssuedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string().optional(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED),
data: ZExternal2FARecipientDataSchema.extend({
reasonCode: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string(),
attemptsUsed: z.number(),
attemptLimit: z.number(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenConsumedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenRevokedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string(),
}),
});
/**
* Event: Recipient's signing window expired.
*/
@@ -830,12 +769,6 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventExternal2FATokenIssuedSchema,
ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema,
ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema,
ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema,
ZDocumentAuditLogEventExternal2FATokenConsumedSchema,
ZDocumentAuditLogEventExternal2FATokenRevokedSchema,
ZDocumentAuditLogEventRecipientExpiredSchema,
]),
);
+2 -23
View File
@@ -5,14 +5,7 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
/**
* All the available types of document authentication options for both access and action.
*/
export const ZDocumentAuthTypesSchema = z.enum([
'ACCOUNT',
'PASSKEY',
'TWO_FACTOR_AUTH',
'EXTERNAL_TWO_FACTOR_AUTH',
'PASSWORD',
'EXPLICIT_NONE',
]);
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'TWO_FACTOR_AUTH', 'PASSWORD', 'EXPLICIT_NONE']);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
@@ -41,10 +34,6 @@ const ZDocumentAuth2FASchema = z.object({
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
});
const ZDocumentAuthExternal2FASchema = z.object({
type: z.literal(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH),
});
/**
* All the document auth methods for both accessing and actioning.
*/
@@ -53,7 +42,6 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
]);
@@ -79,17 +67,10 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
]);
export const ZDocumentActionAuthTypesSchema = z
.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
])
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD])
.describe(
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
);
@@ -116,7 +97,6 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
ZDocumentAuthExplicitNoneSchema,
]);
@@ -125,7 +105,6 @@ export const ZRecipientActionAuthTypesSchema = z
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
DocumentAuth.EXPLICIT_NONE,
])
-7
View File
@@ -34,8 +34,6 @@ export const ZClaimFlagsSchema = z.object({
allowLegacyEnvelopes: z.boolean().optional(),
externalSigning2fa: z.boolean().optional(),
signingReminders: z.boolean().optional(),
});
@@ -104,11 +102,6 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'allowLegacyEnvelopes',
label: 'Allow Legacy Envelopes',
},
externalSigning2fa: {
key: 'externalSigning2fa',
label: 'External signing 2FA',
isEnterprise: true,
},
signingReminders: {
key: 'signingReminders',
label: 'Signing reminders',
-42
View File
@@ -597,48 +597,6 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
user: message,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED }, ({ data }) => {
const message = msg({
message: `External 2FA token issued for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED }, ({ data }) => {
const message = msg({
message: `External 2FA token issuance denied for recipient ${data.recipientEmail}: ${data.reasonCode}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED }, ({ data }) => {
const message = msg({
message: `External 2FA verification succeeded for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED }, ({ data }) => {
const message = msg({
message: `External 2FA verification failed for recipient ${data.recipientEmail}: ${data.reasonCode} (attempt ${data.attemptsUsed}/${data.attemptLimit})`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED }, ({ data }) => {
const message = msg({
message: `External 2FA token consumed for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED }, ({ data }) => {
const message = msg({
message: `External 2FA token revoked for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.exhaustive();
let selectedDescription = description.anonymous;
@@ -1,60 +0,0 @@
-- CreateEnum
CREATE TYPE "SigningTwoFactorTokenStatus" AS ENUM ('ACTIVE', 'CONSUMED', 'REVOKED', 'EXPIRED');
-- CreateTable
CREATE TABLE "SigningTwoFactorToken" (
"id" TEXT NOT NULL,
"recipientId" INTEGER NOT NULL,
"envelopeId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenSalt" TEXT NOT NULL,
"status" "SigningTwoFactorTokenStatus" NOT NULL DEFAULT 'ACTIVE',
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"attempts" INTEGER NOT NULL DEFAULT 0,
"attemptLimit" INTEGER NOT NULL DEFAULT 5,
"issuedByApiTokenId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SigningTwoFactorToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SigningSessionTwoFactorProof" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"recipientId" INTEGER NOT NULL,
"envelopeId" TEXT NOT NULL,
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SigningSessionTwoFactorProof_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SigningTwoFactorToken_recipientId_envelopeId_status_idx" ON "SigningTwoFactorToken"("recipientId", "envelopeId", "status");
-- CreateIndex
CREATE INDEX "SigningTwoFactorToken_envelopeId_idx" ON "SigningTwoFactorToken"("envelopeId");
-- CreateIndex
CREATE INDEX "SigningSessionTwoFactorProof_recipientId_envelopeId_idx" ON "SigningSessionTwoFactorProof"("recipientId", "envelopeId");
-- CreateIndex
CREATE INDEX "SigningSessionTwoFactorProof_expiresAt_idx" ON "SigningSessionTwoFactorProof"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "SigningSessionTwoFactorProof_sessionId_recipientId_envelope_key" ON "SigningSessionTwoFactorProof"("sessionId", "recipientId", "envelopeId");
-- AddForeignKey
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-58
View File
@@ -437,9 +437,6 @@ model Envelope {
envelopeAttachments EnvelopeAttachment[]
signingTwoFactorTokens SigningTwoFactorToken[]
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
@@index([type])
@@index([status])
@@index([userId])
@@ -607,9 +604,6 @@ model Recipient {
fields Field[]
signatures Signature[]
signingTwoFactorTokens SigningTwoFactorToken[]
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
@@index([token])
@@index([email])
@@index([envelopeId])
@@ -1121,58 +1115,6 @@ model Counter {
value Int
}
enum SigningTwoFactorTokenStatus {
ACTIVE
CONSUMED
REVOKED
EXPIRED
}
model SigningTwoFactorToken {
id String @id @default(cuid())
recipientId Int
envelopeId String
tokenHash String
tokenSalt String
status SigningTwoFactorTokenStatus @default(ACTIVE)
expiresAt DateTime
consumedAt DateTime?
revokedAt DateTime?
attempts Int @default(0)
attemptLimit Int @default(5)
issuedByApiTokenId Int?
createdAt DateTime @default(now())
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@index([recipientId, envelopeId, status])
@@index([envelopeId])
}
model SigningSessionTwoFactorProof {
id String @id @default(cuid())
sessionId String
recipientId Int
envelopeId String
verifiedAt DateTime @default(now())
expiresAt DateTime
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@unique([sessionId, recipientId, envelopeId])
@@index([recipientId, envelopeId])
@@index([expiresAt])
}
model RateLimit {
key String
action String
@@ -34,9 +34,6 @@ import { saveAsTemplateRoute } from './save-as-template';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field';
import { getSigningTwoFactorStatusRoute } from './signing-2fa/get-signing-two-factor-status';
import { issueSigningTwoFactorTokenRoute } from './signing-2fa/issue-signing-two-factor-token';
import { verifySigningTwoFactorTokenRoute } from './signing-2fa/verify-signing-two-factor-token';
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
import { updateEnvelopeRoute } from './update-envelope';
import { updateEnvelopeItemsRoute } from './update-envelope-items';
@@ -100,10 +97,5 @@ export const envelopeRouter = router({
saveAsTemplate: saveAsTemplateRoute,
distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute,
signing2fa: {
issue: issueSigningTwoFactorTokenRoute,
verify: verifySigningTwoFactorTokenRoute,
getStatus: getSigningTwoFactorStatusRoute,
},
signingStatus: signingStatusEnvelopeRoute,
});
@@ -176,7 +176,6 @@ export const signEnvelopeFieldRoute = procedure
field,
userId: user?.id,
authOptions,
recipientToken: token,
});
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
@@ -1,45 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getSigningTwoFactorStatus } from '@documenso/lib/server-only/signing-2fa/get-signing-two-factor-status';
import { prisma } from '@documenso/prisma';
import { procedure } from '../../trpc';
import {
ZGetSigningTwoFactorStatusRequestSchema,
ZGetSigningTwoFactorStatusResponseSchema,
} from './get-signing-two-factor-status.types';
export const getSigningTwoFactorStatusRoute = procedure
.input(ZGetSigningTwoFactorStatusRequestSchema)
.output(ZGetSigningTwoFactorStatusResponseSchema)
.query(async ({ input, ctx }) => {
const { token } = input;
ctx.logger.info({
input: {
token: '***',
},
});
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
select: {
id: true,
envelopeId: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
return await getSigningTwoFactorStatus({
recipientId: recipient.id,
envelopeId: recipient.envelopeId,
sessionId: token,
});
});
@@ -1,24 +0,0 @@
import { z } from 'zod';
export const ZGetSigningTwoFactorStatusRequestSchema = z.object({
token: z.string().describe('The recipient signing token from the signing URL.'),
});
export const ZGetSigningTwoFactorStatusResponseSchema = z.object({
required: z.boolean().describe('Whether external 2FA is required for this recipient.'),
hasActiveToken: z.boolean().describe('Whether an active (unexpired) token exists.'),
hasValidProof: z.boolean().describe('Whether a valid session proof exists.'),
tokenExpiresAt: z.date().nullable().describe('When the active token expires, if any.'),
proofExpiresAt: z.date().nullable().describe('When the session proof expires, if any.'),
attemptsRemaining: z
.number()
.nullable()
.describe('Remaining verification attempts for the active token.'),
});
export type TGetSigningTwoFactorStatusRequest = z.infer<
typeof ZGetSigningTwoFactorStatusRequestSchema
>;
export type TGetSigningTwoFactorStatusResponse = z.infer<
typeof ZGetSigningTwoFactorStatusResponseSchema
>;
@@ -1,53 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { issueSigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/issue-signing-two-factor-token';
import { authenticatedProcedure } from '../../trpc';
import {
ZIssueSigningTwoFactorTokenRequestSchema,
ZIssueSigningTwoFactorTokenResponseSchema,
issueSigningTwoFactorTokenMeta,
} from './issue-signing-two-factor-token.types';
export const issueSigningTwoFactorTokenRoute = authenticatedProcedure
.meta(issueSigningTwoFactorTokenMeta)
.input(ZIssueSigningTwoFactorTokenRequestSchema)
.output(ZIssueSigningTwoFactorTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { envelopeId, recipientId } = input;
ctx.logger.info({
input: {
envelopeId,
recipientId,
},
});
const authorizationHeader = ctx.req.headers.get('authorization');
if (!authorizationHeader) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token required to issue signing 2FA tokens',
statusCode: 401,
});
}
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token required to issue signing 2FA tokens',
statusCode: 401,
});
}
const apiToken = await getApiTokenByToken({ token });
const result = await issueSigningTwoFactorToken({
recipientId,
envelopeId,
apiTokenId: apiToken.id,
});
return result;
});
@@ -1,35 +0,0 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../../trpc';
export const issueSigningTwoFactorTokenMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/signing-2fa/issue',
summary: 'Issue a signing 2FA token',
description:
'Issue a one-time signing two-factor authentication token for a recipient. The caller is responsible for delivering the token to the signer through their own channel (e.g., SMS).',
tags: ['Envelope'],
},
};
export const ZIssueSigningTwoFactorTokenRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope.'),
recipientId: z.number().describe('The ID of the recipient to issue the token for.'),
});
export const ZIssueSigningTwoFactorTokenResponseSchema = z.object({
token: z.string().describe('The plaintext one-time token. Visible exactly once.'),
tokenId: z.string().describe('The ID of the created token record.'),
expiresAt: z.date().describe('When the token expires.'),
ttlSeconds: z.number().describe('Token time-to-live in seconds.'),
attemptLimit: z.number().describe('Maximum verification attempts allowed.'),
issuedAt: z.date().describe('When the token was issued.'),
});
export type TIssueSigningTwoFactorTokenRequest = z.infer<
typeof ZIssueSigningTwoFactorTokenRequestSchema
>;
export type TIssueSigningTwoFactorTokenResponse = z.infer<
typeof ZIssueSigningTwoFactorTokenResponseSchema
>;
@@ -1,51 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifySigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/verify-signing-two-factor-token';
import { prisma } from '@documenso/prisma';
import { procedure } from '../../trpc';
import {
ZVerifySigningTwoFactorTokenRequestSchema,
ZVerifySigningTwoFactorTokenResponseSchema,
} from './verify-signing-two-factor-token.types';
export const verifySigningTwoFactorTokenRoute = procedure
.input(ZVerifySigningTwoFactorTokenRequestSchema)
.output(ZVerifySigningTwoFactorTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token, code } = input;
ctx.logger.info({
input: {
token: '***',
},
});
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
select: {
id: true,
envelopeId: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
const result = await verifySigningTwoFactorToken({
recipientId: recipient.id,
envelopeId: recipient.envelopeId,
token: code,
sessionId: token,
});
return {
verified: result!.verified,
expiresAt: result!.expiresAt,
};
});
@@ -1,23 +0,0 @@
import { z } from 'zod';
export const ZVerifySigningTwoFactorTokenRequestSchema = z.object({
token: z.string().describe('The recipient signing token from the signing URL.'),
code: z
.string()
.min(6)
.max(6)
.regex(/^\d{6}$/)
.describe('The 6-digit one-time code to verify.'),
});
export const ZVerifySigningTwoFactorTokenResponseSchema = z.object({
verified: z.boolean().describe('Whether the code was successfully verified.'),
expiresAt: z.date().describe('When the session proof expires.'),
});
export type TVerifySigningTwoFactorTokenRequest = z.infer<
typeof ZVerifySigningTwoFactorTokenRequestSchema
>;
export type TVerifySigningTwoFactorTokenResponse = z.infer<
typeof ZVerifySigningTwoFactorTokenResponseSchema
>;
@@ -0,0 +1,46 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DEFAULT_ANALYTICS_PERIOD, resolveAnalyticsPeriod } from '@documenso/lib/server-only/team/analytics-period';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { getTeamAnalytics } from '@documenso/lib/server-only/team/get-team-analytics';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { authenticatedProcedure } from '../trpc';
import { ZGetTeamAnalyticsRequestSchema, ZGetTeamAnalyticsResponseSchema } from './get-team-analytics.types';
export const getTeamAnalyticsRoute = authenticatedProcedure
.input(ZGetTeamAnalyticsRequestSchema)
.output(ZGetTeamAnalyticsResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, period, timezone, senderIds } = input;
const { user } = ctx;
ctx.logger.info({
input: {
teamId,
period,
senderIds,
},
});
const team = await getTeamById({ userId: user.id, teamId });
// Analytics are restricted to team admins and managers (MANAGE_TEAM).
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to view analytics for this team',
});
}
const { start, end } = resolveAnalyticsPeriod({
period: period ?? DEFAULT_ANALYTICS_PERIOD,
timezone,
});
return await getTeamAnalytics({
userId: user.id,
teamId,
periodStart: start,
periodEnd: end,
senderIds,
});
});
@@ -0,0 +1,38 @@
import { z } from 'zod';
/**
* Calendar period presets for the team analytics dashboard.
*
* Kept structurally in sync with `ZAnalyticsPeriodSchema` in
* `@documenso/lib/server-only/team/get-team-analytics`. This schema is duplicated
* here (rather than imported) so the request types stay client-safe and never
* pull server-only code into the browser bundle. The compiler enforces parity
* where the resolved `period` is handed to `resolveAnalyticsPeriod` in the route.
*/
export const ZAnalyticsPeriodSchema = z.enum([
'week',
'month',
'quarter',
'year',
'lastMonth',
'last7Days',
'last30Days',
]);
export const ZGetTeamAnalyticsRequestSchema = z.object({
teamId: z.number(),
period: ZAnalyticsPeriodSchema.optional(),
timezone: z.string().optional(),
senderIds: z.array(z.number()).optional(),
});
export const ZGetTeamAnalyticsResponseSchema = z.object({
sent: z.number(),
draft: z.number(),
pending: z.number(),
completed: z.number(),
declined: z.number(),
});
export type TGetTeamAnalyticsRequest = z.infer<typeof ZGetTeamAnalyticsRequestSchema>;
export type TGetTeamAnalyticsResponse = z.infer<typeof ZGetTeamAnalyticsResponseSchema>;
@@ -16,6 +16,7 @@ import { findTeamGroupsRoute } from './find-team-groups';
import { findTeamMembersRoute } from './find-team-members';
import { findTeamsRoute } from './find-teams';
import { getTeamRoute } from './get-team';
import { getTeamAnalyticsRoute } from './get-team-analytics';
import { getTeamMembersRoute } from './get-team-members';
import {
ZCreateTeamEmailVerificationMutationSchema,
@@ -32,6 +33,7 @@ import { updateTeamSettingsRoute } from './update-team-settings';
export const teamRouter = router({
find: findTeamsRoute,
get: getTeamRoute,
getAnalytics: getTeamAnalyticsRoute,
create: createTeamRoute,
update: updateTeamRoute,
delete: deleteTeamRoute,
+34 -8
View File
@@ -1,5 +1,6 @@
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { assertUserNotDisabled } from '@documenso/lib/server-only/user/assert-user-not-disabled';
import type { TrpcApiLog } from '@documenso/lib/types/api-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { alphaid } from '@documenso/lib/universal/id';
@@ -96,6 +97,10 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me
const apiToken = await getApiTokenByToken({ token });
// Reject API requests from a disabled account. The token may still be
// present in the DB (e.g. before `disableUser` runs) so we enforce here.
assertUserNotDisabled(apiToken.user);
const trpcApiV2Logger = ctx.logger.child({
...baseLogAttributes,
auth: 'api',
@@ -140,6 +145,11 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me
});
}
// Reject session requests from a disabled account. The session may still be
// valid (sessions aren't invalidated by `disableUser`), so we gate every
// authenticated TRPC call here.
assertUserNotDisabled(ctx.user);
// Recreate the logger with a sub request ID to differentiate between batched
// requests, as well as identifying attributes so every subsequent log line
// (including errors) inherits them.
@@ -199,6 +209,11 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
const apiToken = await getApiTokenByToken({ token });
// Reject API requests from a disabled account. Presenting an API token is
// an explicit attempt to act under that account, so we don't downgrade to
// anonymous here — we reject.
assertUserNotDisabled(apiToken.user);
// Attach identifying attributes to the logger so every subsequent log line
// within this request (including errors) inherits them.
const trpcApiV2Logger = ctx.logger.child({
@@ -238,9 +253,17 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
});
}
// Treat a disabled session as anonymous. Most routes wired through
// `maybeAuthenticatedProcedure` are signer/invite flows that key off an
// input token rather than `ctx.user`, so downgrading lets those keep
// working while routes that genuinely need an account naturally fall
// through to their own auth checks.
const sessionUser = ctx.user && !ctx.user.disabled ? ctx.user : null;
const sessionRecord = sessionUser ? ctx.session : null;
// Resolve `auth` once so it stays in sync between the logger bindings and
// the outgoing metadata.
const auth = ctx.session ? 'session' : null;
const auth = sessionRecord ? 'session' : null;
// Recreate the logger with a sub request ID to differentiate between batched
// requests, as well as identifying attributes so every subsequent log line
@@ -249,7 +272,7 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
...baseLogAttributes,
auth,
nonBatchedRequestId: alphaid(),
userId: ctx.user?.id,
userId: sessionUser?.id,
apiTokenId: null,
} satisfies TrpcApiLog);
@@ -261,15 +284,15 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat
ctx: {
...ctx,
logger: trpcSessionLogger,
user: ctx.user,
session: ctx.session,
user: sessionUser,
session: sessionRecord,
metadata: {
...ctx.metadata,
auditUser: ctx.user
auditUser: sessionUser
? {
id: ctx.user.id,
name: ctx.user.name,
email: ctx.user.email,
id: sessionUser.id,
name: sessionUser.name,
email: sessionUser.email,
}
: undefined,
auth,
@@ -286,6 +309,9 @@ export const adminMiddleware = t.middleware(async ({ ctx, next, path }) => {
});
}
// Disabled admins shouldn't be able to do anything either.
assertUserNotDisabled(ctx.user);
const isUserAdmin = isAdmin(ctx.user);
if (!isUserAdmin) {