Compare commits

..

4 Commits

Author SHA1 Message Date
ephraimduncan 5a0f438ee6 Merge branch 'main' into feat/prefetch-intent-navigation-links
# Conflicts:
#	apps/remix/app/components/general/app-header.tsx
#	apps/remix/app/components/general/app-nav-desktop.tsx
#	apps/remix/app/components/general/app-nav-mobile.tsx
#	apps/remix/app/components/general/folder/folder-card.tsx
#	apps/remix/app/components/general/folder/folder-grid.tsx
#	apps/remix/app/components/general/menu-switcher.tsx
#	apps/remix/app/components/general/org-menu-switcher.tsx
#	apps/remix/app/components/general/settings-nav-mobile.tsx
#	apps/remix/app/components/tables/admin-claims-table.tsx
#	apps/remix/app/components/tables/admin-dashboard-users-table.tsx
#	apps/remix/app/components/tables/admin-organisations-table.tsx
#	apps/remix/app/components/tables/documents-table-action-button.tsx
#	apps/remix/app/components/tables/organisation-email-domains-table.tsx
#	apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#	apps/remix/app/components/tables/user-billing-organisations-table.tsx
#	apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx
#	apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx
#	apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#	apps/remix/app/routes/_authenticated+/dashboard.tsx
#	apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#	apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
#	packages/email/template-components/template-document-reminder.tsx
#	packages/ui/primitives/tooltip.tsx
2026-05-08 11:17:51 +00:00
ephraimduncan e0cdddc59c chore: restore translation files from main
These .po files were accidentally reverted during the merge commit.
Restoring them to match main since translations are autogenerated.
2026-04-20 00:40:29 +00:00
ephraimduncan 862b2a78ea Merge branch 'main' into feat/prefetch-intent-navigation-links 2026-04-20 00:20:51 +00:00
ephraimduncan 807ad95354 perf: add prefetch="intent" to navigation Link components
Enables React Router's intent-based prefetching on Link components
across the app, preloading route data and modules on hover/focus
for faster perceived navigation.
2026-03-16 10:02:32 +00:00
149 changed files with 3075 additions and 3934 deletions
@@ -1,138 +0,0 @@
---
date: 2026-05-06
title: Platform Signing Page Branding
---
## What
Platform-plan organisations (and their teams) can customise the **non-embed
signing pages** (`/sign/:token`, `/d/:token`, and the sibling
complete/expired/rejected/waiting pages) with:
- Six brand colour tokens (background, foreground, primary, primary-foreground,
border, ring) plus a border-radius length.
- A free-text custom CSS block (up to 256 KB).
Settings live on `OrganisationGlobalSettings` and `TeamGlobalSettings`. Teams
inherit from the org via the existing `brandingEnabled === null` mechanism.
## Why
- Embed customers already have white-label CSS; Platform customers want the
same coverage on direct signing URLs that they iframe or link to.
- Persisting on org/team (not per envelope) means it's set-and-forget.
- Sanitising **on save** lets us inline the verbatim string at SSR — no
per-render parsing cost, no `<style>.innerHTML` injection on the client.
- Reusing the existing `embedSigningWhiteLabel` claim flag keeps "if you can
white-label an embed, you can white-label this" as one decision.
## How
### Storage (`packages/prisma/schema.prisma`)
Two new fields on each settings model. No new tables.
| Field | Org type | Team type |
| ---------------- | ------------------ | ------------------ |
| `brandingColors` | `Json?` (nullable) | `Json?` (nullable) |
| `brandingCss` | `String @default("")` | `String?` |
Colours are validated against `ZCssVarsSchema`. The team's `null` means
"inherit"; an empty colour object is collapsed to `null` server-side so a
team toggling `brandingEnabled = true` without filling in colours doesn't
silently override the org's defaults with nothing.
### Sanitiser (`packages/lib/utils/sanitize-branding-css.ts`)
PostCSS + `postcss-selector-parser`. Runs on save only.
- Drops selectors containing `::before`/`::after`/`::backdrop`/`::marker` or
the universal `*`.
- Drops integrity-breaking properties (`display`, `position`, `transform`,
layout-affecting dimensions, text-hiding properties).
- Drops declaration values containing `url(`, `expression(`, `@import`,
`javascript:`.
- Strips `!important`.
- Allows `@media` only; drops other at-rules.
- **Does not** rewrite selectors. Scoping happens at render time via native
CSS nesting under `.documenso-branded { ... }`.
- Final-pass tripwire: if a literal `</style` somehow survives serialization,
reject the entire output. PostCSS already escapes `<` to `\3c` whenever it
would form `</...`; the explicit check is belt-and-braces in case a future
serializer regresses.
- Returns `{ css, warnings[] }`. Warnings are surfaced in the UI.
Border-radius is the only token interpolated raw into a `<style>` block; it
is regex-validated (`CSS_LENGTH_REGEX`) at both the Zod schema and the
runtime `toNativeCssVars` call. Belt-and-braces against schema drift.
### Render (`apps/remix/app/components/general/recipient-branding.tsx`)
Each recipient loader calls `loadRecipientBrandingByTeamId` and threads the
payload through to `<RecipientBranding>`, which emits a single
nonce-attributed `<style>`:
```
.documenso-branded {
--background: ...; ...
<user css>
}
```
Native CSS nesting expands user rules under the wrapper. The body class is
applied unconditionally to recipient routes in `root.tsx` via `useMatches()`
so portaled Radix content (dialogs, popovers, tooltips, dropdowns) inherits
the scope.
CSP for recipient routes already supports `<style nonce>`; no policy
changes needed.
### Plan gate
`organisationClaim.flags.embedSigningWhiteLabel || !IS_BILLING_ENABLED()`.
Self-hosted instances always allow. The outer paywall for logo/URL/details
stays on `allowCustomBranding` (Team plan and up); only the new
colour/CSS section is Platform-only.
### UI (`apps/remix/app/components/forms/branding-preferences-form.tsx`)
Extends the existing branding form. Six `<ColorPicker showHex>` (rewritten
to use the native `<input type="color">` instead of `react-colorful`, which
was removed) in a 2-col grid, plus a free-text radius input and an
`<Accordion>` revealing a mono `<Textarea>`. Defaults come from
`packages/lib/constants/theme.ts` (light-mode hex mirror of `theme.css`).
Warnings from the sanitiser are surfaced in an `<Alert variant="warning">`
after save, and the `brandingCss` textarea is re-synced from the persisted
value so the user sees exactly what was stored. Other fields are
deliberately NOT reset on settings refetch — that would clobber in-flight
edits.
### TRPC
`update-organisation-settings` and `update-team-settings` accept the new
fields, run them through `sanitizeBrandingCss` + `normalizeBrandingColors`,
and return any sanitiser warnings to the client. The team route treats
`null` as "inherit"; an empty post-sanitisation string is collapsed to
`null` (team) so an empty override doesn't mask the org's CSS.
## Known accepted limitations
- The sanitiser does not prevent hostile-but-syntactically-valid CSS
(`color: transparent`, low-contrast values, etc.). The customer is
branding **their own** signing pages — we focus on integrity (no
overlay/hide/exfiltrate), not aesthetic policing.
- User rules targeting `body`/`html`/`:root` no-op once nested under the
wrapper class. Documented for users.
- CSS nesting baseline is Chrome 120+ / Firefox 117+ / Safari 16.5+.
Acceptable for the Platform-tier audience.
- No automated `theme.css``theme.ts` sync check; fat comment in
`theme.ts` reminds devs to update both.
- Per-section team inherit is coarse — `brandingEnabled = null` inherits
everything from the org. Per-field inherit toggles are deferred.
## Out of scope
Live preview, embed-route sanitiser unification, email/PDF certificate
branding, custom font upload, the full ~30 colour tokens in the picker UI,
wiring `hidePoweredBy` through to the actual footer.
+1 -9
View File
@@ -160,16 +160,8 @@ NEXT_PRIVATE_REDIS_PREFIX="documenso"
NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Set to "true" to disable all signup methods (email, Google, Microsoft, OIDC, including the organisation OIDC portal).
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# OPTIONAL: Set to "true" to disable email/password signup only.
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=
# OPTIONAL: Set to "true" to block new-account creation through Google. Existing linked users can still sign in.
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=
# OPTIONAL: Set to "true" to block new-account creation through Microsoft. Existing linked users can still sign in.
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
# OPTIONAL: Set to "true" to block new-account creation through OIDC (including the organisation portal).
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
@@ -224,41 +224,28 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
## Feature Flags
| Variable | Description | Default |
| -------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Disable all signup methods application-wide | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only. SSO signup is unaffected | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google. Existing Google-linked users can still sign in | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
| Variable | Description | Default |
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
### Signup Restrictions
You can control who is allowed to create accounts on your instance with the following environment variables:
You can control who is allowed to create accounts on your instance using two environment variables:
- **`NEXT_PUBLIC_DISABLE_SIGNUP`** (master switch): Set to `true` to block all new signups across every method (email/password, Google, Microsoft, OIDC). When set, this also blocks new-account creation through the organisation OIDC authentication portal.
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`**: Set to `true` to disable email/password signup only. SSO signup is still allowed.
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`**: Set to `true` to block brand-new account creation through the matching SSO provider. Existing users with the provider already linked can still sign in, and existing users can still link the provider to their account. `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` also blocks new-account creation through the organisation authentication portal.
- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups.
- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains.
Sign-in for existing users is never affected — only the creation of brand-new accounts.
Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
Both the master switch and the domain allowlist apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
When both the master switch and the domain allowlist are set, the master switch takes precedence. Signups are blocked regardless of the domain list.
When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list.
```bash
# Allow signups only from specific domains
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
# Allow OIDC signup only; block email/password, Google, Microsoft
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true"
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true"
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
# Or disable signups entirely
NEXT_PUBLIC_DISABLE_SIGNUP="true"
```
@@ -384,10 +371,6 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
# Signup restrictions (optional)
# NEXT_PUBLIC_DISABLE_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
```
@@ -155,13 +155,7 @@ PORT=3000
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
# Signup restrictions (optional)
# Master switch — disables every signup method
NEXT_PUBLIC_DISABLE_SIGNUP=false
# Per-method switches (optional). Each disables brand-new account creation through that method.
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=true
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=true
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
```
@@ -258,10 +252,7 @@ Navigate to the signup page and create your account. Verify your email address
<Callout type="info">
All accounts created through signup are regular user accounts. Admin access must be granted
directly in the database. Once your accounts are set up, consider disabling public signups by
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`. For finer control, use the per-method switches
`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP`, `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`,
`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`, `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`, or restrict
signups to specific email domains with
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with
`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`.
</Callout>
@@ -100,11 +100,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch — disable all signup methods | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google OAuth | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
@@ -153,11 +153,7 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
| Variable | Description | Default |
| --------------------------------- | ---------------------------------- | ------- |
| `PORT` | Application port | `3000` |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch — disable all signup methods | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup only | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Block new accounts via Google OAuth | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
@@ -134,13 +134,6 @@ Leave empty to allow any domain authenticated by your identity provider.
team.
</Callout>
### Allow Personal Organisations
Controls whether users signing in via SSO for the first time also receive their own personal organisation in addition to joining your organisation.
- **Enabled**: New SSO users get a personal organisation where they can create and manage their own documents independently.
- **Disabled**: New SSO users only join your organisation and do not receive a personal organisation.
## User Provisioning
When a user signs in through your SSO portal for the first time:
@@ -1,11 +1,7 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
@@ -19,7 +15,6 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@@ -33,20 +28,17 @@ const ZBrandingPreferencesFormSchema = z.object({
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
brandingColors: ZCssVarsSchema.default({}),
brandingCss: z.string().max(10_000).default(''),
});
export type TBrandingPreferencesFormSchema = z.infer<typeof ZBrandingPreferencesFormSchema>;
type SettingsSubset = Pick<
TeamGlobalSettings,
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails' | 'brandingColors' | 'brandingCss'
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails'
>;
export type BrandingPreferencesFormProps = {
canInherit?: boolean;
hasAdvancedBranding: boolean;
settings: SettingsSubset;
onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise<void>;
context: 'Team' | 'Organisation';
@@ -54,13 +46,11 @@ export type BrandingPreferencesFormProps = {
export function BrandingPreferencesForm({
canInherit = false,
hasAdvancedBranding,
settings,
onFormSubmit,
context,
}: BrandingPreferencesFormProps) {
const { t } = useLingui();
const nonce = useCspNonce();
const team = useOptionalCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -68,17 +58,12 @@ export function BrandingPreferencesForm({
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
const initialColors = parsedColors.success ? parsedColors.data : {};
const form = useForm<TBrandingPreferencesFormSchema>({
values: {
defaultValues: {
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
brandingColors: initialColors,
brandingCss: settings.brandingCss ?? '',
},
resolver: zodResolver(ZBrandingPreferencesFormSchema),
});
@@ -319,225 +304,6 @@ export function BrandingPreferencesForm({
/>
</div>
{hasAdvancedBranding && (
<div className="relative flex w-full flex-col gap-y-6">
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
<div>
<FormLabel>
<Trans>Brand Colours</Trans>
</FormLabel>
<FormDescription className="mt-1 mb-4">
<Trans>Customise the colours used on your signing pages.</Trans>
</FormDescription>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="brandingColors.background"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background</Trans>
</FormLabel>
<FormDescription>
<Trans>Base background colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.background}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.foreground"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Foreground</Trans>
</FormLabel>
<FormDescription>
<Trans>Base text colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.foreground}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.primary"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Primary</Trans>
</FormLabel>
<FormDescription>
<Trans>Primary action colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primary}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.primaryForeground"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Primary Foreground</Trans>
</FormLabel>
<FormDescription>
<Trans>Text colour on primary buttons.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.border"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Border</Trans>
</FormLabel>
<FormDescription>
<Trans>Default border colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.border}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.ring"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Ring</Trans>
</FormLabel>
<FormDescription>
<Trans>Focus ring colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.ring}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="mt-4">
<FormField
control={form.control}
name="brandingColors.radius"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Border Radius</Trans>
</FormLabel>
<FormControl>
<Input
type="text"
placeholder={DEFAULT_BRAND_RADIUS}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
<FormDescription>
<Trans>Border radius size in REM units (e.g. 0.5rem).</Trans>
</FormDescription>
</FormItem>
)}
/>
</div>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="custom-css" className="border-none">
<AccordionTrigger className="rounded border px-3 py-2 text-left text-foreground hover:bg-muted/40 hover:no-underline">
<Trans>Advanced Custom CSS</Trans>
</AccordionTrigger>
<AccordionContent className="-mx-1 px-1 pt-4 text-muted-foreground text-sm leading-relaxed">
<FormField
control={form.control}
name="brandingCss"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Textarea
placeholder={t`/* Write CSS targeting your signing pages. Selectors are scoped automatically. */
.my-button {
background: red;
}`}
className="min-h-[200px] font-mono text-xs"
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormDescription>
<Trans>
Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and
pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be
shown after you save.
</Trans>
</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
+76 -88
View File
@@ -58,20 +58,18 @@ export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
export type SignUpFormProps = {
className?: string;
initialEmail?: string;
isEmailPasswordSignupEnabled?: boolean;
isGoogleSignupEnabled?: boolean;
isMicrosoftSignupEnabled?: boolean;
isOidcSignupEnabled?: boolean;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
returnTo?: string;
};
export const SignUpForm = ({
className,
initialEmail,
isEmailPasswordSignupEnabled = true,
isGoogleSignupEnabled,
isMicrosoftSignupEnabled,
isOidcSignupEnabled,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
returnTo,
}: SignUpFormProps) => {
const { _ } = useLingui();
@@ -88,7 +86,7 @@ export const SignUpForm = ({
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const hasSocialAuthEnabled = isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const form = useForm<TSignUpFormSchema>({
values: {
@@ -147,7 +145,7 @@ export const SignUpForm = ({
const onSignUpWithGoogleClick = async () => {
try {
await authClient.google.signIn();
} catch {
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`),
@@ -159,7 +157,7 @@ export const SignUpForm = ({
const onSignUpWithMicrosoftClick = async () => {
try {
await authClient.microsoft.signIn();
} catch {
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`),
@@ -171,7 +169,7 @@ export const SignUpForm = ({
const onSignUpWithOIDCClick = async () => {
try {
await authClient.oidc.signIn();
} catch {
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`),
@@ -237,80 +235,72 @@ export const SignUpForm = ({
<Form {...form}>
<form className="flex w-full flex-1 flex-col gap-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
{isEmailPasswordSignupEnabled && (
<>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Full Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Full Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange, value } }) => (
<FormItem>
<FormLabel>
<Trans>Sign Here</Trans>
</FormLabel>
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
value={value}
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange, value } }) => (
<FormItem>
<FormLabel>
<Trans>Sign Here</Trans>
</FormLabel>
<FormControl>
<SignaturePadDialog disabled={isSubmitting} value={value} onChange={(v) => onChange(v ?? '')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormMessage />
</FormItem>
)}
/>
{turnstileSiteKey && (
<Turnstile
@@ -335,7 +325,7 @@ export const SignUpForm = ({
</div>
)}
{isGoogleSignupEnabled && (
{isGoogleSSOEnabled && (
<Button
type="button"
size="lg"
@@ -349,7 +339,7 @@ export const SignUpForm = ({
</Button>
)}
{isMicrosoftSignupEnabled && (
{isMicrosoftSSOEnabled && (
<Button
type="button"
size="lg"
@@ -363,7 +353,7 @@ export const SignUpForm = ({
</Button>
)}
{isOidcSignupEnabled && (
{isOIDCSSOEnabled && (
<Button
type="button"
size="lg"
@@ -387,11 +377,9 @@ export const SignUpForm = ({
</p>
</fieldset>
{isEmailPasswordSignupEnabled && (
<Button loading={form.formState.isSubmitting} type="submit" size="lg" className="mt-6 w-full">
<Trans>Create account</Trans>
</Button>
)}
<Button loading={form.formState.isSubmitting} type="submit" size="lg" className="mt-6 w-full">
<Trans>Create account</Trans>
</Button>
</form>
</Form>
<p className="mt-6 text-muted-foreground text-xs">
@@ -58,6 +58,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link
prefetch="intent"
to={getRootHref(params)}
className="hidden rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:inline"
>
@@ -67,7 +68,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
<AppNavDesktop setIsCommandMenuOpen={setIsCommandMenuOpen} />
<Button asChild variant="outline" className="relative hidden h-10 w-10 rounded-lg md:flex">
<Link to="/inbox" className="relative block h-10 w-10">
<Link prefetch="intent" to="/inbox" className="relative block h-10 w-10">
<InboxIcon className="h-5 w-5 flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground" />
{unreadCountData && unreadCountData.count > 0 && (
@@ -70,6 +70,7 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
>
{menuNavigationLinks.map(({ href, label }) => (
<Link
prefetch="intent"
key={href}
to={href}
className={cn(
@@ -80,13 +80,14 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<SheetContent className="flex w-full max-w-[350px] flex-col">
<Link to="/" onClick={handleMenuItemClick}>
<Link prefetch="intent" to="/" onClick={handleMenuItemClick}>
<img src={LogoImage} alt="Documenso Logo" className="dark:invert" width={170} height={25} />
</Link>
<div className="mt-8 flex w-full flex-col items-start gap-y-4">
{menuNavigationLinks.map(({ href, text }) => (
<Link
prefetch="intent"
key={href}
className="flex items-center gap-2 font-semibold text-2xl text-foreground hover:text-foreground/80"
to={href}
@@ -63,7 +63,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
))
.with({ isComplete: false }, () => (
<Button className="w-full" asChild>
<Link to={formatPath}>
<Link prefetch="intent" to={formatPath}>
<Trans>Edit</Trans>
</Link>
</Button>
@@ -82,7 +82,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
{(isOwner || isCurrentTeamDocument) && !isComplete && (
<DropdownMenuItem asChild>
<Link to={`${documentsPath}/${envelope.id}/edit`}>
<Link prefetch="intent" to={`${documentsPath}/${envelope.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
@@ -113,7 +113,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
/>
<DropdownMenuItem asChild>
<Link to={`${documentsPath}/${envelope.id}/logs`}>
<Link prefetch="intent" to={`${documentsPath}/${envelope.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
<Trans>Audit Logs</Trans>
</Link>
@@ -74,7 +74,7 @@ export default function EnvelopeEditorHeader() {
{editorConfig.embedded?.customBrandingLogo ? (
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="h-6 w-auto" />
) : (
<Link to="/">
<Link prefetch="intent" to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
@@ -504,7 +504,7 @@ export const EnvelopeEditor = () => {
})}
asChild
>
<Link to={relativePath.basePath}>
<Link prefetch="intent" to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!minimizeLeftSidebar && (
@@ -4,7 +4,6 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ALLOWED_UPLOAD_MIME_TYPES } from '@documenso/lib/constants/upload';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
@@ -116,12 +115,6 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
.with('CONVERSION_SERVICE_UNAVAILABLE', () => t`File conversion is not available. Please upload a PDF file.`)
.with('CONVERSION_FAILED', () => t`Failed to convert file. Please try uploading a PDF instead.`)
.with(
'UNSUPPORTED_FILE_TYPE',
() => t`This file type is not supported. Please upload a PDF, Word document, or image.`,
)
.otherwise(() => t`An error occurred during upload.`);
toast({
@@ -165,7 +158,9 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
});
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: ALLOWED_UPLOAD_MIME_TYPES,
accept: {
'application/pdf': ['.pdf'],
},
multiple: true,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
maxFiles: maximumEnvelopeItemCount,
@@ -188,7 +183,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
</h2>
<p className="mt-4 text-md text-muted-foreground">
<Trans>Drag and drop your document here</Trans>
<Trans>Drag and drop your PDF file here</Trans>
</p>
{isUploadDisabled && IS_BILLING_ENABLED() && (
@@ -114,18 +114,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
.with(
'UNSUPPORTED_FILE_TYPE',
() => t`This file type is not supported. Please upload a PDF, DOCX, JPEG, or PNG file.`,
)
.with(
'CONVERSION_SERVICE_UNAVAILABLE',
() => t`File conversion is temporarily unavailable. Please upload a PDF file instead.`,
)
.with(
'CONVERSION_FAILED',
() => t`Failed to convert the file to PDF. Please try again or upload a PDF file instead.`,
)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
@@ -54,7 +54,7 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
};
return (
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<Link prefetch="intent" to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<Card className="h-full border border-border transition-all hover:bg-muted/50">
<CardContent className="p-4">
<div className="flex min-w-0 items-center gap-3">
@@ -70,7 +70,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
className="flex flex-1 items-center font-medium text-muted-foreground text-sm hover:text-muted-foreground/80"
data-testid="folder-grid-breadcrumbs"
>
<Link to={formatRootPath()} className="flex items-center">
<Link prefetch="intent" to={formatRootPath()} className="flex items-center">
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home</Trans>
</Link>
@@ -85,7 +85,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
foldersData?.breadcrumbs.map((folder) => (
<div key={folder.id} className="flex items-center">
<span className="px-3">/</span>
<Link to={formatBreadCrumbPath(folder.id)} className="flex items-center">
<Link prefetch="intent" to={formatBreadCrumbPath(folder.id)} className="flex items-center">
<FolderIcon className="mr-2 h-4 w-4" />
<span>{folder.name}</span>
</Link>
@@ -188,6 +188,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
{unpinnedFolders.length > 12 && (
<div className="mt-2 flex items-center justify-center">
<Link
prefetch="intent"
className="font-medium text-muted-foreground text-sm hover:text-foreground"
to={formatViewAllFoldersPath()}
>
@@ -59,7 +59,11 @@ export const MenuSwitcher = () => {
<DropdownMenuContent className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')} align="end" forceMount>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/organisations?action=add-organisation" className="flex items-center justify-between">
<Link
prefetch="intent"
to="/settings/organisations?action=add-organisation"
className="flex items-center justify-between"
>
<Trans>Create Organisation</Trans>
<Plus className="ml-2 h-4 w-4" />
</Link>
@@ -68,20 +72,20 @@ export const MenuSwitcher = () => {
{isUserAdmin && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/admin">
<Link prefetch="intent" to="/admin">
<Trans>Admin panel</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Link prefetch="intent" to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/profile">
<Link prefetch="intent" to="/settings/profile">
<Trans>User settings</Trans>
</Link>
</DropdownMenuItem>
@@ -147,7 +147,7 @@ export const OrgMenuSwitcher = () => {
)}
asChild
>
<Link to={`/o/${org.url}`} className="flex items-center space-x-2 pr-8">
<Link prefetch="intent" to={`/o/${org.url}`} className="flex items-center space-x-2 pr-8">
<span
className={cn('min-w-0 flex-1 truncate', {
'font-semibold': org.id === selectedOrg?.id,
@@ -161,6 +161,7 @@ export const OrgMenuSwitcher = () => {
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link
prefetch="intent"
to={`/o/${org.url}/settings`}
className="mr-2 rounded-sm border p-1 text-muted-foreground transition-opacity duration-200 group-hover:opacity-100 md:opacity-0"
>
@@ -172,7 +173,7 @@ export const OrgMenuSwitcher = () => {
))}
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to="/settings/organisations?action=add-organisation">
<Link prefetch="intent" to="/settings/organisations?action=add-organisation">
<Plus className="mr-2 h-4 w-4" />
<Trans>Create Organisation</Trans>
</Link>
@@ -200,7 +201,7 @@ export const OrgMenuSwitcher = () => {
)}
asChild
>
<Link to={`/t/${team.url}`} className="flex items-center space-x-2 pr-8">
<Link prefetch="intent" to={`/t/${team.url}`} className="flex items-center space-x-2 pr-8">
<span
className={cn('min-w-0 flex-1 truncate', {
'font-semibold': team.id === currentTeam?.id,
@@ -214,6 +215,7 @@ export const OrgMenuSwitcher = () => {
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<div className="absolute top-0 right-0 bottom-0 flex items-center justify-center">
<Link
prefetch="intent"
to={`/t/${team.url}/settings`}
className="mr-2 rounded-sm border p-1 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
@@ -231,7 +233,7 @@ export const OrgMenuSwitcher = () => {
{displayedOrg && (
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to={`/o/${displayedOrg.url}/settings/teams?action=add-team`}>
<Link prefetch="intent" to={`/o/${displayedOrg.url}/settings/teams?action=add-team`}>
<Plus className="mr-2 h-4 w-4" />
<Trans>Create Team</Trans>
</Link>
@@ -252,7 +254,7 @@ export const OrgMenuSwitcher = () => {
<div className="flex-1 overflow-y-auto p-1.5">
{isUserAdmin && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/admin">
<Link prefetch="intent" to="/admin">
<Trans>Admin panel</Trans>
</Link>
</DropdownMenuItem>
@@ -261,7 +263,7 @@ export const OrgMenuSwitcher = () => {
{currentOrganisation &&
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole) && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/o/${currentOrganisation.url}/settings`}>
<Link prefetch="intent" to={`/o/${currentOrganisation.url}/settings`}>
<Trans>Organisation settings</Trans>
</Link>
</DropdownMenuItem>
@@ -269,20 +271,20 @@ export const OrgMenuSwitcher = () => {
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/t/${currentTeam.url}/settings`}>
<Link prefetch="intent" to={`/t/${currentTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Link prefetch="intent" to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/profile">
<Link prefetch="intent" to="/settings/profile">
<Trans>Account</Trans>
</Link>
</DropdownMenuItem>
@@ -297,6 +299,7 @@ export const OrgMenuSwitcher = () => {
{currentOrganisation && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link
prefetch="intent"
to={{
pathname: `/o/${currentOrganisation.url}/support`,
search: currentTeam ? `?team=${currentTeam.id}` : '',
@@ -1,90 +0,0 @@
import type { TCssVarsSchema } from '@documenso/lib/types/css-vars';
import { useEffect } from 'react';
import { toNativeCssVarsString } from '~/utils/css-vars';
export type RecipientBrandingPayload = {
allowCustomBranding: boolean;
colors?: TCssVarsSchema | null;
css?: string | null;
};
export type RecipientBrandingProps = {
branding: RecipientBrandingPayload | null | undefined;
cspNonce: string | undefined;
};
/**
* Renders a `<style nonce>` block for a recipient route, scoped to the
* `.documenso-branded` wrapper rendered in `_recipient+/_layout.tsx`.
*
* Both the CSS variables (from `branding.colors`) and the user's custom CSS
* (from `branding.css`) are emitted inside a single nested rule so the user
* doesn't need to scope their own selectors — native CSS nesting handles it:
*
* .documenso-branded {
* --background: ...;
* .my-class { color: red; }
* }
*
* Equivalent to `.documenso-branded .my-class { color: red; }` after expansion.
*
* The user's CSS is sanitised at write time (`sanitizeBrandingCss`) and stored
* in the DB as-is — no per-render parsing.
*
* Why both SSR `<style>` and a `useEffect` injection?
*
* The rendered `<style>` covers the initial server render so the first paint
* already has the branding applied — without it, the page would flash the
* default theme before hydration.
*
* The `useEffect` covers in-app client-side navigations. When the user
* navigates between recipient routes via the router, the server render
* doesn't run again, so React reconciles the existing DOM. If the loader
* data changes (e.g. a different recipient with different branding), the
* SSR'd `<style>` from the previous page may persist or be reused, leading
* to stale or inconsistent branding. Appending a fresh `<style>` to
* `document.head` and removing it on cleanup guarantees the active branding
* matches the current route on both initial load and subsequent navigations.
*/
export const RecipientBranding = ({ branding, cspNonce }: RecipientBrandingProps) => {
const varsString = toNativeCssVarsString(branding?.colors ?? {});
const userCss = branding?.css ?? '';
const hasVars = varsString.trim().length > 0;
const hasUserCss = userCss.trim().length > 0;
const innerBody = `${hasVars ? `${varsString}\n` : ''}${hasUserCss ? userCss : ''}`.trim();
const css = `.documenso-branded { ${innerBody} }`;
useEffect(() => {
if (!branding?.allowCustomBranding) {
return;
}
if (!hasVars && !hasUserCss) {
return;
}
const style = document.createElement('style');
style.setAttribute('nonce', cspNonce ?? '');
style.textContent = css;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, [branding, cspNonce, css, hasUserCss, hasVars]);
if (!branding?.allowCustomBranding) {
return null;
}
if (!hasVars && !hasUserCss) {
return null;
}
return <style nonce={cspNonce}>{css}</style>;
};
@@ -23,7 +23,7 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link to="/settings/profile">
<Link prefetch="intent" to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
@@ -35,14 +35,14 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Link prefetch="intent" to="/settings/document">
<Button variant="ghost" className={cn('w-full justify-start')}>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/document">
<Link prefetch="intent" className="w-full pl-8" to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
@@ -51,7 +51,7 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/branding">
<Link prefetch="intent" className="w-full pl-8" to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
@@ -60,7 +60,7 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/email">
<Link prefetch="intent" className="w-full pl-8" to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
@@ -69,7 +69,7 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Button>
</Link>
<Link to="/settings/public-profile">
<Link prefetch="intent" to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
@@ -79,7 +79,7 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Button>
</Link>
<Link to="/settings/tokens">
<Link prefetch="intent" to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
@@ -89,7 +89,7 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Button>
</Link>
<Link to="/settings/webhooks">
<Link prefetch="intent" to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
@@ -101,7 +101,7 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</>
)}
<Link to="/settings/organisations">
<Link prefetch="intent" to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
@@ -112,7 +112,7 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Link prefetch="intent" to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
@@ -123,7 +123,7 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Link>
)}
<Link to="/settings/security">
<Link prefetch="intent" to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
@@ -34,7 +34,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
return (
<div className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)} {...props}>
<Link to="/settings/profile">
<Link prefetch="intent" to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
@@ -46,7 +46,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Link prefetch="intent" to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
@@ -56,7 +56,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Button>
</Link>
<Link to="/settings/branding">
<Link prefetch="intent" to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
@@ -66,7 +66,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Button>
</Link>
<Link to="/settings/email">
<Link prefetch="intent" to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
@@ -76,7 +76,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Button>
</Link>
<Link to="/settings/public-profile">
<Link prefetch="intent" to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
@@ -86,7 +86,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Button>
</Link>
<Link to="/settings/tokens">
<Link prefetch="intent" to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
@@ -96,7 +96,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Button>
</Link>
<Link to="/settings/webhooks">
<Link prefetch="intent" to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
@@ -108,7 +108,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</>
)}
<Link to="/settings/organisations">
<Link prefetch="intent" to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
@@ -119,7 +119,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Link prefetch="intent" to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
@@ -130,7 +130,7 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Link>
)}
<Link to="/settings/security">
<Link prefetch="intent" to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
@@ -6,7 +6,7 @@ import { Link } from 'react-router';
export default function DocumentEditSkeleton() {
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link to="/" className="flex grow-0 items-center text-documenso-700 hover:opacity-80">
<Link prefetch="intent" to="/" className="flex grow-0 items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
@@ -71,7 +71,11 @@ export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => {
{
header: t`Name`,
accessorKey: 'name',
cell: ({ row }) => <Link to={`/admin/organisations?query=claim:${row.original.id}`}>{row.original.name}</Link>,
cell: ({ row }) => (
<Link prefetch="intent" to={`/admin/organisations?query=claim:${row.original.id}`}>
{row.original.name}
</Link>
),
},
{
header: t`Allowed teams`,
@@ -71,7 +71,7 @@ export const AdminDashboardUsersTable = ({ users, totalPages, perPage, page }: A
cell: ({ row }) => {
return (
<Button className="w-24" asChild>
<Link to={`/admin/users/${row.original.id}`}>
<Link prefetch="intent" to={`/admin/users/${row.original.id}`}>
<Edit className="mr-2 -ml-1 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
@@ -82,7 +82,11 @@ export const AdminOrganisationsTable = ({
{
header: t`Organisation`,
accessorKey: 'name',
cell: ({ row }) => <Link to={`/admin/organisations/${row.original.id}`}>{row.original.name}</Link>,
cell: ({ row }) => (
<Link prefetch="intent" to={`/admin/organisations/${row.original.id}`}>
{row.original.name}
</Link>
),
},
{
header: t`Created At`,
@@ -92,7 +96,11 @@ export const AdminOrganisationsTable = ({
{
header: t`Owner`,
accessorKey: 'owner',
cell: ({ row }) => <Link to={`/admin/users/${row.original.owner.id}`}>{row.original.owner.name}</Link>,
cell: ({ row }) => (
<Link prefetch="intent" to={`/admin/users/${row.original.owner.id}`}>
{row.original.owner.name}
</Link>
),
},
{
id: 'role',
@@ -152,14 +160,14 @@ export const AdminOrganisationsTable = ({
</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to={`/admin/organisations/${row.original.id}`}>
<Link prefetch="intent" to={`/admin/organisations/${row.original.id}`}>
<SettingsIcon className="mr-2 h-4 w-4" />
<Trans>Manage</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/admin/users/${row.original.owner.id}`}>
<Link prefetch="intent" to={`/admin/users/${row.original.owner.id}`}>
<UserIcon className="mr-2 h-4 w-4" />
<Trans>View owner</Trans>
</Link>
@@ -58,7 +58,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
})
.with(isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, () => (
<Button className="w-32" asChild>
<Link to={formatPath}>
<Link prefetch="intent" to={formatPath}>
<Edit className="mr-2 -ml-1 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
@@ -131,7 +131,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
)}
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
<Link to={formatPath}>
<Link prefetch="intent" to={formatPath}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
@@ -102,7 +102,9 @@ export const OrganisationEmailDomainsDataTable = () => {
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>Manage</Link>
<Link prefetch="intent" to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>
Manage
</Link>
</Button>
<OrganisationEmailDomainDeleteDialog
@@ -80,7 +80,7 @@ export const OrganisationGroupsDataTable = () => {
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/groups/${row.original.id}`}>
<Link prefetch="intent" to={`/o/${organisation.url}/settings/groups/${row.original.id}`}>
<Trans>Manage</Trans>
</Link>
</Button>
@@ -76,7 +76,7 @@ export const OrganisationTeamsTable = () => {
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button variant="outline" asChild>
<Link to={`/t/${row.original.url}/settings`}>
<Link prefetch="intent" to={`/t/${row.original.url}/settings`}>
<Trans>Manage</Trans>
</Link>
</Button>
@@ -78,7 +78,7 @@ export const TemplatesTableActionDropdown = ({
{canMutate && (
<>
<DropdownMenuItem asChild>
<Link to={formatPath}>
<Link prefetch="intent" to={formatPath}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
@@ -74,7 +74,7 @@ export const UserBillingOrganisationsTable = () => {
id: 'actions',
cell: ({ row }) => (
<Button asChild variant="outline">
<Link to={`/o/${row.original.url}/settings/billing`}>
<Link prefetch="intent" to={`/o/${row.original.url}/settings/billing`}>
<Trans>Manage Billing</Trans>
</Link>
</Button>
@@ -92,7 +92,7 @@ export const UserOrganisationsTable = () => {
<div className="flex justify-end space-x-2">
{canExecuteOrganisationAction('MANAGE_ORGANISATION', row.original.currentOrganisationRole) && (
<Button variant="outline" asChild>
<Link to={`/o/${row.original.url}/settings`}>
<Link prefetch="intent" to={`/o/${row.original.url}/settings`}>
<Trans>Manage</Trans>
</Link>
</Button>
+4 -5
View File
@@ -3,6 +3,7 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { i18n } from '@lingui/core';
import { detect, fromHtmlTag } from '@lingui/detect-locale';
import { I18nProvider } from '@lingui/react';
import posthog from 'posthog-js';
import { StrictMode, startTransition, useEffect } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
@@ -14,11 +15,9 @@ function PosthogInit() {
useEffect(() => {
if (postHogConfig) {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
});
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
});
}
}, []);
+1 -9
View File
@@ -17,7 +17,6 @@ import {
Scripts,
ScrollRestoration,
useLoaderData,
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
@@ -111,13 +110,6 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
const [theme] = useTheme();
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
// <style> block from `RecipientBranding` applies to BOTH the main tree and
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
// route tree, attached directly to document.body).
const matches = useMatches();
const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+'));
return (
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
<head>
@@ -145,7 +137,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
<script nonce={nonce(cspNonce)}>0</script>
</head>
<body className={isRecipientRoute ? 'documenso-branded' : undefined}>
<body>
{/* Global license banner currently disabled. Need to wait until after a few releases. */}
{/* {licenseStatus === '?' && (
<div className="bg-destructive text-destructive-foreground">
@@ -95,7 +95,7 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
}}
primaryButton={
<Button asChild>
<Link to="/">
<Link prefetch="intent" to="/">
<Trans>Go home</Trans>
</Link>
</Button>
@@ -64,7 +64,7 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/stats') && 'bg-secondary')}
asChild
>
<Link to="/admin/stats">
<Link prefetch="intent" to="/admin/stats">
<BarChart3 className="mr-2 h-5 w-5" />
<Trans>Stats</Trans>
</Link>
@@ -75,7 +75,7 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/organisations') && 'bg-secondary')}
asChild
>
<Link to="/admin/organisations">
<Link prefetch="intent" to="/admin/organisations">
<Building2Icon className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Link>
@@ -86,7 +86,7 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/claims') && 'bg-secondary')}
asChild
>
<Link to="/admin/claims">
<Link prefetch="intent" to="/admin/claims">
<Wallet2 className="mr-2 h-5 w-5" />
<Trans>Claims</Trans>
</Link>
@@ -97,7 +97,7 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/users') && 'bg-secondary')}
asChild
>
<Link to="/admin/users">
<Link prefetch="intent" to="/admin/users">
<Users className="mr-2 h-5 w-5" />
<Trans>Users</Trans>
</Link>
@@ -108,7 +108,7 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/documents') && 'bg-secondary')}
asChild
>
<Link to="/admin/documents">
<Link prefetch="intent" to="/admin/documents">
<FileStack className="mr-2 h-5 w-5" />
<Trans>Documents</Trans>
</Link>
@@ -122,7 +122,7 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
)}
asChild
>
<Link to="/admin/unsealed-documents">
<Link prefetch="intent" to="/admin/unsealed-documents">
<AlertTriangleIcon className="mr-2 h-5 w-5" />
<Trans>Unsealed Documents</Trans>
</Link>
@@ -133,7 +133,7 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/email-domains') && 'bg-secondary')}
asChild
>
<Link to="/admin/email-domains">
<Link prefetch="intent" to="/admin/email-domains">
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Domains</Trans>
</Link>
@@ -147,7 +147,7 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
)}
asChild
>
<Link to="/admin/organisation-insights">
<Link prefetch="intent" to="/admin/organisation-insights">
<Trophy className="mr-2 h-5 w-5" />
<Trans>Organisation Insights</Trans>
</Link>
@@ -158,7 +158,7 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
className={cn('justify-start md:w-full', pathname?.startsWith('/admin/site-settings') && 'bg-secondary')}
asChild
>
<Link to="/admin/site-settings">
<Link prefetch="intent" to="/admin/site-settings">
<Settings className="mr-2 h-5 w-5" />
<Trans>Site Settings</Trans>
</Link>
@@ -154,7 +154,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
</TooltipProvider>
<Button variant="outline" asChild>
<Link to={`/admin/users/${envelope.userId}`}>
<Link prefetch="intent" to={`/admin/users/${envelope.userId}`}>
<Trans>Go to owner</Trans>
</Link>
</Button>
@@ -61,6 +61,7 @@ export default function AdminDocumentsPage() {
cell: ({ row }) => {
return (
<Link
prefetch="intent"
to={`/admin/documents/${row.original.envelopeId}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
@@ -85,7 +86,7 @@ export default function AdminDocumentsPage() {
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link to={`/admin/users/${row.original.user.id}`}>
<Link prefetch="intent" to={`/admin/users/${row.original.user.id}`}>
<Avatar className="h-12 w-12 border-2 border-white border-solid dark:border-border">
<AvatarFallback className="text-muted-foreground text-xs">{avatarFallbackText}</AvatarFallback>
</Avatar>
@@ -58,6 +58,7 @@ export default function AdminEmailDomainsPage() {
accessorKey: 'domain',
cell: ({ row }) => (
<Link
prefetch="intent"
to={`/admin/email-domains/${row.original.id}`}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[15rem]"
>
@@ -69,7 +70,11 @@ export default function AdminEmailDomainsPage() {
header: _(msg`Organisation`),
accessorKey: 'organisation',
cell: ({ row }) => (
<Link to={`/admin/organisations/${row.original.organisation.id}`} className="hover:underline">
<Link
prefetch="intent"
to={`/admin/organisations/${row.original.organisation.id}`}
className="hover:underline"
>
{row.original.organisation.name}
</Link>
),
@@ -112,7 +117,7 @@ export default function AdminEmailDomainsPage() {
header: _(msg`Actions`),
cell: ({ row }) => (
<Button asChild variant="outline" size="sm">
<Link to={`/admin/email-domains/${row.original.id}`}>
<Link prefetch="intent" to={`/admin/email-domains/${row.original.id}`}>
<Trans>View</Trans>
</Link>
</Button>
@@ -48,7 +48,7 @@ export default function OrganisationInsights({ loaderData }: Route.ComponentProp
<div className="flex items-center justify-between">
<h2 className="font-semibold text-4xl">{organisationName}</h2>
<Button variant="outline" asChild>
<Link to={`/admin/organisations/${organisationId}`}>
<Link prefetch="intent" to={`/admin/organisations/${organisationId}`}>
<Trans>Manage organisation</Trans>
</Link>
</Button>
@@ -132,7 +132,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
header: t`Member`,
cell: ({ row }) => (
<div className="space-y-1">
<Link className="font-medium hover:underline" to={`/admin/users/${row.original.user.id}`}>
<Link prefetch="intent" className="font-medium hover:underline" to={`/admin/users/${row.original.user.id}`}>
{row.original.user.name ?? row.original.user.email}
</Link>
{row.original.user.name && (
@@ -236,7 +236,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
}}
primaryButton={
<Button asChild>
<Link to={`/admin/organisations`}>
<Link prefetch="intent" to={`/admin/organisations`}>
<Trans>Go back</Trans>
</Link>
</Button>
@@ -250,7 +250,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div>
<SettingsHeader title={t`Manage organisation`} subtitle={t`Manage the ${organisation.name} organisation`}>
<Button variant="outline" asChild>
<Link to={`/admin/organisation-insights/${organisationId}`}>
<Link prefetch="intent" to={`/admin/organisation-insights/${organisationId}`}>
<Trans>View insights</Trans>
</Link>
</Button>
@@ -27,7 +27,7 @@ import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/site-settings';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
@@ -45,8 +45,6 @@ export async function loader() {
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
@@ -144,7 +142,7 @@ export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
<ColorPicker {...field} />
</div>
</FormControl>
@@ -164,7 +162,7 @@ export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
<ColorPicker {...field} />
</div>
</FormControl>
@@ -57,7 +57,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
}}
primaryButton={
<Button asChild>
<Link to={`/admin/users`}>
<Link prefetch="intent" to={`/admin/users`}>
<Trans>Go back</Trans>
</Link>
</Button>
@@ -74,7 +74,7 @@ export default function DashboardPage() {
</div>
<Button asChild className="mt-4" variant="outline">
<Link to="/settings/organisations?action=add-organisation">
<Link prefetch="intent" to="/settings/organisations?action=add-organisation">
<Trans>Create organisation</Trans>
</Link>
</Button>
@@ -102,7 +102,7 @@ export default function DashboardPage() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{organisations.map((org) => (
<div key={org.id} className="group relative">
<Link to={`/o/${org.url}`}>
<Link prefetch="intent" to={`/o/${org.url}`}>
<Card className="h-full border pr-6 transition-all hover:bg-muted/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
@@ -143,7 +143,7 @@ export default function DashboardPage() {
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<Link to={`/o/${org.url}/settings`}>
<Link prefetch="intent" to={`/o/${org.url}/settings`}>
<SettingsIcon className="h-4 w-4" />
</Link>
</div>
@@ -176,7 +176,7 @@ export default function DashboardPage() {
<div className="flex gap-4">
{teams.map((team) => (
<div key={team.id} className="group relative">
<Link to={`/t/${team.url}`}>
<Link prefetch="intent" to={`/t/${team.url}`}>
<Card className="w-[350px] shrink-0 border transition-all hover:bg-muted/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
@@ -212,7 +212,7 @@ export default function DashboardPage() {
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<Link to={`/t/${team.url}/settings`}>
<Link prefetch="intent" to={`/t/${team.url}/settings`}>
<SettingsIcon className="h-4 w-4" />
</Link>
</div>
@@ -115,7 +115,7 @@ export default function OrganisationSettingsTeamsPage() {
</div>
<Button asChild>
<Link to={`/o/${organisation.url}/settings`}>
<Link prefetch="intent" to={`/o/${organisation.url}/settings`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
@@ -123,7 +123,7 @@ export default function OrganisationSettingsTeamsPage() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{organisation.teams.map((team) => (
<Link to={`/t/${team.url}`} key={team.id}>
<Link prefetch="intent" to={`/t/${team.url}`} key={team.id}>
<Card className="h-full border border-border transition-all hover:bg-muted/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
@@ -178,19 +178,19 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem asChild>
<Link to={`/t/${team.url}`}>
<Link prefetch="intent" to={`/t/${team.url}`}>
<ArrowRight className="mr-2 h-4 w-4" />
<Trans>Go to team</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/t/${team.url}/settings`}>
<Link prefetch="intent" to={`/t/${team.url}/settings`}>
<SettingsIcon className="mr-2 h-4 w-4" />
<Trans>Settings</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/t/${team.url}/settings/members`}>
<Link prefetch="intent" to={`/t/${team.url}/settings/members`}>
<UsersIcon className="mr-2 h-4 w-4" />
<Trans>Members</Trans>
</Link>
@@ -122,7 +122,7 @@ export default function SettingsLayout() {
}}
primaryButton={
<Button asChild>
<Link to={`/o/${organisation.url}`}>
<Link prefetch="intent" to={`/o/${organisation.url}`}>
<Trans>Go Back</Trans>
</Link>
</Button>
@@ -3,15 +3,13 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg, plural } from '@lingui/core/macro';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import {
@@ -37,13 +35,7 @@ export default function OrganisationSettingsBrandingPage() {
const isPersonalLayoutMode = isPersonalLayout(organisations);
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
const {
data: organisationWithSettings,
isLoading: isLoadingOrganisation,
refetch: refetchOrganisation,
} = trpc.organisation.get.useQuery({
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
@@ -51,7 +43,7 @@ export default function OrganisationSettingsBrandingPage() {
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
let uploadedBrandingLogo: string | undefined;
@@ -64,40 +56,20 @@ export default function OrganisationSettingsBrandingPage() {
uploadedBrandingLogo = '';
}
const result = await updateOrganisationSettings({
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
brandingCss,
},
});
// Refetch so the form re-syncs with the sanitised CSS that was
// actually persisted (sanitiser may have dropped rules).
await refetchOrganisation();
const warnings = result?.cssWarnings ?? [];
setCssWarnings(warnings);
if (warnings.length > 0) {
toast({
title: t`Branding preferences updated with warnings`,
description: plural(warnings.length, {
one: '# CSS rule was dropped during sanitisation.',
other: '# CSS rules were dropped during sanitisation.',
}),
duration: 8000,
});
} else {
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
}
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong`,
@@ -131,36 +103,9 @@ export default function OrganisationSettingsBrandingPage() {
<section>
<BrandingPreferencesForm
context="Organisation"
hasAdvancedBranding={
organisationWithSettings.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED()
}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</section>
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
@@ -118,7 +118,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
}}
primaryButton={
<Button asChild>
<Link to={`/o/${organisation.url}/settings/email-domains`}>
<Link prefetch="intent" to={`/o/${organisation.url}/settings/email-domains`}>
<Trans>Go back</Trans>
</Link>
</Button>
@@ -83,7 +83,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}}
primaryButton={
<Button asChild>
<Link to={`/o/${organisation.url}/settings/groups`}>
<Link prefetch="intent" to={`/o/${organisation.url}/settings/groups`}>
<Trans>Go back</Trans>
</Link>
</Button>
@@ -126,7 +126,7 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/passkeys">
<Link prefetch="intent" to="/settings/security/passkeys">
<Trans>Manage passkeys</Trans>
</Link>
</Button>
@@ -144,7 +144,7 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/activity">
<Link prefetch="intent" to="/settings/security/activity">
<Trans>View activity</Trans>
</Link>
</Button>
@@ -162,7 +162,7 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/sessions">
<Link prefetch="intent" to="/settings/security/sessions">
<Trans>Manage sessions</Trans>
</Link>
</Button>
@@ -180,7 +180,7 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/linked-accounts">
<Link prefetch="intent" to="/settings/security/linked-accounts">
<Trans>Manage linked accounts</Trans>
</Link>
</Button>
@@ -57,7 +57,7 @@ export default function Layout() {
}}
primaryButton={
<Button asChild>
<Link to="/settings/teams">
<Link prefetch="intent" to="/settings/teams">
<Trans>View teams</Trans>
</Link>
</Button>
@@ -93,7 +93,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/documents`}>
<Link prefetch="intent" to={`/t/${team.url}/documents`}>
<Trans>Go back</Trans>
</Link>
</Button>
@@ -112,7 +112,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<DocumentRecipientLinkCopyDialog recipients={envelope.recipients} />
)}
<Link to={documentRootPath} className="flex items-center text-documenso-700 hover:opacity-80">
<Link prefetch="intent" to={documentRootPath} className="flex items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
@@ -83,7 +83,7 @@ export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
secondaryButton={null}
primaryButton={
<Button asChild className="w-32">
<Link to={`/t/${params.teamUrl}/documents`}>
<Link prefetch="intent" to={`/t/${params.teamUrl}/documents`}>
<ChevronLeftIcon className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
@@ -86,7 +86,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/documents`}>
<Link prefetch="intent" to={`/t/${team.url}/documents`}>
<Trans>Go home</Trans>
</Link>
</Button>
@@ -107,7 +107,7 @@ export default function TeamsSettingsLayout() {
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}`}>
<Link prefetch="intent" to={`/t/${team.url}`}>
<Trans>Go Back</Trans>
</Link>
</Button>
@@ -1,14 +1,9 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useState } from 'react';
import {
BrandingPreferencesForm,
@@ -16,32 +11,27 @@ import {
} from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Branding Preferences`);
}
export default function TeamsSettingsPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { toast } = useToast();
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
const {
data: teamWithSettings,
isLoading: isLoadingTeam,
refetch: refetchTeam,
} = trpc.team.get.useQuery({
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
teamReference: team.id,
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const canCustomBranding =
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
let uploadedBrandingLogo: string | undefined;
@@ -54,40 +44,20 @@ export default function TeamsSettingsPage() {
uploadedBrandingLogo = '';
}
const result = await updateTeamSettings({
await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors,
brandingCss,
},
});
// Refetch so the form re-syncs with the sanitised CSS that was
// actually persisted (sanitiser may have dropped rules).
await refetchTeam();
const warnings = result?.cssWarnings ?? [];
setCssWarnings(warnings);
if (warnings.length > 0) {
toast({
title: t`Branding preferences updated with warnings`,
description: plural(warnings.length, {
one: '# CSS rule was dropped during sanitisation.',
other: '# CSS rules were dropped during sanitisation.',
}),
duration: 8000,
});
} else {
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
}
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong`,
@@ -115,35 +85,10 @@ export default function TeamsSettingsPage() {
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</section>
</div>
);
@@ -203,7 +203,7 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/settings/webhooks`}>
<Link prefetch="intent" to={`/t/${team.url}/settings/webhooks`}>
<Trans>Go back</Trans>
</Link>
</Button>
@@ -48,7 +48,7 @@ export default function WebhookPage() {
{
header: t`Webhook`,
cell: ({ row }) => (
<Link to={`/t/${team.url}/settings/webhooks/${row.original.id}`}>
<Link prefetch="intent" to={`/t/${team.url}/settings/webhooks/${row.original.id}`}>
<p className="text-muted-foreground text-xs">{row.original.id}</p>
<p className="max-w-sm truncate font-semibold text-foreground text-xs" title={row.original.webhookUrl}>
{row.original.webhookUrl}
@@ -159,7 +159,7 @@ const WebhookTableActionDropdown = ({ webhook }: { webhook: Webhook }) => {
</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to={`/t/${team.url}/settings/webhooks/${webhook.id}`}>
<Link prefetch="intent" to={`/t/${team.url}/settings/webhooks/${webhook.id}`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
<Trans>Logs</Trans>
</Link>
@@ -77,7 +77,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/templates`}>
<Link prefetch="intent" to={`/t/${team.url}/templates`}>
<Trans>Go back</Trans>
</Link>
</Button>
@@ -118,7 +118,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<div className="flex flex-row justify-between">
<Link to={templateRootPath} className="flex items-center text-documenso-700 hover:opacity-80">
<Link prefetch="intent" to={templateRootPath} className="flex items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Templates</Trans>
</Link>
@@ -139,7 +139,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
/>
<Button asChild size="sm">
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
<Link prefetch="intent" to={`${templateRootPath}/${envelope.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
<Trans>Edit Template</Trans>
</Link>
@@ -101,7 +101,7 @@ export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
secondaryButton={null}
primaryButton={
<Button asChild className="w-32">
<Link to={`/t/${params.teamUrl}/templates`}>
<Link prefetch="intent" to={`/t/${params.teamUrl}/templates`}>
<ChevronLeftIcon className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
@@ -1,21 +1,14 @@
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import type { Route } from './+types/_layout';
export function meta() {
return [
{ title: i18n._(msg`Sign Document - Documenso`) },
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet, noimageindex' },
];
}
import type { Route } from './+types/_layout';
/**
* A layout to handle scenarios where the user is a recipient of a given resource
@@ -2,7 +2,6 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
@@ -21,8 +20,6 @@ import { DocumentSigningAuthProvider } from '~/components/general/document-signi
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
@@ -128,7 +125,6 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
},
select: {
internalVersion: true,
teamId: true,
},
});
@@ -136,17 +132,12 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({
teamId: directEnvelope.teamId,
});
if (directEnvelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs);
return superLoaderJson({
version: 2,
payload: payloadV2,
branding,
} as const);
}
@@ -155,20 +146,17 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
return superLoaderJson({
version: 1,
payload: payloadV1,
branding,
} as const);
}
export default function DirectTemplatePage() {
const data = useSuperLoaderData<typeof loader>();
const cspNonce = useCspNonce();
return (
<>
<RecipientBranding branding={data.branding} cspNonce={cspNonce} />
{data.version === 2 ? <DirectSigningPageV2 data={data.payload} /> : <DirectSigningPageV1 data={data.payload} />}
</>
);
if (data.version === 2) {
return <DirectSigningPageV2 data={data.payload} />;
}
return <DirectSigningPageV1 data={data.payload} />;
}
const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
@@ -3,7 +3,6 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
@@ -36,8 +35,6 @@ import { DocumentSigningPageViewV1 } from '~/components/general/document-signing
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
@@ -275,7 +272,6 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
envelope: {
select: {
internalVersion: true,
teamId: true,
},
},
},
@@ -285,17 +281,12 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({
teamId: foundRecipient.envelope.teamId,
});
if (foundRecipient.envelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs);
return superLoaderJson({
version: 2,
payload: payloadV2,
branding,
} as const);
}
@@ -304,20 +295,17 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
return superLoaderJson({
version: 1,
payload: payloadV1,
branding,
} as const);
}
export default function SigningPage() {
const data = useSuperLoaderData<typeof loader>();
const cspNonce = useCspNonce();
return (
<>
<RecipientBranding branding={data.branding} cspNonce={cspNonce} />
{data.version === 2 ? <SigningPageV2 data={data.payload} /> : <SigningPageV1 data={data.payload} />}
</>
);
if (data.version === 2) {
return <SigningPageV2 data={data.payload} />;
}
return <SigningPageV1 data={data.payload} />;
}
const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
@@ -1,8 +1,6 @@
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
@@ -10,6 +8,7 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env';
import { trpc } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@@ -26,8 +25,6 @@ import { match } from 'ts-pattern';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { ClaimAccount } from '~/components/general/claim-account';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/complete';
@@ -49,8 +46,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
@@ -71,7 +66,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
return {
isDocumentAccessValid: false,
recipientEmail: recipient.email,
branding,
} as const;
}
@@ -83,7 +77,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const recipientName =
recipient.name || fields.find((field) => field.type === FieldType.NAME)?.customText || recipient.email;
const canSignUp = !isExistingUser && isSignupEnabledForProvider('email');
const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true';
const canRedirectToFolder = user && document.userId === user.id && document.folderId && document.team?.url;
@@ -98,7 +92,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
document,
recipient,
returnToHomePath,
branding,
};
}
@@ -107,7 +100,6 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const cspNonce = useCspNonce();
const {
isDocumentAccessValid,
@@ -118,7 +110,6 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
recipient,
recipientEmail,
returnToHomePath,
branding,
} = loaderData;
// Poll signing status every few seconds
@@ -140,163 +131,154 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
const signingStatus = signingStatusData?.status ?? 'PENDING';
if (!isDocumentAccessValid) {
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<DocumentSigningAuthPageView email={recipientEmail} />
</>
);
return <DocumentSigningAuthPageView email={recipientEmail} />;
}
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div
className={cn('-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28', {
'pt-0 lg:pt-0 xl:pt-0': canSignUp,
})}
>
<div
className={cn(
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)}
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
canSignUp,
})}
>
<div
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
canSignUp,
className={cn('flex flex-col items-center', {
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
})}
>
<div
className={cn('flex flex-col items-center', {
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
})}
>
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{document.title}
</span>
</Badge>
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{document.title}
</span>
</Badge>
{/* Card with recipient */}
<SigningCard3D
name={recipientName}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
{/* Card with recipient */}
<SigningCard3D
name={recipientName}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
/>
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
{recipient.role === RecipientRole.SIGNER && <Trans>Document Signed</Trans>}
{recipient.role === RecipientRole.VIEWER && <Trans>Document Viewed</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
</h2>
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<div className="mt-4 flex items-center text-center text-documenso-700">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Everyone has signed</Trans>
</span>
</div>
))
.with({ status: 'PROCESSING' }, () => (
<div className="mt-4 flex items-center text-center text-orange-600">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span className="text-sm">
<Trans>Processing document</Trans>
</span>
</div>
))
.with({ deletedAt: null }, () => (
<div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Waiting for others to sign</Trans>
</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Document no longer available to sign</Trans>
</span>
</div>
))}
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Everyone has signed! You will receive an email copy of the signed document.</Trans>
</p>
))
.with({ status: 'PROCESSING' }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>
All recipients have signed. The document is being processed and you will receive an email copy
shortly.
</Trans>
</p>
))
.with({ deletedAt: null }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>You will receive an email copy of the signed document once everyone has signed.</Trans>
</p>
))
.otherwise(() => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>
This document has been cancelled by the owner and is no longer available for others to sign.
</Trans>
</p>
))}
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
<DocumentShareButton
documentId={document.id}
token={recipient.token}
className="w-full max-w-none md:flex-1"
/>
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
{recipient.role === RecipientRole.SIGNER && <Trans>Document Signed</Trans>}
{recipient.role === RecipientRole.VIEWER && <Trans>Document Viewed</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
</h2>
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<div className="mt-4 flex items-center text-center text-documenso-700">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Everyone has signed</Trans>
</span>
</div>
))
.with({ status: 'PROCESSING' }, () => (
<div className="mt-4 flex items-center text-center text-orange-600">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span className="text-sm">
<Trans>Processing document</Trans>
</span>
</div>
))
.with({ deletedAt: null }, () => (
<div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Waiting for others to sign</Trans>
</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Document no longer available to sign</Trans>
</span>
</div>
))}
{match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: 'COMPLETED' }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Everyone has signed! You will receive an email copy of the signed document.</Trans>
</p>
))
.with({ status: 'PROCESSING' }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>
All recipients have signed. The document is being processed and you will receive an email copy
shortly.
</Trans>
</p>
))
.with({ deletedAt: null }, () => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>You will receive an email copy of the signed document once everyone has signed.</Trans>
</p>
))
.otherwise(() => (
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>
This document has been cancelled by the owner and is no longer available for others to sign.
</Trans>
</p>
))}
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
<DocumentShareButton
documentId={document.id}
token={recipient.token}
className="w-full max-w-none md:flex-1"
{isDocumentCompleted(document) && (
<EnvelopeDownloadDialog
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeItems={document.envelopeItems}
token={recipient?.token}
trigger={
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
)}
{isDocumentCompleted(document) && (
<EnvelopeDownloadDialog
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeItems={document.envelopeItems}
token={recipient?.token}
trigger={
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
)}
{user && (
<Button asChild>
<Link to={returnToHomePath}>
<Trans>Go Back Home</Trans>
</Link>
</Button>
)}
</div>
</div>
<div className="flex flex-col items-center">
{canSignUp && (
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
<h2 className="mt-8 text-center font-semibold text-xl md:mt-0">
<Trans>Need to sign documents?</Trans>
</h2>
<p className="mt-4 max-w-[55ch] text-center text-muted-foreground/60 leading-normal">
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
{user && (
<Button asChild>
<Link to={returnToHomePath}>
<Trans>Go Back Home</Trans>
</Link>
</Button>
)}
</div>
</div>
<div className="flex flex-col items-center">
{canSignUp && (
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
<h2 className="mt-8 text-center font-semibold text-xl md:mt-0">
<Trans>Need to sign documents?</Trans>
</h2>
<p className="mt-4 max-w-[55ch] text-center text-muted-foreground/60 leading-normal">
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
</div>
</div>
</>
</div>
);
}
@@ -1,6 +1,5 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -11,8 +10,6 @@ import { TimerOffIcon } from 'lucide-react';
import { Link } from 'react-router';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import { truncateTitle } from '~/utils/truncate-title';
import type { Route } from './+types/expired';
@@ -35,8 +32,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
const title = document.title;
const recipient = await getRecipientByToken({ token }).catch(() => null);
@@ -59,66 +54,55 @@ export async function loader({ params, request }: Route.LoaderArgs) {
isDocumentAccessValid: true,
recipientEmail,
title,
branding,
};
}
return {
isDocumentAccessValid: false,
recipientEmail,
branding,
};
}
export default function ExpiredSigningPage({ loaderData }: Route.ComponentProps) {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const cspNonce = useCspNonce();
const { isDocumentAccessValid, recipientEmail, title, branding } = loaderData;
const { isDocumentAccessValid, recipientEmail, title } = loaderData;
if (!isDocumentAccessValid) {
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<DocumentSigningAuthPageView email={recipientEmail} />
</>
);
return <DocumentSigningAuthPageView email={recipientEmail} />;
}
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" title={title} className="mb-6 rounded-xl border bg-transparent">
{truncateTitle(title ?? '')}
</Badge>
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" title={title} className="mb-6 rounded-xl border bg-transparent">
{truncateTitle(title ?? '')}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<TimerOffIcon className="h-10 w-10 text-orange-500" />
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<TimerOffIcon className="h-10 w-10 text-orange-500" />
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing Deadline Expired</Trans>
</h2>
</div>
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The signing deadline for this document has passed. Please contact the document owner if you need a new
copy to sign.
</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing Deadline Expired</Trans>
</h2>
</div>
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The signing deadline for this document has passed. Please contact the document owner if you need a new copy
to sign.
</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
</>
</div>
);
}
@@ -1,6 +1,5 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
@@ -13,8 +12,6 @@ import { XCircle } from 'lucide-react';
import { Link } from 'react-router';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import { truncateTitle } from '~/utils/truncate-title';
import type { Route } from './+types/rejected';
@@ -37,8 +34,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
const truncatedTitle = truncateTitle(document.title);
const [fields, recipient] = await Promise.all([
@@ -65,7 +60,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
isDocumentAccessValid: true,
recipientReference,
truncatedTitle,
branding,
};
}
@@ -73,67 +67,57 @@ export async function loader({ params, request }: Route.LoaderArgs) {
return {
isDocumentAccessValid: false,
recipientReference,
branding,
};
}
export default function RejectedSigningPage({ loaderData }: Route.ComponentProps) {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const cspNonce = useCspNonce();
const { isDocumentAccessValid, recipientReference, truncatedTitle, branding } = loaderData;
const { isDocumentAccessValid, recipientReference, truncatedTitle } = loaderData;
if (!isDocumentAccessValid) {
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<DocumentSigningAuthPageView email={recipientReference} />
</>
);
return <DocumentSigningAuthPageView email={recipientReference} />;
}
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="h-10 w-10 text-destructive" />
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="h-10 w-10 text-destructive" />
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="mt-4 flex items-center text-center text-destructive text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further instructions if
necessary.
</Trans>
</p>
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="mt-4 flex items-center text-center text-destructive text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further instructions if
necessary.
</Trans>
</p>
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
</>
</div>
);
}
@@ -1,5 +1,4 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -11,9 +10,6 @@ import type { Team } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { Link, redirect } from 'react-router';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/waiting';
export async function loader({ params, request }: Route.LoaderArgs) {
@@ -65,55 +61,48 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const documentPathForEditing = isOwnerOrTeamMember && team ? formatDocumentsPath(team.url) + '/' + document.id : null;
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
return {
documentPathForEditing,
branding,
};
}
export default function WaitingForTurnToSignPage({ loaderData }: Route.ComponentProps) {
const { documentPathForEditing, branding } = loaderData;
const cspNonce = useCspNonce();
const { documentPathForEditing } = loaderData;
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md text-center">
<h2 className="font-bold text-3xl tracking-tigh">
<Trans>Waiting for Your Turn</Trans>
</h2>
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md text-center">
<h2 className="font-bold text-3xl tracking-tigh">
<Trans>Waiting for Your Turn</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
It's currently not your turn to sign. You will receive an email with instructions once it's your turn to
sign the document.
</Trans>
</p>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
It's currently not your turn to sign. You will receive an email with instructions once it's your turn to
sign the document.
</Trans>
</p>
<p className="mt-4 text-muted-foreground text-sm">
<Trans>Please check your email for updates.</Trans>
</p>
<p className="mt-4 text-muted-foreground text-sm">
<Trans>Please check your email for updates.</Trans>
</p>
<div className="mt-4">
{documentPathForEditing ? (
<Button variant="link" asChild>
<Link to={documentPathForEditing}>
<Trans>Were you trying to edit this document instead?</Trans>
</Link>
</Button>
) : (
<Button variant="link" asChild>
<Link to="/">
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
<div className="mt-4">
{documentPathForEditing ? (
<Button variant="link" asChild>
<Link to={documentPathForEditing}>
<Trans>Were you trying to edit this document instead?</Trans>
</Link>
</Button>
) : (
<Button variant="link" asChild>
<Link to="/">
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
</div>
</>
</div>
);
}
@@ -3,9 +3,9 @@ import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
isSignupEnabledForProvider,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { msg } from '@lingui/core/macro';
@@ -32,11 +32,6 @@ export async function loader({ request }: Route.LoaderArgs) {
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
const isSignupEnabled =
isSignupEnabledForProvider('email') ||
(IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) ||
(IS_MICROSOFT_SSO_ENABLED && isSignupEnabledForProvider('microsoft')) ||
(IS_OIDC_SSO_ENABLED && isSignupEnabledForProvider('oidc'));
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
@@ -50,15 +45,13 @@ export async function loader({ request }: Route.LoaderArgs) {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isSignupEnabled,
oidcProviderLabel,
returnTo,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, oidcProviderLabel, returnTo } =
loaderData;
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel, returnTo } = loaderData;
const { _ } = useLingui();
@@ -102,7 +95,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
returnTo={returnTo}
/>
{!isEmbeddedRedirect && isSignupEnabled && (
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
<p className="mt-6 text-center text-muted-foreground text-sm">
<Trans>
Don't have an account?{' '}
@@ -1,9 +1,5 @@
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { IS_GOOGLE_SSO_ENABLED, IS_MICROSOFT_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { msg } from '@lingui/core/macro';
import { redirect } from 'react-router';
@@ -18,15 +14,14 @@ export function meta() {
}
export function loader({ request }: Route.LoaderArgs) {
const isEmailPasswordSignupEnabled = isSignupEnabledForProvider('email');
const isGoogleSignupEnabled = IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google');
const isMicrosoftSignupEnabled = IS_MICROSOFT_SSO_ENABLED && isSignupEnabledForProvider('microsoft');
const isOidcSignupEnabled = IS_OIDC_SSO_ENABLED && isSignupEnabledForProvider('oidc');
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
const isAnySignupEnabled =
isEmailPasswordSignupEnabled || isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
if (!isAnySignupEnabled) {
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
throw redirect('/signin');
}
@@ -35,30 +30,22 @@ export function loader({ request }: Route.LoaderArgs) {
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
return {
isEmailPasswordSignupEnabled,
isGoogleSignupEnabled,
isMicrosoftSignupEnabled,
isOidcSignupEnabled,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
returnTo,
};
}
export default function SignUp({ loaderData }: Route.ComponentProps) {
const {
isEmailPasswordSignupEnabled,
isGoogleSignupEnabled,
isMicrosoftSignupEnabled,
isOidcSignupEnabled,
returnTo,
} = loaderData;
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
return (
<SignUpForm
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
isEmailPasswordSignupEnabled={isEmailPasswordSignupEnabled}
isGoogleSignupEnabled={isGoogleSignupEnabled}
isMicrosoftSignupEnabled={isMicrosoftSignupEnabled}
isOidcSignupEnabled={isOidcSignupEnabled}
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
returnTo={returnTo}
/>
);
+5 -32
View File
@@ -1,4 +1,4 @@
import { CSS_LENGTH_REGEX, type TCssVarsSchema } from '@documenso/lib/types/css-vars';
import type { TCssVarsSchema } from '@documenso/lib/types/css-vars';
import { colord } from 'colord';
import { toKebabCase } from 'remeda';
@@ -12,50 +12,23 @@ export const toNativeCssVars = (vars: TCssVarsSchema) => {
const color = colord(value);
const { h, s, l } = color.toHsl();
// Tailwind's theme.css consumes these via `hsl(var(--token))`. CSS
// Color 4 space-separated `hsl()` requires `%` on saturation and
// lightness — without it, the function is invalid and the property
// falls back to its initial value (which is why bare numeric output
// here used to silently break customer colours).
cssVars[`--${toKebabCase(key)}`] = `${h} ${s}% ${l}%`;
cssVars[`--${toKebabCase(key)}`] = `${h} ${s} ${l}`;
}
}
// Defence in depth: radius is interpolated raw into the rendered <style>
// block, so anything outside the length pattern is a CSS-injection vector.
// The Zod schema rejects bad values at the API boundary; this re-check
// protects against schema drift and any path that bypasses validation.
if (radius && CSS_LENGTH_REGEX.test(radius)) {
cssVars[`--radius`] = radius;
if (radius) {
cssVars[`--radius`] = `${radius}`;
}
return cssVars;
};
/**
* Pure-string sibling of `toNativeCssVars` — returns the same set of CSS custom
* property declarations as a single string suitable for SSR inlining inside a
* rule block. Does not touch the DOM.
*
* Example: { background: '#111', radius: '0.5rem' }
* -> "--background: 0 0% 6.7%; --radius: 0.5rem;"
*
* Saturation and lightness include the `%` suffix that
* `hsl(var(--token))` requires under CSS Color 4 space-separated syntax.
*/
export const toNativeCssVarsString = (vars: TCssVarsSchema): string => {
const map = toNativeCssVars(vars);
return Object.entries(map)
.map(([k, v]) => `${k}: ${v};`)
.join(' ');
};
export const injectCss = (options: { css?: string; cssVars?: TCssVarsSchema }) => {
const { css, cssVars } = options;
if (css) {
const style = document.createElement('style');
style.textContent = css;
style.innerHTML = css;
document.head.appendChild(style);
}
-17
View File
@@ -1,5 +1,3 @@
import { useRouteLoaderData } from 'react-router';
/**
* Returns the supplied CSP nonce only when rendering on the server.
*
@@ -21,18 +19,3 @@ import { useRouteLoaderData } from 'react-router';
* scripts inherit trust via `'strict-dynamic'`.
*/
export const nonce = (value: string | undefined): string | undefined => (typeof window === 'undefined' ? value : '');
/**
* Reads the per-request CSP nonce surfaced by the root loader. Use this
* inside any non-root route component that needs to render a `<style>`,
* `<script>`, or other element that the CSP gates by nonce.
*
* Centralised here so the cast is in one place — if the root loader's
* `nonce` field is ever renamed/removed, only this function needs updating
* (and TypeScript will catch it at the cast site).
*/
export const useCspNonce = (): string | undefined => {
const rootData = useRouteLoaderData('root') as { nonce?: string } | undefined;
return rootData?.nonce;
};
-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Disallow: /sign/
Disallow: /d/
Disallow: /embed/
+6 -28
View File
@@ -1,4 +1,3 @@
import { getFileExtensionForMimeType } from '@documenso/lib/constants/upload';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
@@ -25,8 +24,6 @@ type DocumentDataInput = {
type: DocumentDataType;
data: string;
initialData: string;
originalData?: string | null;
originalMimeType?: string | null;
};
type EnvelopeForPendingDownload = {
@@ -87,19 +84,7 @@ const handleStaticFileRequest = async ({
isDownload,
context: c,
}: StaticFileRequestOptions) => {
const shouldServeOriginalSourceFile =
version === 'original' &&
documentData.originalData &&
documentData.originalMimeType &&
documentData.originalMimeType !== 'application/pdf';
const documentDataToUse = shouldServeOriginalSourceFile
? documentData.originalData!
: version === 'signed'
? documentData.data
: documentData.initialData;
const contentType = shouldServeOriginalSourceFile ? documentData.originalMimeType! : 'application/pdf';
const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData;
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
@@ -120,7 +105,7 @@ const handleStaticFileRequest = async ({
return c.json({ error: 'File not found' }, 404);
}
c.header('Content-Type', contentType);
c.header('Content-Type', 'application/pdf');
c.header('ETag', etag);
if (!isDownload) {
@@ -132,17 +117,10 @@ const handleStaticFileRequest = async ({
}
if (isDownload) {
const baseTitle = title.replace(/\.[^/.]+$/, '');
let filename: string;
if (version === 'signed') {
filename = `${baseTitle}_signed.pdf`;
} else if (shouldServeOriginalSourceFile) {
const extension = getFileExtensionForMimeType(documentData.originalMimeType!);
filename = `${baseTitle}${extension}`;
} else {
filename = `${baseTitle}.pdf`;
}
// Generate filename following the pattern from envelope-download-dialog.tsx
const baseTitle = title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
c.header('Content-Disposition', contentDisposition(filename));
+1 -1
View File
@@ -46,7 +46,7 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'File too large' }, 400);
}
const result = await putNormalizedPdfFileServerSide({ file });
const result = await putNormalizedPdfFileServerSide(file);
return c.json(result);
} catch (error) {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

+1 -5
View File
@@ -253,9 +253,5 @@ Here's a markdown table documenting all the provided environment variables:
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Set to `true` to disable all signup methods (incl. organisation OIDC portal). |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Set to `true` to disable email/password signup only. SSO signup is unaffected. |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Set to `true` to block new accounts via Google. Existing Google-linked users can still sign in. |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Set to `true` to block new accounts via Microsoft. Existing linked users can still sign in. |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Set to `true` to block new accounts via OIDC (incl. organisation portal). Existing users unaffected.|
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`). |
-9
View File
@@ -48,15 +48,6 @@ services:
entrypoint: sh
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
gotenberg:
image: gotenberg/gotenberg:8
container_name: gotenberg
ports:
- 3001:3000
command:
- 'gotenberg'
- '--api-timeout=30s'
volumes:
minio:
redis:
-4
View File
@@ -59,10 +59,6 @@ services:
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
- NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP}
- NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP=${NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP}
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP}
- NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP}
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
+14 -15
View File
@@ -349,6 +349,20 @@
"dev": true,
"license": "MIT"
},
"apps/docs/node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"apps/docs/node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
@@ -30670,8 +30684,6 @@
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
"postcss": "^8.5.6",
"postcss-selector-parser": "^7.1.0",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
@@ -30689,19 +30701,6 @@
"vitest": "^4.0.18"
}
},
"packages/lib/node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"packages/prettier-config": {
"name": "@documenso/prettier-config",
"version": "0.0.0",
+3 -5
View File
@@ -814,11 +814,9 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
});
const newDocumentData = await putNormalizedPdfFileServerSide({
file: {
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
},
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
await prisma.envelopeItem.update({
@@ -32,14 +32,10 @@ const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
* Expected HSL values after conversion by `toNativeCssVars`:
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
*
* The `%` on saturation and lightness is required: theme.css consumes these
* via `hsl(var(--token))`, and CSS Color 4 space-separated `hsl()` rejects
* bare numbers there. See `apps/remix/app/utils/css-vars.ts`.
*/
const EXPECTED_CSS_VARS = {
'--background': '0 100% 50%',
'--primary': '120 100% 50%',
'--background': '0 100 50',
'--primary': '120 100 50',
'--radius': '1rem',
};
@@ -68,7 +64,7 @@ const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
/**
* When `--background` is set to `0 100% 50%` (hsl(0, 100%, 50%)) the body background
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
*/
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
@@ -1,9 +1,10 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@prisma/client';
@@ -114,8 +115,8 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(redirectPath, 302);
}
// Check if signups are disabled for this provider.
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
// Check if signups are disabled.
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
@@ -1,5 +1,4 @@
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
@@ -66,14 +65,6 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
// Handle new user.
if (!userToLink) {
if (!isSignupEnabledForProvider('oidc')) {
const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl));
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
return c.redirect(errorUrl.toString(), 302);
}
userToLink = await prisma.user.create({
data: {
email: email,
@@ -1,4 +1,4 @@
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { isEmailDomainAllowedForSignup } 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';
@@ -27,6 +27,7 @@ import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/serv
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt';
@@ -183,7 +184,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (!isSignupEnabledForProvider('email')) {
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
statusCode: 400,
});
+4 -37
View File
@@ -13,7 +13,7 @@ type DownloadPDFProps = {
/**
* Specifies which version of the document to download.
* 'signed': Downloads the signed version (default).
* 'original': Downloads the original version (may be DOCX, PNG, JPEG if converted).
* 'original': Downloads the original version.
* 'pending': Downloads the original document with currently-inserted fields burned in.
* Only valid while the envelope is in PENDING status. Not supported via
* recipient token.
@@ -21,29 +21,6 @@ type DownloadPDFProps = {
version?: DocumentVersion;
};
const getFilenameFromContentDisposition = (header: string | null): string | null => {
if (!header) {
return null;
}
const filenameStarMatch = header.match(/filename\*=(?:UTF-8''|utf-8'')([^;]+)/i);
if (filenameStarMatch) {
return decodeURIComponent(filenameStarMatch[1]);
}
const filenameMatch = header.match(/filename="([^"]+)"/);
if (filenameMatch) {
return filenameMatch[1];
}
const filenameNoQuotesMatch = header.match(/filename=([^;\s]+)/);
if (filenameNoQuotesMatch) {
return filenameNoQuotesMatch[1];
}
return null;
};
const versionToFilenameSuffix = (version: DocumentVersion): string => {
switch (version) {
case 'signed':
@@ -63,22 +40,12 @@ export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'si
version,
});
const response = await fetch(downloadUrl);
const blob = await response.blob();
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const contentDisposition = response.headers.get('Content-Disposition');
const serverFilename = getFilenameFromContentDisposition(contentDisposition);
let filename: string;
if (serverFilename) {
filename = serverFilename;
} else {
const baseTitle = (fileName ?? 'document').replace(/\.[^/.]+$/, '');
filename = `${baseTitle}${versionToFilenameSuffix(version)}`;
}
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
downloadFile({
filename,
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
data: blob,
});
};
@@ -1,14 +1,5 @@
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
let posthogPromise: Promise<typeof import('posthog-js')> | null = null;
const getPosthog = async () => {
if (!posthogPromise) {
posthogPromise = import('posthog-js');
}
return posthogPromise;
};
import { posthog } from 'posthog-js';
export function useAnalytics() {
// const featureFlags = useFeatureFlags();
@@ -25,9 +16,7 @@ export function useAnalytics() {
return;
}
void getPosthog().then(({ default: posthog }) => {
posthog.capture(event, properties);
});
posthog.capture(event, properties);
};
/**
@@ -41,9 +30,7 @@ export function useAnalytics() {
return;
}
void getPosthog().then(({ default: posthog }) => {
posthog.captureException(error, properties);
});
posthog.captureException(error, properties);
};
/**
-19
View File
@@ -119,22 +119,3 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
return allowedDomains.includes(emailDomain);
};
/**
* Check if signup is enabled for the given provider.
* The master switch takes precedence over the per-provider flags.
*/
export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
return false;
}
const flagMap = {
email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP',
google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP',
microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP',
oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNUP',
} as const;
return env(flagMap[provider]) !== 'true';
};
-11
View File
@@ -1,11 +0,0 @@
/**
* Maximum length (in characters) of the user-supplied custom CSS for branding.
* Bound enforced at the TRPC request boundary on both the organisation and
* team settings update routes. The sanitiser is run after this check; this
* limit is purely a request-size guard.
*
* 256 KB — generous enough for hand-written branding CSS and the occasional
* compiled-from-Tailwind-or-similar paste, while still keeping a request
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
-58
View File
@@ -1,58 +0,0 @@
import type { TCssVarsSchema } from '../types/css-vars';
/**
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
*
* KEEP THIS FILE IN SYNC WITH `packages/ui/styles/theme.css`.
*
* These are the light-mode default values for the CSS custom properties
* defined under `:root` in the theme stylesheet, exposed here as hex strings
* so they can be used as defaults for colour-picker UI components and other
* places that don't render through CSS variables.
*
* If you change a value in `theme.css`, update it here too. There is NO
* automated check linking the two files; they have drifted historically
* and will drift again unless you update both.
*
* Computed via `colord({ h, s, l }).toHex()` — see the inline HSL comments
* for the source-of-truth values from `theme.css`.
*
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
*/
export const DEFAULT_BRAND_COLORS = {
background: '#ffffff', // 0 0% 100%
foreground: '#0f172a', // 222.2 47.4% 11.2%
muted: '#f1f5f9', // 210 40% 96.1%
mutedForeground: '#64748b', // 215.4 16.3% 46.9%
popover: '#ffffff', // 0 0% 100%
popoverForeground: '#0f172a', // 222.2 47.4% 11.2%
card: '#ffffff', // 0 0% 100%
cardBorder: '#e2e8f0', // 214.3 31.8% 91.4%
cardForeground: '#0f172a', // 222.2 47.4% 11.2%
fieldCard: '#e2f8d3', // 95 74% 90%
fieldCardBorder: '#a2e771', // 95.08 71.08% 67.45%
fieldCardForeground: '#0f172a', // 222.2 47.4% 11.2%
widget: '#f7f7f7', // 0 0% 97%
widgetForeground: '#f2f2f2', // 0 0% 95%
border: '#e2e8f0', // 214.3 31.8% 91.4%
input: '#e2e8f0', // 214.3 31.8% 91.4%
primary: '#a2e771', // 95.08 71.08% 67.45%
primaryForeground: '#162c07', // 95.08 71.08% 10%
secondary: '#f1f5f9', // 210 40% 96.1%
secondaryForeground: '#0f172a', // 222.2 47.4% 11.2%
accent: '#f1f5f9', // 210 40% 96.1%
accentForeground: '#0f172a', // 222.2 47.4% 11.2%
destructive: '#ff0000', // 0 100% 50%
destructiveForeground: '#f8fafc', // 210 40% 98%
ring: '#a2e771', // 95.08 71.08% 67.45%
warning: '#e1cb05', // 54 96% 45%
envelopeEditorBackground: '#f8fafc', //210 40% 98.04%
// `cardBorderTint` is intentionally excluded from the colour-picker UI:
// unlike the rest of these tokens it is consumed via `rgb(var(--token))`
// (not `hsl(...)`) and stored as raw RGB triplets in `theme.css`. It does
// not flow through `toNativeCssVars` and is not user-customisable from the
// branding form. `radius` is a length, not a colour, so it lives in
// `DEFAULT_BRAND_RADIUS` below.
} as const satisfies Record<keyof Omit<TCssVarsSchema, 'radius' | 'cardBorderTint'>, string>;
export const DEFAULT_BRAND_RADIUS = '0.5rem';
-23
View File
@@ -1,23 +0,0 @@
import { env } from '@documenso/lib/utils/env';
export const ALLOWED_UPLOAD_MIME_TYPES: Record<string, string[]> = {
'application/pdf': ['.pdf'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
};
export const isAllowedMimeType = (mimeType: string): boolean =>
mimeType in ALLOWED_UPLOAD_MIME_TYPES;
export const getGotenbergUrl = (): string | undefined => env('NEXT_PRIVATE_GOTENBERG_URL');
export const getGotenbergTimeout = (): number => {
const timeout = env('NEXT_PRIVATE_GOTENBERG_TIMEOUT');
return timeout ? parseInt(timeout, 10) : 30_000;
};
export const getFileExtensionForMimeType = (mimeType: string): string => {
const extensions = ALLOWED_UPLOAD_MIME_TYPES[mimeType];
return extensions?.[0] ?? '.pdf';
};
@@ -471,7 +471,7 @@ const decorateAndSignPdf = async ({
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBytes),
},
{ initialData: envelopeItem.documentData.initialData },
envelopeItem.documentData.initialData,
);
return {
-2
View File
@@ -57,8 +57,6 @@
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
"postcss": "^8.5.6",
"postcss-selector-parser": "^7.1.0",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
@@ -1,55 +0,0 @@
import { IS_BILLING_ENABLED } from '../../constants/app';
import type { TCssVarsSchema } from '../../types/css-vars';
import { ZCssVarsSchema } from '../../types/css-vars';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import { getTeamSettings } from '../team/get-team-settings';
export type RecipientBrandingPayload = {
allowCustomBranding: boolean;
hidePoweredBy: boolean;
colors: TCssVarsSchema | null;
css: string | null;
};
/**
* Resolve the branding payload for a recipient-facing route, given the team
* the envelope/document belongs to. Reads inherited team-or-org branding settings,
* checks the org's claim flags, and returns a payload safe to send to the client.
*
* Returns a minimal disabled payload if the team is not on a plan that allows
* custom branding.
*/
export const loadRecipientBrandingByTeamId = async ({
teamId,
}: {
teamId: number;
}): Promise<RecipientBrandingPayload> => {
const billingEnabled = IS_BILLING_ENABLED();
const [settings, claim] = await Promise.all([
getTeamSettings({ teamId }),
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
]);
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
if (!allowCustomBranding) {
return {
allowCustomBranding: false,
hidePoweredBy,
colors: null,
css: null,
};
}
// brandingColors is stored as JSON; parse defensively. Drop unknown keys via Zod.
const parsedColors = settings.brandingColors ? ZCssVarsSchema.safeParse(settings.brandingColors) : null;
return {
allowCustomBranding: true,
hidePoweredBy,
colors: parsedColors?.success ? parsedColors.data : null,
css: settings.brandingCss && settings.brandingCss.length > 0 ? settings.brandingCss : null,
};
};

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