mirror of
https://github.com/documenso/documenso.git
synced 2026-06-27 14:50:50 +10:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a0f438ee6 | |||
| e0cdddc59c | |||
| 862b2a78ea | |||
| 807ad95354 |
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /sign/
|
||||
Disallow: /d/
|
||||
Disallow: /embed/
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
@@ -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`). |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
Generated
+14
-15
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user