Merge branch 'main' into fix/cc-recipient-order-last

This commit is contained in:
Catalin Pit
2026-07-06 09:44:20 +03:00
committed by GitHub
123 changed files with 2985 additions and 1017 deletions
+14
View File
@@ -180,6 +180,20 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP= NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org). # OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS= NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
# OPTIONAL: Set to "true" to disable all signin methods (email, Google, Microsoft, OIDC).
NEXT_PUBLIC_DISABLE_SIGNIN=
# OPTIONAL: Set to "true" to disable email/password signin only. Also closes /forgot-password and /reset-password.
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=
# OPTIONAL: Set to "true" to hide the Google signin button.
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=
# OPTIONAL: Set to "true" to hide the Microsoft signin button.
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=
# OPTIONAL: Set to "true" to hide the OIDC signin button.
NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=
# OPTIONAL: When OIDC is the only enabled signin transport, /signin auto-redirects
# to the OIDC provider (rendering only a spinner). Set to "true" to disable this
# and keep showing the signin page.
NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=
# OPTIONAL: Set to true to use internal webapp url in browserless requests. # OPTIONAL: Set to true to use internal webapp url in browserless requests.
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
@@ -272,6 +272,12 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing 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_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`) | | | `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch. Disable all signin methods application-wide | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin. Also closes `/forgot-password` and `/reset-password` | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable the automatic `/signin` redirect when OIDC is the only enabled transport | `false` |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | | | `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` | | `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
@@ -303,6 +309,44 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
NEXT_PUBLIC_DISABLE_SIGNUP="true" NEXT_PUBLIC_DISABLE_SIGNUP="true"
``` ```
### Sign-in Restrictions
You can control which methods are available for users to sign in with the following environment variables:
- **`NEXT_PUBLIC_DISABLE_SIGNIN`** (master switch): Set to `true` to block all signin methods (email/password, Google, Microsoft, OIDC). Hides every signin entry point on `/signin` and rejects email/password signin server-side with a `SIGNIN_DISABLED` error.
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN`**: Set to `true` to disable email/password signin only. The email/password form is hidden, the `/forgot-password` and `/reset-password` pages redirect to `/signin`, and the corresponding server endpoints reject requests. SSO signin is unaffected.
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNIN`**: Set to `true` to hide the matching SSO button on the signin page. Useful when an SSO provider is kept configured for account linking but not advertised as a signin entry point.
These flags are opt-in: when none are set, signin behaviour is unchanged from a stock Documenso instance.
```bash
# Allow only OIDC signin (e.g. enterprise SSO-only)
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
# Or disable signin entirely
NEXT_PUBLIC_DISABLE_SIGNIN="true"
```
### OIDC Auto-redirect
When OIDC is the only enabled signin transport on your instance, `/signin` automatically redirects users straight to the OIDC provider instead of showing the signin form. The page renders a spinner while the redirect happens. No extra configuration is required — disabling every other signin method is enough to trigger it.
- **`NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT`**: Set to `true` to opt out of the automatic redirect and keep rendering the signin page even when OIDC is the only enabled transport.
The redirect only triggers when OIDC is configured and email/password, Google, and Microsoft signin are all disabled. If any other transport remains enabled, the signin form is shown as normal.
```bash
# OIDC-only signin: disabling all other methods auto-redirects to the provider
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
# Opt out of the auto-redirect while still OIDC-only
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
```
--- ---
## AI Features ## AI Features
@@ -446,6 +490,16 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true" # NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true" # NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org" # NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
# Sign-in restrictions (optional)
# NEXT_PUBLIC_DISABLE_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN="true"
# Opt out of the automatic OIDC redirect when OIDC is the only enabled transport (optional)
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
``` ```
--- ---
@@ -163,6 +163,19 @@ NEXT_PUBLIC_DISABLE_SIGNUP=false
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true # NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true # NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org # NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
# Signin restrictions (optional)
# Master switch — disables every signin method
# NEXT_PUBLIC_DISABLE_SIGNIN=true
# Per-method switches (optional). Each disables that signin path.
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=true
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=true
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=true
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=true
# When OIDC is the only enabled transport, /signin auto-redirects to the provider.
# Set this to opt out and keep showing the signin page (optional).
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true
``` ```
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout> <Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
@@ -112,6 +112,12 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft 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_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | | | `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment). For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
@@ -159,6 +159,12 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft 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_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | | | `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`| Hide the Microsoft signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` | | `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -23,7 +24,7 @@ import { useParams } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({ const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }), name: ZNameSchema,
}); });
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>; type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
@@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
toast({ toast({
description: t`Folder created successfully`, description: t`Folder created successfully`,
}); });
} catch (err) { } catch (_err) {
toast({ toast({
title: t`Failed to create folder`, title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`, description: t`An unknown error occurred while creating the folder.`,
@@ -1,5 +1,6 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -23,8 +24,6 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderUpdateDialogProps = { export type FolderUpdateDialogProps = {
folder: TFolderWithSubfolders | null; folder: TFolderWithSubfolders | null;
isOpen: boolean; isOpen: boolean;
@@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = {
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({ export const ZUpdateFolderFormSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
visibility: z.nativeEnum(DocumentVisibility).optional(), visibility: z.nativeEnum(DocumentVisibility).optional(),
}); });
@@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => { export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
const { t } = useLingui(); const { t } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation(); const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
@@ -1,5 +1,6 @@
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -25,14 +26,13 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { z } from 'zod'; import { z } from 'zod';
export type PasskeyCreateDialogProps = { export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode; trigger?: React.ReactNode;
onSuccess?: () => void; onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({ const ZCreatePasskeyFormSchema = z.object({
passkeyName: z.string().min(3), passkeyName: ZNameSchema,
}); });
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>; type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
@@ -1,4 +1,5 @@
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -19,16 +20,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
import { z } from 'zod'; import type { z } from 'zod';
export type TeamEmailUpdateDialogProps = { export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail; teamEmail: TeamEmail;
trigger?: React.ReactNode; trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamEmailFormSchema = z.object({ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), data: true,
}); }).shape.data;
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>; type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
@@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
defaultValues: { defaultValues: {
name: teamEmail.name, name: teamEmail.name,
}, },
mode: 'onSubmit',
}); });
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation(); const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
@@ -1,5 +1,10 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
BRANDING_LOGO_ALLOWED_TYPES,
BRANDING_LOGO_MAX_SIZE_BYTES,
BRANDING_LOGO_MAX_SIZE_MB,
} from '@documenso/lib/constants/branding';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme'; import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars'; import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -21,15 +26,17 @@ import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce'; import { useCspNonce } from '~/utils/nonce';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB import { FormStickySaveBar } from './form-sticky-save-bar';
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZBrandingPreferencesFormSchema = z.object({ const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(), brandingEnabled: z.boolean().nullable(),
brandingLogo: z brandingLogo: z
.instanceof(File) .instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') .refine(
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted') (file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
)
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.nullish(), .nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')), brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(), brandingCompanyDetails: z.string().max(500).optional(),
@@ -71,38 +78,82 @@ export function BrandingPreferencesForm({
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors); const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
const initialColors = parsedColors.success ? parsedColors.data : {}; const initialColors = parsedColors.success ? parsedColors.data : {};
// The saved state the form maps to. Used both as the reactive `values` source and as
// the explicit target for a Reset (see handleReset).
const savedValues: TBrandingPreferencesFormSchema = {
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
brandingColors: initialColors,
brandingCss: settings.brandingCss ?? '',
};
const form = useForm<TBrandingPreferencesFormSchema>({ const form = useForm<TBrandingPreferencesFormSchema>({
values: { values: savedValues,
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
brandingColors: initialColors,
brandingCss: settings.brandingCss ?? '',
},
resolver: zodResolver(ZBrandingPreferencesFormSchema), resolver: zodResolver(ZBrandingPreferencesFormSchema),
}); });
const isBrandingEnabled = form.watch('brandingEnabled'); const isBrandingEnabled = form.watch('brandingEnabled');
const getSavedLogoPreviewUrl = () => {
if (!settings.brandingLogo) {
return '';
}
const file = JSON.parse(settings.brandingLogo);
if (!('type' in file) || !('data' in file)) {
return '';
}
const logoUrl =
context === 'Team'
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
return `${logoUrl}?v=${Date.now()}`;
};
useEffect(() => { useEffect(() => {
if (settings.brandingLogo) { const savedLogoPreviewUrl = getSavedLogoPreviewUrl();
const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) { if (savedLogoPreviewUrl) {
const logoUrl = setPreviewUrl(savedLogoPreviewUrl);
context === 'Team'
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
setPreviewUrl(logoUrl + '?v=' + Date.now());
setHasLoadedPreview(true);
}
} }
setHasLoadedPreview(true); setHasLoadedPreview(true);
}, [settings.brandingLogo]); }, [settings.brandingLogo]);
// Reset the form to the saved values. The form is driven by the `values` prop (no
// `defaultValues`), so `reset()` with no argument doesn't re-baseline the dirty check;
// passing the saved values clears the per-field dirty tracking (dirtyFields).
const handleReset = () => {
setPreviewUrl(getSavedLogoPreviewUrl());
form.reset(savedValues);
};
// `formState.isDirty` is unreliable for a `values`-driven form: after a reset (or a
// save + refetch) it can stay true even though every field already matches its saved
// value and `dirtyFields` is empty. Derive the flag from `dirtyFields` instead so the
// sticky save bar reliably disappears.
const hasUnsavedChanges = Object.keys(form.formState.dirtyFields).length > 0;
// Re-baseline the form to the just-saved state after a successful submit. The `values`
// prop re-syncs most fields once the route refetches, but write-only fields (the logo
// is a File that isn't reflected back into `values`) would otherwise stay dirty and
// keep the save bar visible. Relies on the page handler rethrowing on error so we only
// re-baseline on success.
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
return;
}
form.reset(form.getValues());
});
// Cleanup ObjectURL on unmount or when previewUrl changes // Cleanup ObjectURL on unmount or when previewUrl changes
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -114,7 +165,7 @@ export function BrandingPreferencesForm({
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}> <fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
<FormField <FormField
control={form.control} control={form.control}
@@ -167,7 +218,7 @@ export function BrandingPreferencesForm({
/> />
<div className="relative flex w-full flex-col gap-y-4"> <div className="relative flex w-full flex-col gap-y-4">
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />} {!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
<FormField <FormField
control={form.control} control={form.control}
@@ -199,7 +250,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative"> <FormControl className="relative">
<Input <Input
type="file" type="file"
accept={ACCEPTED_FILE_TYPES.join(',')} accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
disabled={!isBrandingEnabled} disabled={!isBrandingEnabled}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -321,7 +372,7 @@ export function BrandingPreferencesForm({
{hasAdvancedBranding && ( {hasAdvancedBranding && (
<div className="relative flex w-full flex-col gap-y-6"> <div className="relative flex w-full flex-col gap-y-6">
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />} {!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
<div> <div>
<FormLabel> <FormLabel>
@@ -538,11 +589,11 @@ export function BrandingPreferencesForm({
</div> </div>
)} )}
<div className="flex flex-row justify-end space-x-4"> <FormStickySaveBar
<Button type="submit" loading={form.formState.isSubmitting}> isDirty={hasUnsavedChanges}
<Trans>Update</Trans> isSubmitting={form.formState.isSubmitting}
</Button> onReset={handleReset}
</div> />
</fieldset> </fieldset>
</form> </form>
</Form> </Form>
@@ -21,7 +21,6 @@ import { ReminderSettingsPicker } from '@documenso/ui/components/document/remind
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert'; import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox'; import { Combobox } from '@documenso/ui/primitives/combobox';
import { import {
Form, Form,
@@ -46,6 +45,7 @@ import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox'; import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
import { FormStickySaveBar } from './form-sticky-save-bar';
/** /**
* Can't infer this from the schema since we need to keep the schema inside the component to allow * Can't infer this from the schema since we need to keep the schema inside the component to allow
@@ -147,9 +147,21 @@ export const DocumentPreferencesForm = ({
resolver: zodResolver(ZDocumentPreferencesFormSchema), resolver: zodResolver(ZDocumentPreferencesFormSchema),
}); });
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
// The page handler surfaces its own error toast. Keep the form dirty so
// the save bar stays visible and the user can retry.
return;
}
form.reset(data);
});
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}> <fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{!isPersonalLayoutMode && ( {!isPersonalLayoutMode && (
<FormField <FormField
@@ -756,11 +768,11 @@ export const DocumentPreferencesForm = ({
/> />
)} )}
<div className="flex flex-row justify-end space-x-4"> <FormStickySaveBar
<Button type="submit" loading={form.formState.isSubmitting}> isDirty={form.formState.isDirty}
<Trans>Update</Trans> isSubmitting={form.formState.isSubmitting}
</Button> onReset={() => form.reset()}
</div> />
</fieldset> </fieldset>
</form> </form>
</Form> </Form>
@@ -4,7 +4,6 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes'; import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { Button } from '@documenso/ui/primitives/button';
import { import {
Form, Form,
FormControl, FormControl,
@@ -22,6 +21,8 @@ import type { TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
const ZEmailPreferencesFormSchema = z.object({ const ZEmailPreferencesFormSchema = z.object({
emailId: z.string().nullable(), emailId: z.string().nullable(),
emailReplyTo: zEmail().nullable(), emailReplyTo: zEmail().nullable(),
@@ -59,9 +60,21 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
const emails = emailData?.data || []; const emails = emailData?.data || [];
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
// The page handler surfaces its own error toast. Keep the form dirty so
// the save bar stays visible and the user can retry.
return;
}
form.reset(data);
});
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}> <fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{organisation.organisationClaim.flags.emailDomains && ( {organisation.organisationClaim.flags.emailDomains && (
<FormField <FormField
@@ -203,11 +216,11 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
)} )}
/> />
<div className="flex flex-row justify-end space-x-4"> <FormStickySaveBar
<Button type="submit" loading={form.formState.isSubmitting}> isDirty={form.formState.isDirty}
<Trans>Update</Trans> isSubmitting={form.formState.isSubmitting}
</Button> onReset={() => form.reset()}
</div> />
</fieldset> </fieldset>
</form> </form>
</Form> </Form>
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { import {
Form, Form,
FormControl, FormControl,
@@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({ const ZEmailTransportFormSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
fromName: z.string().min(1), fromName: ZNameSchema,
fromAddress: z.string().email(), fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']), type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(), host: z.string().optional(),
@@ -0,0 +1,119 @@
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
export type FormStickySaveBarProps = {
isDirty: boolean;
isSubmitting: boolean;
onReset: () => void;
};
/**
* A single `position: sticky` bar rendered at the bottom of the form.
*
* - When the form's end is on screen it settles into place as a plain footer (just the
* Reset / Save buttons).
* - When the form's end is scrolled off, it sticks to the bottom of the viewport and
* shows the "unsaved changes" pill chrome.
*
* Because it's the same element in the form's flow, it auto-aligns to the form and the
* float <-> dock hand-off is a native, scroll-linked transition (no measurement, no
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
* the pill chrome.
*/
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
const { t } = useLingui();
const sentinelRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) {
return;
}
// The sentinel sits at the bar's resting position (the end of the form). While the
// bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of
// view); once you reach the form's end it comes into view and the bar settles.
const observer = new IntersectionObserver(
([entry]) => {
setIsStuck(!entry.isIntersecting);
},
{
root: null,
rootMargin: '0px 0px -24px 0px',
threshold: 0,
},
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, []);
// Show the floating pill chrome only when there are unsaved changes AND the form's
// end is off screen.
const isFloating = isDirty && isStuck;
return (
<>
<div
data-testid="form-sticky-save-bar"
className={cn(
'z-40 flex min-h-9 min-w-0 items-center gap-x-2 rounded-lg py-4 transition-[margin,padding,background-color,border-color,box-shadow] duration-200 md:gap-x-4',
isDirty ? 'sticky bottom-6' : '',
// On mobile the docked and floating states are geometrically identical (only
// paint changes): a horizontal bleed there overflows the narrow viewport and
// fights the IntersectionObserver (oscillation + partial hiding). From `sm` up
// there's room, so we restore the original chrome — the island bleeds 8px past
// the form when floating, and the buttons sit flush with the fields when docked.
isFloating
? 'border border-border bg-background px-4 shadow-2xl sm:-mx-2'
: 'border border-transparent bg-transparent px-4 shadow-none sm:px-0',
)}
>
<AnimatePresence initial={false}>
{isFloating && (
<motion.div
key="notice"
role="region"
aria-label={t`Unsaved changes`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="flex min-h-9 min-w-0 items-center gap-x-2 text-sm"
>
<AlertTriangleIcon className="h-5 w-5 flex-shrink-0 text-destructive" />
<span className="font-medium text-xs md:text-sm">
<Trans>You have unsaved changes</Trans>
</span>
</motion.div>
)}
</AnimatePresence>
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
{isDirty && (
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
<Trans>Undo</Trans>
</Button>
)}
<Button type="submit" className="shrink-0" size="sm" loading={isSubmitting} disabled={!isDirty}>
<Trans>Save changes</Trans>
</Button>
</div>
</div>
{/* Sentinel: detects when the sticky bar is floating (stuck) vs settled (docked). */}
<div ref={sentinelRef} aria-hidden className="pointer-events-none h-px w-full" />
</>
);
};
@@ -4,7 +4,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types'; import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -12,11 +11,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import type { z } from 'zod'; import type { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({ const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
name: true, name: true,
url: true, url: true,
@@ -137,36 +137,11 @@ export const OrganisationUpdateForm = () => {
)} )}
/> />
<div className="flex flex-row justify-end space-x-4"> <FormStickySaveBar
<AnimatePresence> isDirty={form.formState.isDirty}
{form.formState.isDirty && ( isSubmitting={form.formState.isSubmitting}
<motion.div onReset={() => form.reset()}
initial={{ />
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
<Trans>Reset</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Trans>Update organisation</Trans>
</Button>
</div>
</fieldset> </fieldset>
</form> </form>
</Form> </Form>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
+63 -49
View File
@@ -58,6 +58,7 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
export type SignInFormProps = { export type SignInFormProps = {
className?: string; className?: string;
initialEmail?: string; initialEmail?: string;
isEmailPasswordSigninEnabled?: boolean;
isGoogleSSOEnabled?: boolean; isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean; isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean; isOIDCSSOEnabled?: boolean;
@@ -68,6 +69,7 @@ export type SignInFormProps = {
export const SignInForm = ({ export const SignInForm = ({
className, className,
initialEmail, initialEmail,
isEmailPasswordSigninEnabled = true,
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled, isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
@@ -324,66 +326,78 @@ export const SignInForm = ({
<Form {...form}> <Form {...form}>
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}> <form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting || isPasskeyLoading}> <fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting || isPasskeyLoading}>
<FormField {isEmailPasswordSigninEnabled && (
control={form.control} <>
name="email" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="email"
<FormLabel> render={({ field }) => (
<Trans>Email</Trans> <FormItem>
</FormLabel> <FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl> <FormControl>
<Input type="email" {...field} /> <Input type="email" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Password</Trans> <Trans>Password</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<PasswordInput {...field} /> <PasswordInput {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<p className="mt-2 text-right"> <p className="mt-2 text-right">
<Link to="/forgot-password" className="text-muted-foreground text-sm duration-200 hover:opacity-70"> <Link
<Trans>Forgot your password?</Trans> to="/forgot-password"
</Link> className="text-muted-foreground text-sm duration-200 hover:opacity-70"
</p> >
</FormItem> <Trans>Forgot your password?</Trans>
)} </Link>
/> </p>
</FormItem>
)}
/>
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && ( {turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
<Turnstile <Turnstile
ref={turnstileRef} ref={turnstileRef}
siteKey={turnstileSiteKey} siteKey={turnstileSiteKey}
options={{ options={{
size: 'flexible', size: 'flexible',
appearance: 'always', appearance: 'always',
}} }}
/> />
)}
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</>
)} )}
<Button type="submit" size="lg" loading={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
{!isEmbeddedRedirect && ( {!isEmbeddedRedirect && (
<> <>
{hasSocialAuthEnabled && ( {isEmailPasswordSigninEnabled && hasSocialAuthEnabled && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase"> <div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="h-px flex-1 bg-border" /> <div className="h-px flex-1 bg-border" />
<span className="bg-transparent text-muted-foreground"> <span className="bg-transparent text-muted-foreground">
+1 -1
View File
@@ -1,8 +1,8 @@
import communityCardsImage from '@documenso/assets/images/community-cards.png'; import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -2,7 +2,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types'; import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -10,11 +9,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import type { z } from 'zod'; import type { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
export type UpdateTeamDialogProps = { export type UpdateTeamDialogProps = {
teamId: number; teamId: number;
teamName: string; teamName: string;
@@ -135,36 +135,11 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
)} )}
/> />
<div className="flex flex-row justify-end space-x-4"> <FormStickySaveBar
<AnimatePresence> isDirty={form.formState.isDirty}
{form.formState.isDirty && ( isSubmitting={form.formState.isSubmitting}
<motion.div onReset={() => form.reset()}
initial={{ />
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
<Trans>Reset</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Trans>Update team</Trans>
</Button>
</div>
</fieldset> </fieldset>
</form> </form>
</Form> </Form>
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client'; import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import type { ReactNode } from 'react';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
type AdminGlobalSettingsSectionProps = { type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null; settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean; isTeam?: boolean;
/** When viewing a team, the parent organisation settings the team inherits from. */
inheritedSettings?: OrganisationGlobalSettings | null;
}; };
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => { export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
inheritedSettings,
}: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) { if (!settings) {
return null; return null;
} }
const textValue = (value: string | null | undefined) => { const notSet = <Trans>Not set</Trans>;
if (value === null || value === undefined) {
return notSetLabel; const inheritedValue = (value: ReactNode) => {
if (!isTeam || value === null) {
return notSet;
} }
return value; return (
<span className="flex items-center gap-1.5">
<span className="text-muted-foreground">
<Trans>Inherited</Trans>:
</span>
<span>{value}</span>
</span>
);
}; };
const brandingTextValue = (value: string | null | undefined) => { const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value === null || value === undefined || value.trim() === '') { if (value && value.trim() !== '') {
return notSetLabel; return value;
} }
return value; if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
}; };
const booleanValue = (value: boolean | null | undefined) => { const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
if (value === null || value === undefined) {
return notSetLabel; const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
if (value !== null && value !== undefined) {
return booleanLabel(value);
} }
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>; return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet;
};
const visibilityLabel = (value: string | null | undefined) => {
return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null;
};
const visibilityValue = (value: string | null | undefined, inherited?: string | null) => {
const label = visibilityLabel(value);
if (label !== null) {
return label;
}
return inheritedValue(visibilityLabel(inherited));
}; };
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings); const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
@@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}> <DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue> <DetailsValue>
{settings.documentVisibility != null {visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
</DetailsValue> </DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}> <DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue> <DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}> <DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue> <DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}> <DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue> <DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}> <DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue> <DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}> <DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue> <DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}> <DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue> <DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}> <DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue> <DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}> <DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue> <DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}> <DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue> <DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}> <DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue> <DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}> <DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue> <DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}> <DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue> <DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}> <DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue> <DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}> <DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue> <DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
</DetailsCard> </DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}> <DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue> <DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
</DetailsCard> </DetailsCard>
{isTeam && parsedEmailSettings.success && ( {isTeam && parsedEmailSettings.success && (
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
)} )}
<DetailsCard label={<Trans>AI features</Trans>}> <DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue> <DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard> </DetailsCard>
</div> </div>
); );
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -19,7 +20,6 @@ import { useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup'; import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = { export type ClaimAccountProps = {
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
export const ZClaimAccountFormSchema = z export const ZClaimAccountFormSchema = z
.object({ .object({
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }), name: ZNameSchema,
email: zEmail().min(1), email: zEmail().min(1),
password: ZPasswordSchema, password: ZPasswordSchema,
}) })
@@ -1,11 +1,4 @@
import { import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
import { RateLimitArrayInput } from './rate-limit-array-input'; import { RateLimitArrayInput } from './rate-limit-array-input';
/**
* The rate-limit editor renders its own per-row inline errors, but a submit
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
* window). Rendering the field's message here guarantees the form never fails
* silently when those errors are not tied to a row the editor is showing.
*/
type ClaimLimitFieldsProps<T extends FieldValues> = { type ClaimLimitFieldsProps<T extends FieldValues> = {
control: Control<T>; control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */ /** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean; disabled?: boolean;
}; };
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({ export const ClaimLimitFields = <T extends FieldValues>({
control, control,
prefix = '', prefix = '',
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>; const name = (key: string) => `${prefix}${key}` as Path<T>;
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => ( const limitGroups: LimitGroup[] = [
{
title: <Trans>Documents</Trans>,
quotaKey: 'documentQuota',
rateLimitKey: 'documentRateLimits',
},
{
title: <Trans>Emails</Trans>,
quotaKey: 'emailQuota',
rateLimitKey: 'emailRateLimits',
},
{
title: <Trans>API</Trans>,
quotaKey: 'apiQuota',
rateLimitKey: 'apiRateLimits',
},
];
const renderQuotaField = (group: LimitGroup) => (
<FormField <FormField
control={control} control={control}
name={name(key)} name={name(group.quotaKey)}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{label}</FormLabel> <FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
@@ -47,20 +73,18 @@ export const ClaimLimitFields = <T extends FieldValues>({
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))} onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/> />
</FormControl> </FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
); );
const renderRateLimitField = (key: string, label: ReactNode) => ( const renderRateLimitField = (group: LimitGroup) => (
<FormField <FormField
control={control} control={control}
name={name(key)} name={name(group.rateLimitKey)}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{label}</FormLabel>
<FormControl> <FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} /> <RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl> </FormControl>
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
); );
return ( return (
<div className="space-y-4 rounded-md border p-4"> <div className="space-y-3">
<FormLabel> <div>
<Trans>Limits</Trans> <h3 className="font-semibold text-base">
</FormLabel> <Trans>Limits</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
</Trans>
</p>
</div>
{renderQuotaField( <div className="overflow-hidden rounded-lg border">
'documentQuota', <div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
<Trans>Monthly document quota</Trans>, {limitGroups.map((group) => (
<Trans>Empty = Unlimited, 0 = Blocked</Trans>, <div key={group.quotaKey} className="space-y-4 p-4">
)} <h4 className="font-semibold text-sm">{group.title}</h4>
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
{renderQuotaField( {renderQuotaField(group)}
'emailQuota', {renderRateLimitField(group)}
<Trans>Monthly email quota</Trans>, </div>
<Trans>Empty = Unlimited, 0 = Blocked</Trans>, ))}
)} </div>
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)} </div>
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
</div> </div>
); );
}; };
@@ -1,13 +1,38 @@
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period'; import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import {
getQuotaUsagePercent,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from '@documenso/lib/universal/quota-usage';
import { cn } from '@documenso/ui/lib/utils';
import type { BadgeProps } from '@documenso/ui/primitives/badge';
import { Badge } from '@documenso/ui/primitives/badge';
import { Progress } from '@documenso/ui/primitives/progress'; import { Progress } from '@documenso/ui/primitives/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client'; import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import { useState } from 'react'; import type { LucideIcon } from 'lucide-react';
import { match } from 'ts-pattern'; import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useId, useState } from 'react';
import { OrganisationUsageResetButton } from './organisation-usage-reset-button'; import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
type CapacityUsage = {
members: number;
teams: number;
};
type UsageRow = {
counter: 'document' | 'email' | 'api';
label: ReactNode;
icon: LucideIcon;
used: number;
effectiveLimit: number | null;
};
type OrganisationUsagePanelProps = { type OrganisationUsagePanelProps = {
organisationId: string; organisationId: string;
monthlyStats: Pick< monthlyStats: Pick<
@@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = {
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' 'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[]; >[];
organisationClaim: OrganisationClaim; organisationClaim: OrganisationClaim;
capacityUsage?: CapacityUsage;
};
type UsageCardState = {
status: {
label: ReactNode;
variant: NonNullable<BadgeProps['variant']>;
};
percent: number;
hasFiniteLimit: boolean;
progressClassName: string;
subtext: ReactNode;
};
type UsageCardStateOptions = {
used: number;
limit: number | null | undefined;
footnote?: ReactNode;
};
const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => {
const percent = getQuotaUsagePercent(used, limit ?? null);
const hasFiniteLimit = Boolean(limit && limit > 0);
if (limit === null || limit === undefined) {
return {
status: { label: <Trans>Unlimited</Trans>, variant: 'neutral' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
}
if (limit === 0) {
return {
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? <Trans>Resource blocked</Trans>,
};
}
if (used > limit) {
return {
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-destructive',
subtext: footnote ?? null,
};
}
if (isQuotaExceeded(limit, used)) {
return {
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
subtext: footnote ?? null,
};
}
if (isQuotaNearing(limit, used)) {
return {
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
percent,
hasFiniteLimit,
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
subtext: footnote ?? null,
};
}
return {
status: { label: <Trans>Within limit</Trans>, variant: 'default' },
percent,
hasFiniteLimit,
progressClassName: '',
subtext: footnote ?? null,
};
};
type UsageStatCardProps = {
label: ReactNode;
icon: LucideIcon;
used: number;
limit: number | null | undefined;
/** When true the card is a plain counter with no limit, status or progress. */
countOnly?: boolean;
footnote?: ReactNode;
action?: ReactNode;
};
const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => {
const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote });
return (
<div className="flex flex-col rounded-lg border bg-background p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
<Icon className="h-4 w-4 text-muted-foreground" />
<span>{label}</span>
</div>
{!countOnly && (
<Badge variant={status.variant} size="small">
{status.label}
</Badge>
)}
</div>
<div className="mt-4 flex flex-1 flex-col">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-1.5">
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
{used.toLocaleString()}
</span>
{hasFiniteLimit ? (
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
) : null}
</div>
{hasFiniteLimit ? (
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
) : null}
</div>
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
</div>
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
</div>
);
}; };
export const OrganisationUsagePanel = ({ export const OrganisationUsagePanel = ({
organisationId, organisationId,
monthlyStats, monthlyStats,
organisationClaim, organisationClaim,
capacityUsage,
}: OrganisationUsagePanelProps) => { }: OrganisationUsagePanelProps) => {
const monthlyUsagePeriodId = useId();
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period); const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0]; const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
@@ -30,86 +193,105 @@ export const OrganisationUsagePanel = ({
// current period), so only offer the reset action when viewing the current month. // current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod(); const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
const rows = [ const capacityRows = capacityUsage
? [
{
key: 'members',
label: <Trans>Members</Trans>,
icon: UsersIcon,
used: capacityUsage.members,
limit: normalizeCapacityLimit(organisationClaim.memberCount),
},
{
key: 'teams',
label: <Trans>Teams</Trans>,
icon: UsersRoundIcon,
used: capacityUsage.teams,
limit: normalizeCapacityLimit(organisationClaim.teamCount),
},
]
: [];
const monthlyRows: UsageRow[] = [
{ {
counter: 'document' as const, counter: 'document',
label: <Trans>Documents</Trans>, label: <Trans>Documents</Trans>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0, used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota, effectiveLimit: organisationClaim.documentQuota,
}, },
{ {
counter: 'email' as const, counter: 'email',
label: <Trans>Emails</Trans>, label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0, used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota, effectiveLimit: organisationClaim.emailQuota,
}, },
{ {
counter: 'api' as const, counter: 'api',
label: <Trans>API requests</Trans>, label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0, used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota, effectiveLimit: organisationClaim.apiQuota,
}, },
]; ];
return ( return (
<div className="space-y-4 rounded-md border p-4"> <div className="mt-4 space-y-6">
<div className="flex items-center justify-between gap-2"> {capacityRows.length > 0 ? (
<h3 className="font-medium text-sm"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans> {capacityRows.map((row) => (
</h3> <UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
))}
</div>
) : null}
{monthlyStats.length > 0 && ( <div className="space-y-3">
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SelectTrigger className="w-40"> <h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
<SelectValue /> <Trans>Monthly usage</Trans>
</SelectTrigger> </h3>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{rows.map((row) => { {monthlyStats.length > 0 ? (
const percent = <Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
row.effectiveLimit && row.effectiveLimit > 0 <SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100)) <SelectValue />
: 0; </SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
return ( <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div key={row.counter} className="space-y-1"> {monthlyRows.map((row) => (
<div className="flex items-center justify-between text-sm"> <UsageStatCard
<span>{row.label}</span> key={row.counter}
<span className="text-muted-foreground"> label={row.label}
{row.used} /{' '} icon={row.icon}
{match(row.effectiveLimit) used={row.used}
.with(null, () => <Trans>Unlimited</Trans>) limit={row.effectiveLimit}
.with(0, () => <Trans>Blocked</Trans>) action={
.otherwise(String)} selectedStat && isCurrentPeriod ? (
</span> <OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div> ) : undefined
}
/>
))}
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null} <UsageStatCard
label={<Trans>Reports</Trans>}
{selectedStat && isCurrentPeriod && ( icon={MailOpenIcon}
<div className="flex w-full justify-end pt-1"> used={selectedStat?.emailReports ?? 0}
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} /> limit={null}
</div> countOnly
)} footnote={<Trans>Sent this period</Trans>}
</div> />
);
})}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>
<Trans>Reports</Trans>
</span>
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { RotateCcwIcon } from 'lucide-react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = { type OrganisationUsageResetButtonProps = {
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
loading={isPending} loading={isPending}
onClick={() => reset({ organisationId, counter })} onClick={() => reset({ organisationId, counter })}
> >
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Reset</Trans> <Trans>Reset</Trans>
</Button> </Button>
); );
@@ -1,7 +1,9 @@
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Trans } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { PlusIcon, Trash2Icon } from 'lucide-react'; import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
type RateLimitEntryValue = { window: string; max: number }; type RateLimitEntryValue = { window: string; max: number };
@@ -11,50 +13,153 @@ type RateLimitArrayInputProps = {
disabled?: boolean; disabled?: boolean;
}; };
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0;
/** Keep in-progress rows; drop rows that are completely empty. */
const persistEntries = (entries: RateLimitEntryValue[]) => {
return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput);
};
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => { export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
const entries = value ?? []; const { t } = useLingui();
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(null);
const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY];
const getWindowError = (entry: RateLimitEntryValue, index: number) => {
const window = entry.window.trim();
if (!hasEntryInput(entry)) {
return null;
}
if (window === '') {
return t`Enter a window, e.g. 5m`;
}
if (!RATE_LIMIT_WINDOW_REGEX.test(window)) {
return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`;
}
const isDuplicateWindow = entries.some((otherEntry, otherIndex) => {
return otherIndex !== index && otherEntry.window.trim() === window;
});
return isDuplicateWindow ? t`Use a unique window for each rate limit` : null;
};
const getMaxError = (entry: RateLimitEntryValue) => {
if (!hasEntryInput(entry)) {
return null;
}
return entry.max > 0 ? null : t`Enter a max request count greater than 0`;
};
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => { const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); if (index >= value.length) {
onChange(next); const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
if (hasEntryInput(nextDraftEntry)) {
onChange(persistEntries([...value, nextDraftEntry]));
setDraftEntry(null);
return;
}
setDraftEntry(nextDraftEntry);
return;
}
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(persistEntries(next));
}; };
const removeEntry = (index: number) => { const removeEntry = (index: number) => {
onChange(entries.filter((_, i) => i !== index)); if (index >= value.length) {
setDraftEntry(null);
return;
}
const next = value.filter((_, i) => i !== index);
onChange(persistEntries(next));
}; };
const addEntry = () => { const addEntry = () => {
onChange([...entries, { window: '5m', max: 100 }]); setDraftEntry(EMPTY_ENTRY);
}; };
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{entries.map((entry, index) => ( <div className="flex items-center gap-2 text-muted-foreground text-xs">
<div key={index} className="flex items-center gap-2"> <span className="w-20 shrink-0">
<Input <Trans>Window</Trans>
className="w-24" </span>
placeholder="5m" <span className="flex-1">
value={entry.window} <Trans>Max requests</Trans>
disabled={disabled} </span>
onChange={(e) => updateEntry(index, { window: e.target.value })} <span className="w-9 shrink-0" aria-hidden="true" />
/> </div>
<Input
className="w-32"
type="number"
min={1}
value={entry.max}
disabled={disabled}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}> {entries.map((entry, index) => {
const windowError = getWindowError(entry, index);
const maxError = getMaxError(entry);
return (
<div key={index} className="space-y-1">
<div className="flex items-center gap-2">
<Input
className="w-20 shrink-0"
placeholder="5m"
value={entry.window}
disabled={disabled}
aria-invalid={Boolean(windowError)}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<Input
className="flex-1"
type="number"
min={1}
placeholder="100"
value={entry.max || ''}
disabled={disabled}
aria-invalid={Boolean(maxError)}
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
disabled={disabled}
aria-label={t`Remove rate limit`}
onClick={() => removeEntry(index)}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-dashed"
disabled={isAddDisabled}
onClick={addEntry}
>
<PlusIcon className="mr-2 h-4 w-4" /> <PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit</Trans> <Trans>Add rate limit window</Trans>
</Button> </Button>
</div> </div>
); );
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
}; };
const ZUpdatePasskeySchema = z.object({ const ZUpdatePasskeySchema = z.object({
name: z.string(), name: ZNameSchema,
}); });
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>; type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types'; import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types'; import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
@@ -30,7 +31,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client'; import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react'; import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -42,7 +43,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog'; import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog'; import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog'; import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section'; import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
import { ClaimLimitFields } from '~/components/general/claim-limit-fields'; import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} /> <GenericOrganisationAdminForm organisation={organisation} />
<div className="mt-6 rounded-lg border p-4"> <SettingsHeader
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> title={t`Organisation usage`}
<div> subtitle={t`Current usage against organisation limits.`}
<p className="font-medium text-sm"> className="mt-6"
<Trans>Organisation usage</Trans> hideDivider
</p> />
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2"> <OrganisationUsagePanel
<DetailsCard label={<Trans>Members</Trans>}> organisationId={organisation.id}
<DetailsValue> monthlyStats={organisation.monthlyStats}
{organisation.members.length} /{' '} organisationClaim={organisation.organisationClaim}
{organisation.organisationClaim.memberCount === 0 capacityUsage={{
? t`Unlimited` members: organisation.members.length,
: organisation.organisationClaim.memberCount} teams: organisation.teams.length,
</DetailsValue> }}
</DetailsCard> />
<DetailsCard label={<Trans>Teams</Trans>}>
<DetailsValue>
{organisation.teams.length} /{' '}
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
</DetailsValue>
</DetailsCard>
</div>
<div className="mt-4">
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
/>
</div>
</div>
<div className="mt-6 rounded-lg border p-4"> <div className="mt-6 rounded-lg border p-4">
<Accordion type="single" collapsible> <Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0"> <AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0"> <AccordionTrigger className="py-0">
<div className="text-left"> <div className="text-left">
<p className="font-medium text-sm"> <p className="font-semibold text-base">
<Trans>Global Settings</Trans> <Trans>Global Settings</Trans>
</p> </p>
<p className="mt-1 font-normal text-muted-foreground text-sm"> <p className="mt-1 text-muted-foreground text-sm">
<Trans>Default settings applied to this organisation.</Trans> <Trans>Default settings applied to this organisation.</Trans>
</p> </p>
</div> </div>
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
className="mt-16" className="mt-16"
/> />
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral"> <Alert
className={cn(
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
)}
variant="neutral"
>
<div className="mb-4 sm:mb-0"> <div className="mb-4 sm:mb-0">
<AlertTitle> <AlertTitle>
<Trans>Subscription</Trans> <Trans>Subscription</Trans>
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2"> <AlertDescription className="mr-2">
{organisation.subscription ? ( {organisation.subscription ? (
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span> <span className="flex items-center gap-2">
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
)}
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
</span>
) : ( ) : (
<span> <span>
<Trans>No subscription found</Trans> <Trans>No subscription found</Trans>
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div> <div>
<Button <Button
variant="outline" variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer} loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })} onClick={async () => createStripeCustomer({ organisationId })}
> >
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && ( {organisation.customerId && !organisation.subscription && (
<div> <div>
<Button variant="outline" asChild> <Button variant="outline" className="bg-background" asChild>
<Link <Link
target="_blank" target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`} to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -383,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog <AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId} organisationId={organisationId}
trigger={ trigger={
<Button variant="outline"> <Button variant="outline" className="bg-background">
<Trans>Sync Stripe subscription</Trans> <Trans>Sync Stripe subscription</Trans>
</Button> </Button>
} }
/> />
<Button variant="outline" asChild> <Button variant="outline" className="bg-background" asChild>
<Link <Link
target="_blank" target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`} to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -406,21 +398,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div className="mt-16 space-y-10"> <div className="mt-16 space-y-10">
<div> <div>
<label className="font-medium text-sm leading-none"> <h3 className="font-semibold text-base">
<Trans>Organisation Members</Trans> <Trans>Organisation Members</Trans>
</label> </h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>People with access to this organisation.</Trans>
</p>
<div className="my-2"> <div className="mt-3">
<DataTable columns={organisationMembersColumns} data={organisation.members} /> <DataTable columns={organisationMembersColumns} data={organisation.members} />
</div> </div>
</div> </div>
<div> <div>
<label className="font-medium text-sm leading-none"> <h3 className="font-semibold text-base">
<Trans>Organisation Teams</Trans> <Trans>Organisation Teams</Trans>
</label> </h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Teams that belong to this organisation.</Trans>
</p>
<div className="my-2"> <div className="mt-3">
<DataTable columns={teamsColumns} data={organisation.teams} /> <DataTable columns={teamsColumns} data={organisation.teams} />
</div> </div>
</div> </div>
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center"> <FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans> <Trans>Inherited subscription claim</Trans>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" /> <InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger> </TooltipTrigger>
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</FormLabel> </FormLabel>
<FormControl> <div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
<Input disabled {...field} /> {field.value ? (
</FormControl> <span className="font-mono text-foreground">{field.value}</span>
<FormMessage /> ) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
</FormItem> </FormItem>
)} )}
/> />
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)} )}
/> />
<FormField <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
control={form.control} <FormField
name="claims.teamCount" control={form.control}
render={({ field }) => ( name="claims.teamCount"
<FormItem> render={({ field }) => (
<FormLabel> <FormItem>
<Trans>Team Count</Trans> <FormLabel>
</FormLabel> <Trans>Team Count</Trans>
<FormControl> </FormLabel>
<Input <FormControl>
type="number" <Input
min={0} type="number"
{...field} min={0}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} {...field}
/> onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
</FormControl> />
<FormDescription> </FormControl>
<Trans>Number of teams allowed. 0 = Unlimited</Trans> <FormDescription>
</FormDescription> <Trans>Number of teams allowed. 0 = Unlimited</Trans>
<FormMessage /> </FormDescription>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
<FormField <FormField
control={form.control} control={form.control}
name="claims.memberCount" name="claims.memberCount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Member Count</Trans> <Trans>Member Count</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={0} min={0}
{...field} {...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans> <Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="claims.envelopeItemCount" name="claims.envelopeItemCount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Envelope Item Count</Trans> <Trans>Envelope Item Count</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={1} min={1}
{...field} {...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans> <Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="claims.recipientCount" name="claims.recipientCount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Recipient Count</Trans> <Trans>Recipient Count</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min={0} min={0}
{...field} {...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)} onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans> <Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
<div> <div>
<FormLabel> <h3 className="font-semibold text-base">
<Trans>Feature Flags</Trans> <Trans>Feature Flags</Trans>
</FormLabel> </h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Capabilities enabled for this organisation.</Trans>
</p>
<div className="mt-2 space-y-2 rounded-md border p-4"> <div className="mt-3 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => { {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
@@ -287,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="mt-4"> <div className="mt-4">
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam /> <AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; 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 { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation(); const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => { const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try { try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data; const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined; // Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) { formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
// Empty the branding logo if the user unsets it. if (brandingLogo instanceof File) {
if (brandingLogo === null) { formData.append('brandingLogo', brandingLogo);
uploadedBrandingLogo = ''; }
await updateOrganisationBrandingLogo(formData);
} }
const result = await updateOrganisationSettings({ const result = await updateOrganisationSettings({
organisationId: organisation.id, organisationId: organisation.id,
data: { data: {
brandingEnabled: brandingEnabled ?? undefined, brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl, brandingUrl,
brandingCompanyDetails, brandingCompanyDetails,
brandingColors, brandingColors,
@@ -104,6 +106,9 @@ export default function OrganisationSettingsBrandingPage() {
description: t`We were unable to update your branding preferences at this time, please try again later`, description: t`We were unable to update your branding preferences at this time, please try again later`,
variant: 'destructive', variant: 'destructive',
}); });
// Rethrow so the form knows the save failed and keeps the unsaved changes.
throw err;
} }
}; };
@@ -105,6 +105,8 @@ export default function OrganisationSettingsDocumentPage() {
description: t`We were unable to update your document preferences at this time, please try again later`, description: t`We were unable to update your document preferences at this time, please try again later`,
variant: 'destructive', variant: 'destructive',
}); });
throw err;
} }
}; };
@@ -49,6 +49,8 @@ export default function OrganisationSettingsGeneral() {
description: t`We were unable to update your email preferences at this time, please try again later`, description: t`We were unable to update your email preferences at this time, please try again later`,
variant: 'destructive', variant: 'destructive',
}); });
throw err;
} }
}; };
@@ -3,6 +3,7 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types'; import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -28,7 +29,6 @@ import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog'; import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { import {
@@ -36,7 +36,6 @@ import {
OrganisationMembersMultiSelectCombobox, OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox'; } from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id'; import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) { export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
@@ -113,7 +112,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
} }
const ZUpdateOrganisationGroupFormSchema = z.object({ const ZUpdateOrganisationGroupFormSchema = z.object({
name: z.string().min(1, msg`Name is required`.id), name: ZNameSchema,
organisationRole: z.nativeEnum(OrganisationMemberRole), organisationRole: z.nativeEnum(OrganisationMemberRole),
memberIds: z.array(z.string()), memberIds: z.array(z.string()),
}); });
@@ -1,6 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -38,6 +37,7 @@ export default function TeamsSettingsPage() {
}); });
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation(); const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED(); const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
@@ -48,22 +48,23 @@ export default function TeamsSettingsPage() {
try { try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data; const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined; // Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) { formData.append('payload', JSON.stringify({ teamId: team.id }));
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
// Empty the branding logo if the user unsets it. if (brandingLogo instanceof File) {
if (brandingLogo === null) { formData.append('brandingLogo', brandingLogo);
uploadedBrandingLogo = ''; }
await updateTeamBrandingLogo(formData);
} }
const result = await updateTeamSettings({ const result = await updateTeamSettings({
teamId: team.id, teamId: team.id,
data: { data: {
brandingEnabled, brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null, brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null, brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors, brandingColors,
@@ -99,6 +100,9 @@ export default function TeamsSettingsPage() {
description: t`We were unable to update your branding preferences at this time, please try again later`, description: t`We were unable to update your branding preferences at this time, please try again later`,
variant: 'destructive', variant: 'destructive',
}); });
// Rethrow so the form knows the save failed and keeps the unsaved changes.
throw err;
} }
}; };
@@ -96,6 +96,8 @@ export default function TeamsSettingsPage() {
description: t`We were unable to update your document preferences at this time, please try again later`, description: t`We were unable to update your document preferences at this time, please try again later`,
variant: 'destructive', variant: 'destructive',
}); });
throw err;
} }
}; };
@@ -49,6 +49,8 @@ export default function TeamEmailSettingsGeneral() {
description: t`We were unable to update your email preferences at this time, please try again later`, description: t`We were unable to update your email preferences at this time, please try again later`,
variant: 'destructive', variant: 'destructive',
}); });
throw err;
} }
}; };
@@ -1,6 +1,7 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router'; import { Link, redirect } from 'react-router';
import { ForgotPasswordForm } from '~/components/forms/forgot-password'; import { ForgotPasswordForm } from '~/components/forms/forgot-password';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@@ -9,6 +10,14 @@ export function meta() {
return appMetaTags(msg`Forgot Password`); return appMetaTags(msg`Forgot Password`);
} }
export async function loader() {
if (!isSigninEnabledForProvider('email')) {
throw redirect('/signin');
}
return null;
}
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
return ( return (
<div className="w-screen max-w-lg px-4"> <div className="w-screen max-w-lg px-4">
@@ -1,3 +1,4 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity'; import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
@@ -13,6 +14,10 @@ export function meta() {
} }
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
if (!isSigninEnabledForProvider('email')) {
throw redirect('/signin');
}
const { token } = params; const { token } = params;
const isValid = await getResetTokenValidity({ token }); const isValid = await getResetTokenValidity({ token });
@@ -1,7 +1,8 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router'; import { Link, redirect } from 'react-router';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@@ -9,6 +10,14 @@ export function meta() {
return appMetaTags(msg`Reset Password`); return appMetaTags(msg`Reset Password`);
} }
export async function loader() {
if (!isSigninEnabledForProvider('email')) {
throw redirect('/signin');
}
return null;
}
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
return ( return (
<div className="w-screen max-w-lg px-4"> <div className="w-screen max-w-lg px-4">
@@ -1,8 +1,11 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { import {
IS_GOOGLE_SSO_ENABLED, IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED, IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_AUTO_REDIRECT_DISABLED,
IS_OIDC_SSO_ENABLED, IS_OIDC_SSO_ENABLED,
isSigninEnabledForProvider,
isSignupEnabledForProvider, isSignupEnabledForProvider,
OIDC_PROVIDER_LABEL, OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth'; } from '@documenso/lib/constants/auth';
@@ -11,6 +14,7 @@ import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, redirect, useSearchParams } from 'react-router'; import { Link, redirect, useSearchParams } from 'react-router';
@@ -28,10 +32,20 @@ export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request); const { isAuthenticated } = await getOptionalSession(request);
// SSR env variables. // SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED; const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED; const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED && isSigninEnabledForProvider('google');
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED; const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED && isSigninEnabledForProvider('microsoft');
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED && isSigninEnabledForProvider('oidc');
// Automatically redirect to OIDC when it is the only enabled signin transport,
// unless the redirect has been explicitly disabled via env.
const isOIDCOnlyTransport =
isOIDCSSOEnabled && !isEmailPasswordSigninEnabled && !isGoogleSSOEnabled && !isMicrosoftSSOEnabled;
const shouldAutoRedirectToOIDC = isOIDCOnlyTransport && !IS_OIDC_AUTO_REDIRECT_DISABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL; const oidcProviderLabel = OIDC_PROVIDER_LABEL;
const isSignupEnabled = const isSignupEnabled =
isSignupEnabledForProvider('email') || isSignupEnabledForProvider('email') ||
(IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) || (IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) ||
@@ -47,18 +61,28 @@ export async function loader({ request }: Route.LoaderArgs) {
} }
return { return {
isEmailPasswordSigninEnabled,
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled, isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
isSignupEnabled, isSignupEnabled,
oidcProviderLabel, oidcProviderLabel,
returnTo, returnTo,
shouldAutoRedirectToOIDC,
}; };
} }
export default function SignIn({ loaderData }: Route.ComponentProps) { export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, oidcProviderLabel, returnTo } = const {
loaderData; isEmailPasswordSigninEnabled,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isSignupEnabled,
oidcProviderLabel,
returnTo,
shouldAutoRedirectToOIDC,
} = loaderData;
const { _ } = useLingui(); const { _ } = useLingui();
@@ -76,6 +100,27 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
setIsEmbeddedRedirect(params.get('embedded') === 'true'); setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, []); }, []);
useEffect(() => {
if (!shouldAutoRedirectToOIDC) {
return;
}
void authClient.oidc.signIn({ redirectPath: returnTo ?? '/' });
}, [shouldAutoRedirectToOIDC, returnTo]);
if (shouldAutoRedirectToOIDC) {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Redirecting to {oidcProviderLabel || 'OIDC'}...</Trans>
</p>
</div>
</div>
);
}
return ( return (
<div className="w-screen max-w-lg px-4"> <div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background"> <div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
@@ -95,6 +140,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
<hr className="-mx-6 my-4" /> <hr className="-mx-6 my-4" />
<SignInForm <SignInForm
isEmailPasswordSigninEnabled={isEmailPasswordSigninEnabled}
isGoogleSSOEnabled={isGoogleSSOEnabled} isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled} isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled} isOIDCSSOEnabled={isOIDCSSOEnabled}
+1 -28
View File
@@ -1,9 +1,8 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator'; import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router'; import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers'; import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import { import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema, ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema, ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema, ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema, ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema, ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema, ZUploadPdfRequestSchema,
} from './files.types'; } from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf'; import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
@@ -61,29 +57,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Upload failed' }, 500); return c.json({ error: 'Upload failed' }, 500);
} }
}) })
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.get( .get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId', '/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema), sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>; export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>; export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({ export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeId: z.string().min(1), envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1), envelopeItemId: z.string().min(1),
-1
View File
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
// Files route. // Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware); app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute); app.route('/api/files', filesRoute);
// AI route. // AI route.
+6
View File
@@ -64,6 +64,12 @@ services:
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP} - NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP}
- NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP} - NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP}
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS} - NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
- NEXT_PUBLIC_DISABLE_SIGNIN=${NEXT_PUBLIC_DISABLE_SIGNIN}
- NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN}
- NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=${NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN}
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN}
- NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=${NEXT_PUBLIC_DISABLE_OIDC_SIGNIN}
- NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=${NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT}
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12} - NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE} - NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
- NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS} - NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS}
+4 -4
View File
@@ -15,7 +15,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@libpdf/core": "^0.4.0", "@libpdf/core": "^0.4.1",
"@lingui/conf": "^5.6.0", "@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0", "@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0", "@marsidev/react-turnstile": "^1.5.0",
@@ -4649,9 +4649,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@libpdf/core": { "node_modules/@libpdf/core": {
"version": "0.4.0", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.1.tgz",
"integrity": "sha512-G9nZRjf9DGDJaS/C23YWogk8akPM7O/6HfMslxVsKTKRbbbb+0szpQIetcGGUGRu7KtmBDmGDWCgz//DXSmq8A==", "integrity": "sha512-DWGxWw1na8oFPixz+b6kOgu4tMD8xJLDgyPGVUNqIswO5POHAxsqmTvUvDW0IrwHP7kFRHBV3I3T/KhTABf9rA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/ciphers": "^2.2.0", "@noble/ciphers": "^2.2.0",
+1 -1
View File
@@ -87,7 +87,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@libpdf/core": "^0.4.0", "@libpdf/core": "^0.4.1",
"@lingui/conf": "^5.6.0", "@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0", "@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1", "@prisma/extension-read-replicas": "^0.4.1",
@@ -526,7 +526,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
// Should be able to access organisation settings // Should be able to access organisation settings
await expect(page.getByText('Organisation Settings')).toBeVisible(); await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible(); await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible(); await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
// Should have delete permissions // Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
expect(res.status()).toBe(401); expect(res.status()).toBe(401);
}); });
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => { test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser(); const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id); const presignToken = await createPresignTokenForUser(user.id, team.id);
@@ -0,0 +1,37 @@
import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo';
import { expect, test } from '@playwright/test';
import sharp from 'sharp';
const makePng = async (width = 1200, height = 1200) =>
sharp({
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
test.describe('optimiseBrandingLogo', () => {
test('re-encodes a valid image to a PNG buffer', async () => {
const input = await makePng();
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.format).toBe('png');
});
test('bounds the image to a maximum of 512px on its largest side', async () => {
const input = await makePng(2000, 1000);
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.width).toBeLessThanOrEqual(512);
expect(metadata.height).toBeLessThanOrEqual(512);
});
test('rejects input that is not a valid image', async () => {
await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow();
});
});
@@ -0,0 +1,225 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from './fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const LOGO_PATH = path.join(__dirname, '../../assets/logo.png');
type MultipartFile = { name: string; mimeType: string; buffer: Buffer };
const enableBrandingAndUpload = async (page: Page) => {
// Enable custom branding so the file input is no longer disabled.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
// Upload the logo file through the real multipart route.
await page.locator('input[type="file"]').setInputFiles(LOGO_PATH);
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
};
/**
* POST a logo straight to the dedicated multipart tRPC route using the
* authenticated browser cookies. This bypasses the client-side form validation,
* which is the only way to exercise the server-side image validation /
* sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate.
*/
const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => {
const multipart: Record<string, string | MultipartFile> = {
payload: JSON.stringify({ organisationId }),
};
if (file) {
multipart.brandingLogo = file;
}
return await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart });
};
/**
* Grant the organisation the custom-branding entitlement. The positive branding
* flows require it whenever billing is enabled; with billing disabled the gate is
* bypassed, so this keeps these tests valid in both modes.
*/
const grantCustomBranding = async (organisationClaimId: string) => {
await prisma.organisationClaim.update({
where: { id: organisationClaimId },
data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } },
});
};
test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
const parsed = JSON.parse(settings.brandingLogo);
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => {
const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it
// through the team relation.
const teamWithSettings = await prisma.team.findUniqueOrThrow({
where: { id: team.id },
include: { teamGlobalSettings: true },
});
expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy();
const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? '');
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// Confirm the logo was stored before we clear it.
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
// Remove the logo and save again.
await page.getByRole('button', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
// Clearing the logo persists an empty string via the dedicated route.
await expect
.poll(async () => {
const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
return updated.brandingLogo;
})
.toBe('');
});
test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
// Positive control: a genuine PNG is accepted and stored. This also proves the
// direct multipart request shape matches what the route expects.
const validResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(validResponse.ok()).toBeTruthy();
const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(afterValid.brandingLogo).toBeTruthy();
// Bytes that pass the MIME/size allowlist but are not a real image must be
// rejected by the server (the `sharp` re-encode) without changing stored state.
const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'fake.png',
mimeType: 'image/png',
buffer: Buffer.from('this is definitely not a valid png'),
});
expect(invalidResponse.ok()).toBeFalsy();
expect(invalidResponse.status()).toBeGreaterThanOrEqual(400);
expect(invalidResponse.status()).toBeLessThan(500);
const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
// The previously stored, valid logo is left untouched by the rejected upload.
expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo);
});
test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => {
// The entitlement is only enforced when billing is enabled; with billing off
// the check is intentionally skipped server-side, so this can't be exercised.
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true',
'Entitlement is only enforced when billing is enabled.',
);
// Seeded organisations have no `allowCustomBranding` claim flag.
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
const response = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(response.ok()).toBeFalsy();
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeFalsy();
});
@@ -19,7 +19,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
}); });
// Wait for the form to load. // Wait for the form to load.
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// Change the amount to 2. // Change the amount to 2.
const amountInput = page.getByTestId('envelope-expiration-amount'); const amountInput = page.getByTestId('envelope-expiration-amount');
@@ -35,7 +35,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
await unitTrigger.click(); await unitTrigger.click();
await page.getByRole('option', { name: 'Weeks' }).click(); await page.getByRole('option', { name: 'Weeks' }).click();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database. // Verify via database.
@@ -57,14 +57,14 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
redirectPath: `/o/${organisation.url}/settings/document`, redirectPath: `/o/${organisation.url}/settings/document`,
}); });
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// Find the mode select (shows "Custom duration") and change to "Never expires". // Find the mode select (shows "Custom duration") and change to "Never expires".
const modeTrigger = page.getByTestId('envelope-expiration-mode'); const modeTrigger = page.getByTestId('envelope-expiration-mode');
await modeTrigger.click(); await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).click(); await page.getByRole('option', { name: 'Never expires' }).click();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database. // Verify via database.
@@ -109,7 +109,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
redirectPath: `/t/${team.url}/settings/document`, redirectPath: `/t/${team.url}/settings/document`,
}); });
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// The expiration picker mode select should show "Inherit from organisation" by default. // The expiration picker mode select should show "Inherit from organisation" by default.
const modeTrigger = page.getByTestId('envelope-expiration-mode'); const modeTrigger = page.getByTestId('envelope-expiration-mode');
@@ -128,7 +128,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await unitTrigger.click(); await unitTrigger.click();
await page.getByRole('option', { name: 'Days' }).click(); await page.getByRole('option', { name: 'Days' }).click();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify team setting is overridden. // Verify team setting is overridden.
@@ -324,10 +324,7 @@ test.describe('Signing Certificate Tests', () => {
.click(); .click();
await page.getByRole('option', { name: 'No' }).click(); await page.getByRole('option', { name: 'No' }).click();
await page await page.getByRole('button', { name: 'Save changes' }).first().click();
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
@@ -347,10 +344,7 @@ test.describe('Signing Certificate Tests', () => {
.getByRole('combobox') .getByRole('combobox')
.click(); .click();
await page.getByRole('option', { name: 'Yes' }).click(); await page.getByRole('option', { name: 'Yes' }).click();
await page await page.getByRole('button', { name: 'Save changes' }).first().click();
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
@@ -60,7 +60,7 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
await page.getByLabel('Organisation URL*').clear(); await page.getByLabel('Organisation URL*').clear();
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId); await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
await page.getByRole('button', { name: 'Update organisation' }).click(); await page.getByRole('button', { name: 'Save changes' }).click();
// Check we have been redirected to the new organisation URL and the name is updated. // Check we have been redirected to the new organisation URL and the name is updated.
await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`); await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`);
@@ -39,7 +39,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'No' }).click(); await page.getByRole('option', { name: 'No' }).click();
await page.getByTestId('include-signing-certificate-trigger').click(); await page.getByTestId('include-signing-certificate-trigger').click();
await page.getByRole('option', { name: 'No' }).click(); await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({ const teamSettings = await getTeamSettings({
@@ -73,7 +73,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByTestId('document-date-format-trigger').click(); await page.getByTestId('document-date-format-trigger').click();
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click(); await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({ const updatedTeamSettings = await getTeamSettings({
@@ -128,7 +128,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com'); await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click(); await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails'); await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({ const teamSettings = await getTeamSettings({
@@ -150,7 +150,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com'); await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click(); await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails'); await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({ const updatedTeamSettings = await getTeamSettings({
@@ -165,7 +165,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation // Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click(); await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click(); await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
@@ -208,7 +208,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck(); await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck();
await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck(); await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({ const teamSettings = await getTeamSettings({
@@ -245,7 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck(); await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck(); await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({ const updatedTeamSettings = await getTeamSettings({
@@ -292,7 +292,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Reply to email' }).fill(''); await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click(); await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click(); await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
@@ -142,3 +142,38 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0); await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0); await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
}); });
test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({
page,
}) => {
const { user, team, organisation } = await seedUser();
await enableOrganisationBranding({
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
});
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['enabled-disabled-branding-signer@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
// Branding enabled → the custom logo is rendered on the signing page.
await page.goto(`/sign/${recipients[0].token}`);
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
// Disable branding while keeping the stored logo (the team inherits this).
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { brandingEnabled: false },
});
// Branding disabled → the custom logo is gone and the Documenso fallback
// (an internal link to "/") is shown instead.
await page.goto(`/sign/${recipients[0].token}`);
await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0);
await expect(page.locator('a[href="/"]').first()).toBeVisible();
});
@@ -66,7 +66,7 @@ test('[TEAMS]: update team', async ({ page }) => {
await page.getByLabel('Team URL*').clear(); await page.getByLabel('Team URL*').clear();
await page.getByLabel('Team URL*').fill(updatedTeamId); await page.getByLabel('Team URL*').fill(updatedTeamId);
await page.getByRole('button', { name: 'Update team' }).click(); await page.getByRole('button', { name: 'Save changes' }).click();
// Check we have been redirected to the new team URL and the name is updated. // Check we have been redirected to the new team URL and the name is updated.
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`); await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
@@ -0,0 +1,163 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
/**
* Calls a team-group tRPC mutation directly, bypassing the UI.
*
* The UI only ever surfaces CUSTOM / INTERNAL_ORGANISATION groups, so these
* authorisation rules must be enforced on the server - a crafted request can
* target any `teamGroupId`, including the system-managed INTERNAL_TEAM groups.
*/
const callTeamGroupMutation = (
page: Page,
procedure: 'team.group.delete' | 'team.group.update',
teamId: number,
input: Record<string, unknown>,
) =>
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({ json: input }),
});
/**
* Every team is created with three system-managed INTERNAL_TEAM groups
* (admin/manager/member). They are the backbone of team-specific access and,
* like organisation internal groups, must not be deletable - deleting them
* silently strips team members of access while leaving the team row in place.
*/
test('[TEAMS]: internal team groups cannot be deleted via the API', async ({ page }) => {
// Member inheritance OFF: membership is granted exclusively through the team's
// INTERNAL_TEAM groups, so removing them is what causes the access loss.
const { user: owner, team } = await seedUser({ inheritMembers: false });
// A direct team member whose access depends on the INTERNAL_TEAM member group.
const directMember = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await apiSignin({ page, email: owner.email });
const internalTeamGroups = await prisma.teamGroup.findMany({
where: {
teamId: team.id,
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
},
});
// admin + manager + member.
expect(internalTeamGroups).toHaveLength(3);
for (const group of internalTeamGroups) {
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
teamId: team.id,
teamGroupId: group.id,
});
expect(response.status(), `INTERNAL_TEAM ${group.teamRole} group must not be deletable`).not.toBe(200);
}
// None of the internal groups were removed.
const remaining = await prisma.teamGroup.count({
where: {
teamId: team.id,
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
},
});
expect(remaining).toBe(3);
// The direct member therefore keeps their team access.
const memberStillHasAccess = await prisma.teamGroup.findFirst({
where: {
teamId: team.id,
organisationGroup: {
type: OrganisationGroupType.INTERNAL_TEAM,
organisationGroupMembers: {
some: { organisationMember: { userId: directMember.id } },
},
},
},
});
expect(memberStillHasAccess).not.toBeNull();
});
/**
* Guards against over-blocking: user-created (CUSTOM) team groups are not
* internal and must remain removable by team managers/admins.
*/
test('[TEAMS]: custom team groups can still be deleted', async ({ page }) => {
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
const customGroup = await prisma.organisationGroup.create({
data: {
id: generateDatabaseId('org_group'),
name: `custom-${team.url}`,
type: OrganisationGroupType.CUSTOM,
organisationRole: OrganisationMemberRole.MEMBER,
organisationId: organisation.id,
teamGroups: {
create: {
id: generateDatabaseId('team_group'),
teamId: team.id,
teamRole: TeamMemberRole.MEMBER,
},
},
},
include: { teamGroups: true },
});
const customTeamGroup = customGroup.teamGroups[0];
await apiSignin({ page, email: owner.email });
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
teamId: team.id,
teamGroupId: customTeamGroup.id,
});
expect(response.status()).toBe(200);
const deleted = await prisma.teamGroup.findUnique({ where: { id: customTeamGroup.id } });
expect(deleted).toBeNull();
});
/**
* The same root cause affects updates: an INTERNAL_TEAM group's role must not be
* editable either, otherwise a team admin could rewrite the backbone roles
* (e.g. promote the member group to admin).
*/
test('[TEAMS]: internal team groups cannot be updated via the API', async ({ page }) => {
const { user: owner, team } = await seedUser({ inheritMembers: false });
await apiSignin({ page, email: owner.email });
const internalMemberGroup = await prisma.teamGroup.findFirstOrThrow({
where: {
teamId: team.id,
teamRole: TeamMemberRole.MEMBER,
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
},
});
const response = await callTeamGroupMutation(page, 'team.group.update', team.id, {
id: internalMemberGroup.id,
data: { teamRole: TeamMemberRole.ADMIN },
});
expect(response.status()).not.toBe(200);
const reloaded = await prisma.teamGroup.findUniqueOrThrow({ where: { id: internalMemberGroup.id } });
expect(reloaded.teamRole).toBe(TeamMemberRole.MEMBER);
});
@@ -0,0 +1,79 @@
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
test('[TEAMS]: settings save bar docks at the bottom of the form', async ({ page }) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings`,
});
await expect(page.getByLabel('Team Name*')).toBeVisible();
const saveButton = page.getByRole('button', { name: 'Save changes' });
// Pristine: the docked Save button is present but disabled; no Undo, no floating notice.
await expect(saveButton).toBeVisible();
await expect(saveButton).toBeDisabled();
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
// Make a change → Save enables and Undo appears.
const updatedName = `team-${Date.now()}`;
await page.getByLabel('Team Name*').clear();
await page.getByLabel('Team Name*').fill(updatedName);
await expect(saveButton).toBeEnabled();
await expect(page.getByRole('button', { name: 'Undo' })).toBeVisible();
// Undo → value restored, Save disabled again, Undo gone.
await page.getByRole('button', { name: 'Undo' }).click();
await expect(page.getByLabel('Team Name*')).toHaveValue(team.name);
await expect(saveButton).toBeDisabled();
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
// Change again → Save → success toast, returns to a pristine (disabled) state.
await page.getByLabel('Team Name*').clear();
await page.getByLabel('Team Name*').fill(updatedName);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(page.getByText('Your team has been successfully updated.').first()).toBeVisible();
await expect(saveButton).toBeDisabled();
});
test('[ORGANISATIONS]: settings save bar floats when the form footer is off-screen', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/document`,
});
// Wait for the long document-preferences form to load.
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// Pristine: no floating notice even though the footer is below the fold.
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
// Edit a field near the top → the footer is off-screen, so the floating pill appears.
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: 'German' }).click();
await expect(page.getByText('You have unsaved changes')).toBeVisible();
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
// Scroll to the footer → the floating pill merges into the docked buttons and the
// notice disappears.
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
});
@@ -75,7 +75,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await item.click(); await item.click();
} }
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
// Wait for the update to complete // Wait for the update to complete
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible(); await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
@@ -140,7 +140,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await item.click(); await item.click();
} }
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save changes' }).first().click();
// Wait for finish // Wait for finish
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible(); await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
@@ -17,6 +17,7 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET', // TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS', // TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE', InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
SigninDisabled: 'SIGNIN_DISABLED',
SignupDisabled: 'SIGNUP_DISABLED', SignupDisabled: 'SIGNUP_DISABLED',
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL', SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE', // IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
@@ -1,6 +1,7 @@
import { import {
isDisposableEmail, isDisposableEmail,
isEmailDomainAllowedForSignup, isEmailDomainAllowedForSignup,
isSigninEnabledForProvider,
isSignupEnabledForProvider, isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth'; } from '@documenso/lib/constants/auth';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email'; import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
@@ -64,6 +65,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => { .post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
const requestMetadata = c.get('requestMetadata'); const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
statusCode: 400,
});
}
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json'); const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
const loginLimitResult = await loginRateLimit.check({ const loginLimitResult = await loginRateLimit.check({
@@ -244,6 +251,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { password, currentPassword } = c.req.valid('json'); const { password, currentPassword } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata'); const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
statusCode: 400,
});
}
const { session, user } = await getSession(c); const { session, user } = await getSession(c);
await updatePassword({ await updatePassword({
@@ -346,6 +359,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => { .post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata'); const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
statusCode: 400,
});
}
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const forgotLimitResult = await forgotPasswordRateLimit.check({ const forgotLimitResult = await forgotPasswordRateLimit.check({
@@ -377,6 +396,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => { .post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata'); const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
statusCode: 400,
});
}
const { token, password } = c.req.valid('json'); const { token, password } = c.req.valid('json');
const resetLimitResult = await resetPasswordRateLimit.check({ const resetLimitResult = await resetPasswordRateLimit.check({
+1 -1
View File
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod'; import { z } from 'zod';
+27 -15
View File
@@ -1,25 +1,10 @@
import MailChecker from 'mailchecker'; import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env'; import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app'; import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const SALT_ROUNDS = 12; export const SALT_ROUNDS = 12;
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering.
*/
export const ZNameSchema = z
.string()
.trim()
.min(3, { message: 'Please enter a valid name.' })
.max(255, { message: 'Name cannot be more than 255 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
});
export const IDENTITY_PROVIDER_NAME: Record<string, string> = { export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso', DOCUMENSO: 'Documenso',
GOOGLE: 'Google', GOOGLE: 'Google',
@@ -41,6 +26,14 @@ export const IS_OIDC_SSO_ENABLED = Boolean(
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL'); export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
/**
* Opt-out flag for the automatic OIDC redirect.
*
* When OIDC is the only enabled signin transport we redirect to the provider
* automatically. Set this to "true" to keep rendering the signin page instead.
*/
export const IS_OIDC_AUTO_REDIRECT_DISABLED = env('NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT') === 'true';
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = { export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
ACCOUNT_SSO_LINK: 'Linked account to SSO', ACCOUNT_SSO_LINK: 'Linked account to SSO',
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO', ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
@@ -188,3 +181,22 @@ export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'micro
return env(flagMap[provider]) !== 'true'; return env(flagMap[provider]) !== 'true';
}; };
/**
* Check if signin is enabled for the given provider.
* The master switch takes precedence over the per-provider flags.
*/
export const isSigninEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
if (env('NEXT_PUBLIC_DISABLE_SIGNIN') === 'true') {
return false;
}
const flagMap = {
email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN',
google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN',
microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN',
oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNIN',
} as const;
return env(flagMap[provider]) !== 'true';
};
+10
View File
@@ -9,3 +9,13 @@
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory. * cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/ */
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024; export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
/**
* Branding logo upload constraints. Enforced server-side at the TRPC request
* boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX.
*/
export const BRANDING_LOGO_MAX_SIZE_MB = 5;
export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024;
export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp'];
@@ -0,0 +1,26 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { putFileServerSide } from '../../universal/upload/put-file.server';
import { optimiseBrandingLogo } from '../../utils/images/logo';
/**
* Validate, sanitise and store an uploaded branding logo. Returns the
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
* column (the same format the serving endpoints already expect).
*/
export const buildBrandingLogoData = async (file: File): Promise<string> => {
const buffer = Buffer.from(await file.arrayBuffer());
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'The branding logo must be a valid image file.',
});
});
const documentData = await putFileServerSide({
name: 'branding-logo.png',
type: 'image/png',
arrayBuffer: async () => Promise.resolve(optimised),
});
return JSON.stringify(documentData);
};
@@ -83,15 +83,17 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
// Handle hard or soft deleting the actual document if user has permission. // Handle hard or soft deleting the actual document if user has permission.
if (hasDeleteAccess) { if (hasDeleteAccess) {
await handleDocumentOwnerDelete({ const updatedEnvelope = await handleDocumentOwnerDelete({
envelope, envelope,
user, user,
requestMetadata, requestMetadata,
}); });
const envelopeForWebhook = { ...envelope, ...(updatedEnvelope ?? {}) };
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED, event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)), data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForWebhook)),
userId, userId,
teamId, teamId,
}); });
@@ -1,4 +1,4 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind'; import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
export type QuotaFlags = { export type QuotaFlags = {
isDocumentQuotaExceeded: boolean; isDocumentQuotaExceeded: boolean;
@@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = {
}; };
}; };
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => { export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return { return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0), isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
@@ -1,4 +1,4 @@
export const QUOTA_WARNING_THRESHOLD = 0.8; import { getQuotaWarningCount } from '../../universal/quota-usage';
export type QuotaAlertKind = 'quota' | 'quotaNearing'; export type QuotaAlertKind = 'quota' | 'quotaNearing';
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up // From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the // warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above. // exhausting request is handled by the quota branch above.
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD); const warningCount = getQuotaWarningCount(quota);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount; const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
+31 -18
View File
@@ -2441,6 +2441,7 @@ msgstr "Branding-Logo"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "Markenpräferenzen" msgstr "Markenpräferenzen"
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
msgstr "Derzeit können alle Organisationsmitglieder auf dieses Team zugreifen" msgstr "Derzeit können alle Organisationsmitglieder auf dieses Team zugreifen"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "Zurzeit kann das Branding nur für Teams und darüber konfiguriert werden." msgstr "Zurzeit kann das Branding nur für Teams und darüber konfiguriert werden."
@@ -4214,8 +4216,8 @@ msgstr "Dokument storniert"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "Dokument storniert" msgstr "Dokument storniert"
@@ -7942,6 +7944,11 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "Andernfalls wird das Dokument als Entwurf erstellt." msgstr "Andernfalls wird das Dokument als Entwurf erstellt."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8917,6 +8924,11 @@ msgstr "Weiterleitungs-URL"
msgid "Redirecting" msgid "Redirecting"
msgstr "Weiterleitung" msgstr "Weiterleitung"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9192,9 +9204,7 @@ msgstr "Umschlag erneut senden"
msgid "Resend verification" msgid "Resend verification"
msgstr "Bestätigung erneut senden" msgstr "Bestätigung erneut senden"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "Zurücksetzen" msgstr "Zurücksetzen"
@@ -9379,6 +9389,7 @@ msgid "Save as Template"
msgstr "Als Vorlage speichern" msgstr "Als Vorlage speichern"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "Änderungen speichern" msgstr "Änderungen speichern"
@@ -10219,6 +10230,11 @@ msgstr "Website Einstellungen"
msgid "Skip" msgid "Skip"
msgstr "Überspringen" msgstr "Überspringen"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "Einige Unterzeichner haben noch kein Unterschriftsfeld zugewiesen bekommen. Bitte weisen Sie jedem Unterzeichner mindestens ein Unterschriftsfeld zu, bevor Sie fortfahren." msgstr "Einige Unterzeichner haben noch kein Unterschriftsfeld zugewiesen bekommen. Bitte weisen Sie jedem Unterzeichner mindestens ein Unterschriftsfeld zu, bevor Sie fortfahren."
@@ -12249,6 +12265,7 @@ msgstr "Nicht autorisiert"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "Unvollendet" msgstr "Unvollendet"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "Rückgängig" msgstr "Rückgängig"
@@ -12298,6 +12315,10 @@ msgstr "Verknüpfung aufheben"
msgid "Unpin" msgid "Unpin"
msgstr "Lösen" msgstr "Lösen"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12317,9 +12338,6 @@ msgstr "Unbetitelte Gruppe"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12342,6 +12360,7 @@ msgstr "Banner aktualisieren"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "Rechnungsdaten aktualisieren" msgstr "Rechnungsdaten aktualisieren"
@@ -12369,10 +12388,6 @@ msgstr "E-Mail aktualisieren"
msgid "Update Fields" msgid "Update Fields"
msgstr "Felder aktualisieren" msgstr "Felder aktualisieren"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "Organisation aktualisieren"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12408,10 +12423,6 @@ msgstr "Rolle aktualisieren"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "Abonnementsanspruch aktualisieren" msgstr "Abonnementsanspruch aktualisieren"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "Team aktualisieren"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12942,7 +12953,7 @@ msgstr "Warten"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "Warten auf andere" msgstr "Warten auf andere"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "Warten auf andere, um die Unterzeichnung abzuschließen." msgstr "Warten auf andere, um die Unterzeichnung abzuschließen."
@@ -13916,8 +13927,7 @@ msgstr "Du wurdest eingeladen, {0} auf Documenso beizutreten"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "Sie wurden eingeladen, der folgenden Organisation beizutreten" msgstr "Sie wurden eingeladen, der folgenden Organisation beizutreten"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "Du wurdest von einem Dokument entfernt" msgstr "Du wurdest von einem Dokument entfernt"
@@ -14037,6 +14047,10 @@ msgstr "Sie haben den Zugriff erfolgreich widerrufen."
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "Sie haben das Recht, Ihre Zustimmung zur Verwendung elektronischer Unterschriften jederzeit vor Abschluss des Unterzeichnungsprozesses zu widerrufen. Um Ihre Zustimmung zu widerrufen, kontaktieren Sie bitte den Absender des Dokuments. Sollten Sie den Absender nicht erreichen, können Sie sich für Unterstützung an <0>{SUPPORT_EMAIL}</0> wenden. Seien Sie sich bewusst, dass der Widerruf der Zustimmung den Abschluss der zugehörigen Transaktion oder Dienstleistung verzögern oder stoppen kann." msgstr "Sie haben das Recht, Ihre Zustimmung zur Verwendung elektronischer Unterschriften jederzeit vor Abschluss des Unterzeichnungsprozesses zu widerrufen. Um Ihre Zustimmung zu widerrufen, kontaktieren Sie bitte den Absender des Dokuments. Sollten Sie den Absender nicht erreichen, können Sie sich für Unterstützung an <0>{SUPPORT_EMAIL}</0> wenden. Seien Sie sich bewusst, dass der Widerruf der Zustimmung den Abschluss der zugehörigen Transaktion oder Dienstleistung verzögern oder stoppen kann."
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "Sie haben {memberName} aktualisiert." msgstr "Sie haben {memberName} aktualisiert."
@@ -14716,4 +14730,3 @@ msgstr "Ihr Verifizierungscode:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com" msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com"
+52 -17
View File
@@ -2436,6 +2436,7 @@ msgstr "Branding Logo"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "Branding Preferences" msgstr "Branding Preferences"
@@ -3567,6 +3568,7 @@ msgid "Currently all organisation members can access this team"
msgstr "Currently all organisation members can access this team" msgstr "Currently all organisation members can access this team"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "Currently branding can only be configured for Teams and above plans." msgstr "Currently branding can only be configured for Teams and above plans."
@@ -4209,8 +4211,8 @@ msgstr "Document cancelled"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "Document Cancelled" msgstr "Document Cancelled"
@@ -7937,6 +7939,11 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "Otherwise, the document will be created as a draft." msgstr "Otherwise, the document will be created as a draft."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr "Overlapping fields detected"
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8772,6 +8779,10 @@ msgstr "Recipient ID:"
msgid "Recipient rejected the document" msgid "Recipient rejected the document"
msgstr "Recipient rejected the document" msgstr "Recipient rejected the document"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient rejected the document externally"
msgstr "Recipient rejected the document externally"
#: apps/remix/app/components/general/admin-global-settings-section.tsx #: apps/remix/app/components/general/admin-global-settings-section.tsx
msgid "Recipient removed" msgid "Recipient removed"
msgstr "Recipient removed" msgstr "Recipient removed"
@@ -8908,6 +8919,11 @@ msgstr "Redirect URL"
msgid "Redirecting" msgid "Redirecting"
msgstr "Redirecting" msgstr "Redirecting"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr "Redirecting to {0}..."
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9183,9 +9199,7 @@ msgstr "Resend Envelope"
msgid "Resend verification" msgid "Resend verification"
msgstr "Resend verification" msgstr "Resend verification"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "Reset" msgstr "Reset"
@@ -9370,6 +9384,7 @@ msgid "Save as Template"
msgstr "Save as Template" msgstr "Save as Template"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "Save changes" msgstr "Save changes"
@@ -10210,6 +10225,11 @@ msgstr "Site Settings"
msgid "Skip" msgid "Skip"
msgstr "Skip" msgstr "Skip"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
@@ -11088,6 +11108,23 @@ msgstr "The document signing process will be stopped"
msgid "The document was created but could not be sent to recipients." msgid "The document was created but could not be sent to recipients."
msgstr "The document was created but could not be sent to recipients." msgstr "The document was created but could not be sent to recipients."
#: packages/lib/utils/document-audit-logs.ts
msgid "The document was rejected externally by {onBehalfOf} on behalf of {user}"
msgstr "The document was rejected externally by {onBehalfOf} on behalf of {user}"
#: packages/lib/utils/document-audit-logs.ts
#: packages/lib/utils/document-audit-logs.ts
msgid "The document was rejected externally by {onBehalfOf} on behalf of the recipient"
msgstr "The document was rejected externally by {onBehalfOf} on behalf of the recipient"
#: packages/lib/utils/document-audit-logs.ts
msgid "The document was rejected externally on behalf of {user}"
msgstr "The document was rejected externally on behalf of {user}"
#: packages/lib/utils/document-audit-logs.ts
msgid "The document was rejected externally on behalf of the recipient"
msgstr "The document was rejected externally on behalf of the recipient"
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "The document will be hidden from your account" msgid "The document will be hidden from your account"
msgstr "The document will be hidden from your account" msgstr "The document will be hidden from your account"
@@ -12223,6 +12260,7 @@ msgstr "Unauthorized"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "Uncompleted" msgstr "Uncompleted"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "Undo" msgstr "Undo"
@@ -12272,6 +12310,10 @@ msgstr "Unlink"
msgid "Unpin" msgid "Unpin"
msgstr "Unpin" msgstr "Unpin"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr "Unsaved changes"
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12291,9 +12333,6 @@ msgstr "Untitled Group"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12316,6 +12355,7 @@ msgstr "Update Banner"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "Update Billing" msgstr "Update Billing"
@@ -12343,10 +12383,6 @@ msgstr "Update email"
msgid "Update Fields" msgid "Update Fields"
msgstr "Update Fields" msgstr "Update Fields"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "Update organisation"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12382,10 +12418,6 @@ msgstr "Update role"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "Update Subscription Claim" msgstr "Update Subscription Claim"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "Update team"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12916,7 +12948,7 @@ msgstr "Waiting"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "Waiting for others" msgstr "Waiting for others"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "Waiting for others to complete signing." msgstr "Waiting for others to complete signing."
@@ -13890,8 +13922,7 @@ msgstr "You have been invited to join {0} on Documenso"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "You have been invited to join the following organisation" msgstr "You have been invited to join the following organisation"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "You have been removed from a document" msgstr "You have been removed from a document"
@@ -14011,6 +14042,10 @@ msgstr "You have successfully revoked access."
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr "You have unsaved changes"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "You have updated {memberName}." msgstr "You have updated {memberName}."
+31 -18
View File
@@ -2441,6 +2441,7 @@ msgstr "Logotipo de Marca"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "Preferencias de marca" msgstr "Preferencias de marca"
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
msgstr "Actualmente, todos los miembros de la organización pueden acceder a este equipo" msgstr "Actualmente, todos los miembros de la organización pueden acceder a este equipo"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "Actualmente la marca solo se puede configurar para Equipos y planes superiores." msgstr "Actualmente la marca solo se puede configurar para Equipos y planes superiores."
@@ -4214,8 +4216,8 @@ msgstr "Documento cancelado"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "Documento cancelado" msgstr "Documento cancelado"
@@ -7942,6 +7944,11 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "De lo contrario, el documento se creará como un borrador." msgstr "De lo contrario, el documento se creará como un borrador."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8917,6 +8924,11 @@ msgstr "URL de redirección"
msgid "Redirecting" msgid "Redirecting"
msgstr "Redireccionando" msgstr "Redireccionando"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9192,9 +9204,7 @@ msgstr "Reenviar sobre (envelope)"
msgid "Resend verification" msgid "Resend verification"
msgstr "Reenviar verificación" msgstr "Reenviar verificación"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "Restablecer" msgstr "Restablecer"
@@ -9379,6 +9389,7 @@ msgid "Save as Template"
msgstr "Guardar como plantilla" msgstr "Guardar como plantilla"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "Guardar cambios" msgstr "Guardar cambios"
@@ -10219,6 +10230,11 @@ msgstr "Configuraciones del sitio"
msgid "Skip" msgid "Skip"
msgstr "Omitir" msgstr "Omitir"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "Algunos firmantes no han sido asignados a un campo de firma. Asigne al menos 1 campo de firma a cada firmante antes de continuar." msgstr "Algunos firmantes no han sido asignados a un campo de firma. Asigne al menos 1 campo de firma a cada firmante antes de continuar."
@@ -12249,6 +12265,7 @@ msgstr "No autorizado"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "Incompleto" msgstr "Incompleto"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "Deshacer" msgstr "Deshacer"
@@ -12298,6 +12315,10 @@ msgstr "Desvincular"
msgid "Unpin" msgid "Unpin"
msgstr "Desanclar" msgstr "Desanclar"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12317,9 +12338,6 @@ msgstr "Grupo sin título"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12342,6 +12360,7 @@ msgstr "Actualizar banner"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "Actualizar facturación" msgstr "Actualizar facturación"
@@ -12369,10 +12388,6 @@ msgstr "Actualizar correo electrónico"
msgid "Update Fields" msgid "Update Fields"
msgstr "Actualizar Campos" msgstr "Actualizar Campos"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "Actualizar organización"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12408,10 +12423,6 @@ msgstr "Actualizar rol"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "Actualizar reclamo de suscripción" msgstr "Actualizar reclamo de suscripción"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "Actualizar equipo"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12942,7 +12953,7 @@ msgstr "Esperando"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "Esperando a otros" msgstr "Esperando a otros"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "Esperando a que otros completen la firma." msgstr "Esperando a que otros completen la firma."
@@ -13916,8 +13927,7 @@ msgstr "Te han invitado a unirte a {0} en Documenso"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "Has sido invitado a unirte a la siguiente organización" msgstr "Has sido invitado a unirte a la siguiente organización"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "Te han eliminado de un documento" msgstr "Te han eliminado de un documento"
@@ -14037,6 +14047,10 @@ msgstr "Has revocado el acceso con éxito."
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "Usted tiene el derecho de retirar su consentimiento para usar firmas electrónicas en cualquier momento antes de completar el proceso de firma. Para retirar su consentimiento, comuníquese con el remitente del documento. Si no se comunica con el remitente, puede comunicarse con <0>{SUPPORT_EMAIL}</0> para obtener asistencia. Tenga en cuenta que retirar el consentimiento puede retrasar o detener la finalización de la transacción o servicio relacionado." msgstr "Usted tiene el derecho de retirar su consentimiento para usar firmas electrónicas en cualquier momento antes de completar el proceso de firma. Para retirar su consentimiento, comuníquese con el remitente del documento. Si no se comunica con el remitente, puede comunicarse con <0>{SUPPORT_EMAIL}</0> para obtener asistencia. Tenga en cuenta que retirar el consentimiento puede retrasar o detener la finalización de la transacción o servicio relacionado."
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "Has actualizado a {memberName}." msgstr "Has actualizado a {memberName}."
@@ -14716,4 +14730,3 @@ msgstr "Su código de verificación:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com" msgid "your-domain.com another-domain.com"
msgstr "su-dominio.com otro-dominio.com" msgstr "su-dominio.com otro-dominio.com"
+32 -19
View File
@@ -2441,6 +2441,7 @@ msgstr "Logo de la marque"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "Préférences de branding" msgstr "Préférences de branding"
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
msgstr "Actuellement, tous les membres de l'organisation peuvent accéder à cette équipe" msgstr "Actuellement, tous les membres de l'organisation peuvent accéder à cette équipe"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "Actuellement, la personnalisation de la marque ne peut être configurée que pour les plans Équipe et plus." msgstr "Actuellement, la personnalisation de la marque ne peut être configurée que pour les plans Équipe et plus."
@@ -4214,8 +4216,8 @@ msgstr "Document annulé"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "Document Annulé" msgstr "Document Annulé"
@@ -7942,6 +7944,11 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "Sinon, le document sera créé sous forme de brouillon." msgstr "Sinon, le document sera créé sous forme de brouillon."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8424,7 +8431,7 @@ msgstr "Veuillez réessayer ou contacter notre support."
#. placeholder {0}: `'${t(deleteMessage)}'` #. placeholder {0}: `'${t(deleteMessage)}'`
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please type {0} to confirm" msgid "Please type {0} to confirm"
msgstr "Veuiillez taper {0} pour confirmer" msgstr "Veuillez taper {0} pour confirmer"
#. placeholder {0}: user.email #. placeholder {0}: user.email
#: apps/remix/app/components/dialogs/account-delete-dialog.tsx #: apps/remix/app/components/dialogs/account-delete-dialog.tsx
@@ -8917,6 +8924,11 @@ msgstr "URL de redirection"
msgid "Redirecting" msgid "Redirecting"
msgstr "Redirection" msgstr "Redirection"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9192,9 +9204,7 @@ msgstr "Renvoyer lenveloppe"
msgid "Resend verification" msgid "Resend verification"
msgstr "Renvoyer la vérification" msgstr "Renvoyer la vérification"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "Réinitialiser" msgstr "Réinitialiser"
@@ -9379,6 +9389,7 @@ msgid "Save as Template"
msgstr "Enregistrer comme modèle" msgstr "Enregistrer comme modèle"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "Enregistrer les modifications" msgstr "Enregistrer les modifications"
@@ -10219,6 +10230,11 @@ msgstr "Paramètres du site"
msgid "Skip" msgid "Skip"
msgstr "Ignorer" msgstr "Ignorer"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "Certains signataires n'ont pas été assignés à un champ de signature. Veuillez assigner au moins 1 champ de signature à chaque signataire avant de continuer." msgstr "Certains signataires n'ont pas été assignés à un champ de signature. Veuillez assigner au moins 1 champ de signature à chaque signataire avant de continuer."
@@ -12249,6 +12265,7 @@ msgstr "Non autorisé"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "Non complet" msgstr "Non complet"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "Annuler" msgstr "Annuler"
@@ -12298,6 +12315,10 @@ msgstr "Délier"
msgid "Unpin" msgid "Unpin"
msgstr "Détacher" msgstr "Détacher"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12317,9 +12338,6 @@ msgstr "Groupe sans titre"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12342,6 +12360,7 @@ msgstr "Mettre à jour la bannière"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "Mettre à jour la facturation" msgstr "Mettre à jour la facturation"
@@ -12369,10 +12388,6 @@ msgstr "Mettre à jour l'e-mail"
msgid "Update Fields" msgid "Update Fields"
msgstr "Mettre à jour les champs" msgstr "Mettre à jour les champs"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "Mettre à jour l'organisation"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12408,10 +12423,6 @@ msgstr "Mettre à jour le rôle"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "Mettre à jour la réclamation d'abonnement" msgstr "Mettre à jour la réclamation d'abonnement"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "Mettre à jour l'équipe"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12942,7 +12953,7 @@ msgstr "En attente"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "En attente des autres" msgstr "En attente des autres"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "En attente que d'autres terminent la signature." msgstr "En attente que d'autres terminent la signature."
@@ -13916,8 +13927,7 @@ msgstr "Vous avez été invité à rejoindre {0} sur Documenso"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "Vous avez été invité à rejoindre l'organisation suivante" msgstr "Vous avez été invité à rejoindre l'organisation suivante"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "Vous avez été supprimé d'un document" msgstr "Vous avez été supprimé d'un document"
@@ -14037,6 +14047,10 @@ msgstr "Vous avez révoqué l'accès avec succès."
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "Vous avez le droit de retirer votre consentement à l'utilisation des signatures électroniques à tout moment avant de terminer le processus de signature. Pour retirer votre consentement, veuillez contacter l'expéditeur du document. Si vous ne contactez pas l'expéditeur, vous pouvez contacter <0>{SUPPORT_EMAIL}</0> pour obtenir de l'aide. Sachez que le retrait de consentement peut retarder ou arrêter l'achèvement de la transaction ou du service associé." msgstr "Vous avez le droit de retirer votre consentement à l'utilisation des signatures électroniques à tout moment avant de terminer le processus de signature. Pour retirer votre consentement, veuillez contacter l'expéditeur du document. Si vous ne contactez pas l'expéditeur, vous pouvez contacter <0>{SUPPORT_EMAIL}</0> pour obtenir de l'aide. Sachez que le retrait de consentement peut retarder ou arrêter l'achèvement de la transaction ou du service associé."
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "Vous avez mis à jour {memberName}." msgstr "Vous avez mis à jour {memberName}."
@@ -14716,4 +14730,3 @@ msgstr "Votre code de vérification :"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com" msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com"
+31 -18
View File
@@ -2441,6 +2441,7 @@ msgstr "Logo del Marchio"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "Preferenze per il branding" msgstr "Preferenze per il branding"
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
msgstr "Attualmente tutti i membri dell'organizzazione possono accedere a questo team" msgstr "Attualmente tutti i membri dell'organizzazione possono accedere a questo team"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "Attualmente il marchio può essere configurato solo per i piani Team e superiori." msgstr "Attualmente il marchio può essere configurato solo per i piani Team e superiori."
@@ -4214,8 +4216,8 @@ msgstr "Documento annullato"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "Documento Annullato" msgstr "Documento Annullato"
@@ -7942,6 +7944,11 @@ msgstr "Originale"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "Altrimenti, il documento sarà creato come bozza." msgstr "Altrimenti, il documento sarà creato come bozza."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8917,6 +8924,11 @@ msgstr "URL di reindirizzamento"
msgid "Redirecting" msgid "Redirecting"
msgstr "Reindirizzamento" msgstr "Reindirizzamento"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9192,9 +9204,7 @@ msgstr "Invia nuovamente busta"
msgid "Resend verification" msgid "Resend verification"
msgstr "Reinvia verifica" msgstr "Reinvia verifica"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "Ripristina" msgstr "Ripristina"
@@ -9379,6 +9389,7 @@ msgid "Save as Template"
msgstr "Salva come modello" msgstr "Salva come modello"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "Salva le modifiche" msgstr "Salva le modifiche"
@@ -10219,6 +10230,11 @@ msgstr "Impostazioni del sito"
msgid "Skip" msgid "Skip"
msgstr "Salta" msgstr "Salta"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "Alcuni firmatari non hanno un campo firma assegnato. Assegna almeno 1 campo di firma a ciascun firmatario prima di procedere." msgstr "Alcuni firmatari non hanno un campo firma assegnato. Assegna almeno 1 campo di firma a ciascun firmatario prima di procedere."
@@ -12249,6 +12265,7 @@ msgstr "Non autorizzato"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "Incompleto" msgstr "Incompleto"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "Annulla" msgstr "Annulla"
@@ -12298,6 +12315,10 @@ msgstr "Scollega"
msgid "Unpin" msgid "Unpin"
msgstr "Rimuovi" msgstr "Rimuovi"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12317,9 +12338,6 @@ msgstr "Gruppo senza nome"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12342,6 +12360,7 @@ msgstr "Aggiorna banner"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "Aggiorna fatturazione" msgstr "Aggiorna fatturazione"
@@ -12369,10 +12388,6 @@ msgstr "Aggiorna email"
msgid "Update Fields" msgid "Update Fields"
msgstr "Aggiorna campi" msgstr "Aggiorna campi"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "Aggiorna organizzazione"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12408,10 +12423,6 @@ msgstr "Aggiorna ruolo"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "Aggiorna reclamo di sottoscrizione" msgstr "Aggiorna reclamo di sottoscrizione"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "Aggiorna team"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12942,7 +12953,7 @@ msgstr "In attesa"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "In attesa di altri" msgstr "In attesa di altri"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "In attesa che altri completino la firma." msgstr "In attesa che altri completino la firma."
@@ -13916,8 +13927,7 @@ msgstr "Sei stato invitato a unirti a {0} su Documenso"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "Sei stato invitato a unirti alla seguente organizzazione" msgstr "Sei stato invitato a unirti alla seguente organizzazione"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "Sei stato rimosso da un documento" msgstr "Sei stato rimosso da un documento"
@@ -14037,6 +14047,10 @@ msgstr "Hai revocato con successo l'accesso."
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "Hai il diritto di ritirare il tuo consenso all'uso delle firme elettroniche in qualsiasi momento prima di completare il processo di firma. Per ritirare il tuo consenso, contatta il mittente del documento. Nel caso in cui non riesci a contattare il mittente, puoi contattare <0>{SUPPORT_EMAIL}</0> per assistenza. Sii consapevole che il ritiro del consenso potrebbe ritardare o fermare il completamento della transazione o del servizio correlato." msgstr "Hai il diritto di ritirare il tuo consenso all'uso delle firme elettroniche in qualsiasi momento prima di completare il processo di firma. Per ritirare il tuo consenso, contatta il mittente del documento. Nel caso in cui non riesci a contattare il mittente, puoi contattare <0>{SUPPORT_EMAIL}</0> per assistenza. Sii consapevole che il ritiro del consenso potrebbe ritardare o fermare il completamento della transazione o del servizio correlato."
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "Hai aggiornato {memberName}." msgstr "Hai aggiornato {memberName}."
@@ -14716,4 +14730,3 @@ msgstr "Il tuo codice di verifica:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com" msgid "your-domain.com another-domain.com"
msgstr "tuo-dominio.com altro-dominio.com" msgstr "tuo-dominio.com altro-dominio.com"
+31 -18
View File
@@ -2441,6 +2441,7 @@ msgstr "ブランディングロゴ"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "ブランディング設定" msgstr "ブランディング設定"
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
msgstr "現在、すべての組織メンバーがこのチームにアクセスできます" msgstr "現在、すべての組織メンバーがこのチームにアクセスできます"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "現在、ブランディングは Teams プラン以上のみ設定できます。" msgstr "現在、ブランディングは Teams プラン以上のみ設定できます。"
@@ -4214,8 +4216,8 @@ msgstr "文書は取り消されました"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "文書はキャンセルされました" msgstr "文書はキャンセルされました"
@@ -7942,6 +7944,11 @@ msgstr "オリジナル"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "チェックを入れない場合、文書は下書きとして作成されます。" msgstr "チェックを入れない場合、文書は下書きとして作成されます。"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8917,6 +8924,11 @@ msgstr "リダイレクト URL"
msgid "Redirecting" msgid "Redirecting"
msgstr "リダイレクト中" msgstr "リダイレクト中"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9192,9 +9204,7 @@ msgstr "封筒を再送信"
msgid "Resend verification" msgid "Resend verification"
msgstr "認証を再送" msgstr "認証を再送"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "リセット" msgstr "リセット"
@@ -9379,6 +9389,7 @@ msgid "Save as Template"
msgstr "テンプレートとして保存" msgstr "テンプレートとして保存"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "変更を保存" msgstr "変更を保存"
@@ -10219,6 +10230,11 @@ msgstr "サイト設定"
msgid "Skip" msgid "Skip"
msgstr "スキップ" msgstr "スキップ"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "一部の署名者に署名フィールドが割り当てられていません。続行する前に、各署名者に少なくとも 1 つの署名フィールドを割り当ててください。" msgstr "一部の署名者に署名フィールドが割り当てられていません。続行する前に、各署名者に少なくとも 1 つの署名フィールドを割り当ててください。"
@@ -12249,6 +12265,7 @@ msgstr "権限がありません"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "未完了" msgstr "未完了"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "元に戻す" msgstr "元に戻す"
@@ -12298,6 +12315,10 @@ msgstr "リンク解除"
msgid "Unpin" msgid "Unpin"
msgstr "ピン留めを解除" msgstr "ピン留めを解除"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12317,9 +12338,6 @@ msgstr "無題のグループ"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12342,6 +12360,7 @@ msgstr "バナーを更新"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "請求情報を更新" msgstr "請求情報を更新"
@@ -12369,10 +12388,6 @@ msgstr "メールを更新"
msgid "Update Fields" msgid "Update Fields"
msgstr "フィールドを更新" msgstr "フィールドを更新"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "組織を更新"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12408,10 +12423,6 @@ msgstr "役割を更新"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "サブスクリプションクレームを更新" msgstr "サブスクリプションクレームを更新"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "チームを更新"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12942,7 +12953,7 @@ msgstr "保留中"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "他の人の完了待ち" msgstr "他の人の完了待ち"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "他の署名者による署名完了を待っています。" msgstr "他の署名者による署名完了を待っています。"
@@ -13916,8 +13927,7 @@ msgstr "Documenso で {0} に参加するよう招待されています"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "次の組織に参加するよう招待されています。" msgstr "次の組織に参加するよう招待されています。"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "ドキュメントから削除されました" msgstr "ドキュメントから削除されました"
@@ -14037,6 +14047,10 @@ msgstr "アクセスを正常に取り消しました。"
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "署名プロセスを完了する前であれば、電子署名の利用に対する同意をいつでも撤回する権利があります。同意を撤回するには、文書の送信者に連絡してください。送信者に連絡できない場合は、<0>{SUPPORT_EMAIL}</0> までお問い合わせください。同意を撤回すると、関連する取引やサービスの完了が遅延または中止される可能性がある点にご注意ください。" msgstr "署名プロセスを完了する前であれば、電子署名の利用に対する同意をいつでも撤回する権利があります。同意を撤回するには、文書の送信者に連絡してください。送信者に連絡できない場合は、<0>{SUPPORT_EMAIL}</0> までお問い合わせください。同意を撤回すると、関連する取引やサービスの完了が遅延または中止される可能性がある点にご注意ください。"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "{memberName} を更新しました。" msgstr "{memberName} を更新しました。"
@@ -14716,4 +14730,3 @@ msgstr "認証コード:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com" msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com"
+31 -18
View File
@@ -2441,6 +2441,7 @@ msgstr "브랜딩 로고"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "브랜딩 환경설정" msgstr "브랜딩 환경설정"
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
msgstr "현재 모든 조직 구성원이 이 팀에 접근할 수 있습니다." msgstr "현재 모든 조직 구성원이 이 팀에 접근할 수 있습니다."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "브랜딩은 현재 Teams 요금제 이상에서만 구성할 수 있습니다." msgstr "브랜딩은 현재 Teams 요금제 이상에서만 구성할 수 있습니다."
@@ -4214,8 +4216,8 @@ msgstr "문서가 취소되었습니다"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "문서가 취소됨" msgstr "문서가 취소됨"
@@ -7942,6 +7944,11 @@ msgstr "원본"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "그렇지 않으면 문서는 초안으로 생성됩니다." msgstr "그렇지 않으면 문서는 초안으로 생성됩니다."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8917,6 +8924,11 @@ msgstr "리디렉션 URL"
msgid "Redirecting" msgid "Redirecting"
msgstr "리디렉션 중" msgstr "리디렉션 중"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9192,9 +9204,7 @@ msgstr "봉투 다시 보내기"
msgid "Resend verification" msgid "Resend verification"
msgstr "인증 다시 보내기" msgstr "인증 다시 보내기"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "초기화" msgstr "초기화"
@@ -9379,6 +9389,7 @@ msgid "Save as Template"
msgstr "템플릿으로 저장" msgstr "템플릿으로 저장"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "변경 사항 저장" msgstr "변경 사항 저장"
@@ -10219,6 +10230,11 @@ msgstr "사이트 설정"
msgid "Skip" msgid "Skip"
msgstr "건너뛰기" msgstr "건너뛰기"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "일부 서명자에게 서명 필드가 할당되지 않았습니다. 진행하기 전에 각 서명자에게 최소 1개 이상의 서명 필드를 할당해 주세요." msgstr "일부 서명자에게 서명 필드가 할당되지 않았습니다. 진행하기 전에 각 서명자에게 최소 1개 이상의 서명 필드를 할당해 주세요."
@@ -12249,6 +12265,7 @@ msgstr "권한이 없습니다"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "미완료" msgstr "미완료"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "실행 취소" msgstr "실행 취소"
@@ -12298,6 +12315,10 @@ msgstr "연결 해제"
msgid "Unpin" msgid "Unpin"
msgstr "고정 해제" msgstr "고정 해제"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12317,9 +12338,6 @@ msgstr "제목 없는 그룹"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12342,6 +12360,7 @@ msgstr "배너 업데이트"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "결제 정보 업데이트" msgstr "결제 정보 업데이트"
@@ -12369,10 +12388,6 @@ msgstr "이메일 업데이트"
msgid "Update Fields" msgid "Update Fields"
msgstr "필드 업데이트" msgstr "필드 업데이트"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "조직 업데이트"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12408,10 +12423,6 @@ msgstr "역할 업데이트"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "구독 클레임 업데이트" msgstr "구독 클레임 업데이트"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "팀 업데이트"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12942,7 +12953,7 @@ msgstr "대기 중"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "다른 사람을 기다리는 중" msgstr "다른 사람을 기다리는 중"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "다른 서명자들이 이 문서에 서명 완료하기를 기다리는 중입니다." msgstr "다른 서명자들이 이 문서에 서명 완료하기를 기다리는 중입니다."
@@ -13916,8 +13927,7 @@ msgstr "Documenso에서 {0} 조직에 초대되었습니다."
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "다음 조직에 참여하라는 초대를 받았습니다." msgstr "다음 조직에 참여하라는 초대를 받았습니다."
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "문서에서 제거되었습니다." msgstr "문서에서 제거되었습니다."
@@ -14037,6 +14047,10 @@ msgstr "접근 권한을 성공적으로 철회했습니다."
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "전자 서명을 완료하기 전 언제든지 전자 서명 사용에 대한 동의를 철회할 권리가 있습니다. 동의를 철회하려면 문서 발송자에게 문의해 주세요. 발송자에게 연락할 수 없는 경우 <0>{SUPPORT_EMAIL}</0>로 도움을 요청해 주세요. 동의를 철회하면 관련 거래나 서비스가 지연되거나 중단될 수 있습니다." msgstr "전자 서명을 완료하기 전 언제든지 전자 서명 사용에 대한 동의를 철회할 권리가 있습니다. 동의를 철회하려면 문서 발송자에게 문의해 주세요. 발송자에게 연락할 수 없는 경우 <0>{SUPPORT_EMAIL}</0>로 도움을 요청해 주세요. 동의를 철회하면 관련 거래나 서비스가 지연되거나 중단될 수 있습니다."
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "{memberName}을(를) 업데이트했습니다." msgstr "{memberName}을(를) 업데이트했습니다."
@@ -14716,4 +14730,3 @@ msgstr "인증 코드:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com" msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com"
+31 -18
View File
@@ -2441,6 +2441,7 @@ msgstr "Branding-logo"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "Brandingvoorkeuren" msgstr "Brandingvoorkeuren"
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
msgstr "Momenteel hebben alle organisatieleden toegang tot dit team" msgstr "Momenteel hebben alle organisatieleden toegang tot dit team"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "Branding kan momenteel alleen worden geconfigureerd voor Teams- en hogere abonnementen." msgstr "Branding kan momenteel alleen worden geconfigureerd voor Teams- en hogere abonnementen."
@@ -4214,8 +4216,8 @@ msgstr "Document geannuleerd"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "Document geannuleerd" msgstr "Document geannuleerd"
@@ -7942,6 +7944,11 @@ msgstr "Origineel"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "Anders wordt het document als concept aangemaakt." msgstr "Anders wordt het document als concept aangemaakt."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8917,6 +8924,11 @@ msgstr "Redirect-URL"
msgid "Redirecting" msgid "Redirecting"
msgstr "Doorsturen" msgstr "Doorsturen"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9192,9 +9204,7 @@ msgstr "Envelope opnieuw verzenden"
msgid "Resend verification" msgid "Resend verification"
msgstr "Verificatie opnieuw verzenden" msgstr "Verificatie opnieuw verzenden"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "Resetten" msgstr "Resetten"
@@ -9379,6 +9389,7 @@ msgid "Save as Template"
msgstr "Opslaan als sjabloon" msgstr "Opslaan als sjabloon"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "Wijzigingen opslaan" msgstr "Wijzigingen opslaan"
@@ -10219,6 +10230,11 @@ msgstr "Siteinstellingen"
msgid "Skip" msgid "Skip"
msgstr "Overslaan" msgstr "Overslaan"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "Sommige ondertekenaars hebben geen handtekeningveld toegewezen gekregen. Wijs ten minste 1 handtekeningveld toe aan elke ondertekenaar voordat je doorgaat." msgstr "Sommige ondertekenaars hebben geen handtekeningveld toegewezen gekregen. Wijs ten minste 1 handtekeningveld toe aan elke ondertekenaar voordat je doorgaat."
@@ -12249,6 +12265,7 @@ msgstr "Niet gemachtigd"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "Onvoltooid" msgstr "Onvoltooid"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "Ongedaan maken" msgstr "Ongedaan maken"
@@ -12298,6 +12315,10 @@ msgstr "Ontkoppelen"
msgid "Unpin" msgid "Unpin"
msgstr "Losmaken" msgstr "Losmaken"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12317,9 +12338,6 @@ msgstr "Naamloze groep"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12342,6 +12360,7 @@ msgstr "Banner bijwerken"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "Facturering bijwerken" msgstr "Facturering bijwerken"
@@ -12369,10 +12388,6 @@ msgstr "E-mail bijwerken"
msgid "Update Fields" msgid "Update Fields"
msgstr "Velden bijwerken" msgstr "Velden bijwerken"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "Organisatie bijwerken"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12408,10 +12423,6 @@ msgstr "Rol bijwerken"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "Abonnementsclaim bijwerken" msgstr "Abonnementsclaim bijwerken"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "Team bijwerken"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12942,7 +12953,7 @@ msgstr "Wachten"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "Wachten op anderen" msgstr "Wachten op anderen"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "Wachten tot anderen het ondertekenen hebben voltooid." msgstr "Wachten tot anderen het ondertekenen hebben voltooid."
@@ -13916,8 +13927,7 @@ msgstr "Je bent uitgenodigd om {0} op Documenso te joinen"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "Je bent uitgenodigd om lid te worden van de volgende organisatie" msgstr "Je bent uitgenodigd om lid te worden van de volgende organisatie"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "Je bent verwijderd uit een document" msgstr "Je bent verwijderd uit een document"
@@ -14037,6 +14047,10 @@ msgstr "Je hebt de toegang succesvol ingetrokken."
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "Je hebt het recht je toestemming voor het gebruik van elektronische handtekeningen op elk moment vóór voltooiing van het ondertekeningsproces in te trekken. Neem hiervoor contact op met de verzender van het document. Als dat niet lukt, kun je contact opnemen met <0>{SUPPORT_EMAIL}</0> voor hulp. Houd er rekening mee dat het intrekken van toestemming de voltooiing van de betreffende transactie of dienst kan vertragen of stopzetten." msgstr "Je hebt het recht je toestemming voor het gebruik van elektronische handtekeningen op elk moment vóór voltooiing van het ondertekeningsproces in te trekken. Neem hiervoor contact op met de verzender van het document. Als dat niet lukt, kun je contact opnemen met <0>{SUPPORT_EMAIL}</0> voor hulp. Houd er rekening mee dat het intrekken van toestemming de voltooiing van de betreffende transactie of dienst kan vertragen of stopzetten."
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "Je hebt {memberName} bijgewerkt." msgstr "Je hebt {memberName} bijgewerkt."
@@ -14716,4 +14730,3 @@ msgstr "Uw verificatiecode:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com" msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com"
+31 -19
View File
@@ -2441,6 +2441,7 @@ msgstr "Logo marki"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "Ustawienia brandingu" msgstr "Ustawienia brandingu"
@@ -2501,7 +2502,6 @@ msgstr "Akceptując prośbę, przyznasz zespołowi {0} następujące uprawnienia
#: packages/email/templates/confirm-team-email.tsx #: packages/email/templates/confirm-team-email.tsx
msgid "By accepting this request, you will be granting <0>{teamName}</0> access to:" msgid "By accepting this request, you will be granting <0>{teamName}</0> access to:"
msgstr "Akceptując prośbę, umożliwisz zespołowi <0>{teamName}</0> na:"
msgstr "Akceptując prośbę, umożliwisz zespołowi <0>{teamName}</0>:" msgstr "Akceptując prośbę, umożliwisz zespołowi <0>{teamName}</0>:"
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
@@ -3573,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
msgstr "Obecnie wszyscy użytkownicy organizacji mogą uzyskać dostęp tego zespołu" msgstr "Obecnie wszyscy użytkownicy organizacji mogą uzyskać dostęp tego zespołu"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "Branding możesz skonfigurować tylko w planie Teams i wyższym." msgstr "Branding możesz skonfigurować tylko w planie Teams i wyższym."
@@ -4215,8 +4216,8 @@ msgstr "Anulowano dokument"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "Dokument został anulowany" msgstr "Dokument został anulowany"
@@ -7943,6 +7944,11 @@ msgstr "Oryginalny"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "W przeciwnym razie dokument zostanie utworzony jako wersja robocza." msgstr "W przeciwnym razie dokument zostanie utworzony jako wersja robocza."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8918,6 +8924,11 @@ msgstr "Adres URL przekierowania"
msgid "Redirecting" msgid "Redirecting"
msgstr "Przekierowywanie" msgstr "Przekierowywanie"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9193,9 +9204,7 @@ msgstr "Wyślij ponownie kopertę"
msgid "Resend verification" msgid "Resend verification"
msgstr "Wyślij ponownie wiadomość weryfikacyjną" msgstr "Wyślij ponownie wiadomość weryfikacyjną"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "Resetuj" msgstr "Resetuj"
@@ -9380,6 +9389,7 @@ msgid "Save as Template"
msgstr "Zapisz jako szablon" msgstr "Zapisz jako szablon"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "Zapisz zmiany" msgstr "Zapisz zmiany"
@@ -10220,6 +10230,11 @@ msgstr "Ustawienia strony"
msgid "Skip" msgid "Skip"
msgstr "Pomiń" msgstr "Pomiń"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "Niektórym podpisującym nie przypisano pola podpisu. Przypisz co najmniej jedno pole podpisu do każdego podpisującego." msgstr "Niektórym podpisującym nie przypisano pola podpisu. Przypisz co najmniej jedno pole podpisu do każdego podpisującego."
@@ -12250,6 +12265,7 @@ msgstr "Nieautoryzowany"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "Niezakończono" msgstr "Niezakończono"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "Cofnij" msgstr "Cofnij"
@@ -12299,6 +12315,10 @@ msgstr "Rozłącz"
msgid "Unpin" msgid "Unpin"
msgstr "Odepnij" msgstr "Odepnij"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12318,9 +12338,6 @@ msgstr "Grupa bez nazwy"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12343,6 +12360,7 @@ msgstr "Zaktualizuj baner"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "Zaktualizuj płatności" msgstr "Zaktualizuj płatności"
@@ -12370,10 +12388,6 @@ msgstr "Zaktualizuj adres e-mail"
msgid "Update Fields" msgid "Update Fields"
msgstr "Zaktualizuj pola" msgstr "Zaktualizuj pola"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "Zaktualizuj organizację"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12409,10 +12423,6 @@ msgstr "Zaktualizuj rolę"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "Zaktualizuj subskrypcję" msgstr "Zaktualizuj subskrypcję"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "Zaktualizuj zespół"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12943,7 +12953,7 @@ msgstr "Oczekiwanie"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "Oczekiwanie na innych" msgstr "Oczekiwanie na innych"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "Oczekiwanie na zakończenie podpisywania przez innych." msgstr "Oczekiwanie na zakończenie podpisywania przez innych."
@@ -13917,8 +13927,7 @@ msgstr "Dołącz do organizacji {0} w Documenso"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "Masz zaproszenie do dołączenia do następującej organizacji" msgstr "Masz zaproszenie do dołączenia do następującej organizacji"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "Usunięto Cię z dokumentu" msgstr "Usunięto Cię z dokumentu"
@@ -14038,6 +14047,10 @@ msgstr "Dostęp został unieważniony."
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "Masz prawo wycofać swoją zgodę na używanie podpisów elektronicznych w dowolnym momencie przed zakończeniem procesu podpisywania. Aby wycofać zgodę, skontaktuj się z nadawcą dokumentu. Jeśli nie możesz skontaktować się z nadawcą, napisz do nas na adres <0>{SUPPORT_EMAIL}</0>. Pamiętaj, że wycofanie zgody może opóźnić lub wstrzymać realizację danej transakcji lub usługi." msgstr "Masz prawo wycofać swoją zgodę na używanie podpisów elektronicznych w dowolnym momencie przed zakończeniem procesu podpisywania. Aby wycofać zgodę, skontaktuj się z nadawcą dokumentu. Jeśli nie możesz skontaktować się z nadawcą, napisz do nas na adres <0>{SUPPORT_EMAIL}</0>. Pamiętaj, że wycofanie zgody może opóźnić lub wstrzymać realizację danej transakcji lub usługi."
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "Użytkownik {memberName} został zaktualizowany." msgstr "Użytkownik {memberName} został zaktualizowany."
@@ -14717,4 +14730,3 @@ msgstr "Twój kod weryfikacyjny:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com" msgid "your-domain.com another-domain.com"
msgstr "twoja-domena.pl inna-domena.pl" msgstr "twoja-domena.pl inna-domena.pl"
+52 -17
View File
@@ -2436,6 +2436,7 @@ msgstr "Logo da Marca"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "Preferências da Marca" msgstr "Preferências da Marca"
@@ -3567,6 +3568,7 @@ msgid "Currently all organisation members can access this team"
msgstr "Atualmente, todos os membros da organização podem acessar esta equipe" msgstr "Atualmente, todos os membros da organização podem acessar esta equipe"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "Atualmente, a marca só pode ser configurada para planos Teams e superiores." msgstr "Atualmente, a marca só pode ser configurada para planos Teams e superiores."
@@ -4209,8 +4211,8 @@ msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "Documento Cancelado" msgstr "Documento Cancelado"
@@ -7937,6 +7939,11 @@ msgstr "Original"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "Caso contrário, o documento será criado como um rascunho." msgstr "Caso contrário, o documento será criado como um rascunho."
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8772,6 +8779,10 @@ msgstr ""
msgid "Recipient rejected the document" msgid "Recipient rejected the document"
msgstr "" msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient rejected the document externally"
msgstr ""
#: apps/remix/app/components/general/admin-global-settings-section.tsx #: apps/remix/app/components/general/admin-global-settings-section.tsx
msgid "Recipient removed" msgid "Recipient removed"
msgstr "" msgstr ""
@@ -8908,6 +8919,11 @@ msgstr "URL de Redirecionamento"
msgid "Redirecting" msgid "Redirecting"
msgstr "Redirecionando" msgstr "Redirecionando"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9183,9 +9199,7 @@ msgstr ""
msgid "Resend verification" msgid "Resend verification"
msgstr "Reenviar verificação" msgstr "Reenviar verificação"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "Redefinir" msgstr "Redefinir"
@@ -9370,6 +9384,7 @@ msgid "Save as Template"
msgstr "" msgstr ""
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "" msgstr ""
@@ -10210,6 +10225,11 @@ msgstr "Configurações do Site"
msgid "Skip" msgid "Skip"
msgstr "Pular" msgstr "Pular"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "Alguns signatários não receberam um campo de assinatura. Por favor, atribua pelo menos 1 campo de assinatura a cada signatário antes de prosseguir." msgstr "Alguns signatários não receberam um campo de assinatura. Por favor, atribua pelo menos 1 campo de assinatura a cada signatário antes de prosseguir."
@@ -11088,6 +11108,23 @@ msgstr ""
msgid "The document was created but could not be sent to recipients." msgid "The document was created but could not be sent to recipients."
msgstr "O documento foi criado, mas não pôde ser enviado aos destinatários." msgstr "O documento foi criado, mas não pôde ser enviado aos destinatários."
#: packages/lib/utils/document-audit-logs.ts
msgid "The document was rejected externally by {onBehalfOf} on behalf of {user}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
#: packages/lib/utils/document-audit-logs.ts
msgid "The document was rejected externally by {onBehalfOf} on behalf of the recipient"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "The document was rejected externally on behalf of {user}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "The document was rejected externally on behalf of the recipient"
msgstr ""
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "The document will be hidden from your account" msgid "The document will be hidden from your account"
msgstr "O documento será ocultado da sua conta" msgstr "O documento será ocultado da sua conta"
@@ -12223,6 +12260,7 @@ msgstr "Não autorizado"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "Não concluído" msgstr "Não concluído"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "" msgstr ""
@@ -12272,6 +12310,10 @@ msgstr "Desvincular"
msgid "Unpin" msgid "Unpin"
msgstr "Desafixar" msgstr "Desafixar"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12291,9 +12333,6 @@ msgstr "Grupo sem título"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12316,6 +12355,7 @@ msgstr "Atualizar Banner"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "Atualizar Faturamento" msgstr "Atualizar Faturamento"
@@ -12343,10 +12383,6 @@ msgstr "Atualizar e-mail"
msgid "Update Fields" msgid "Update Fields"
msgstr "Atualizar Campos" msgstr "Atualizar Campos"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "Atualizar organização"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12382,10 +12418,6 @@ msgstr "Atualizar função"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "Atualizar Reivindicação de Assinatura" msgstr "Atualizar Reivindicação de Assinatura"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "Atualizar equipe"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12916,7 +12948,7 @@ msgstr "Aguardando"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "Aguardando outros" msgstr "Aguardando outros"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "Aguardando outros completarem a assinatura." msgstr "Aguardando outros completarem a assinatura."
@@ -13890,8 +13922,7 @@ msgstr "Você foi convidado para participar de {0} no Documenso"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "Você foi convidado para participar da seguinte organização" msgstr "Você foi convidado para participar da seguinte organização"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "Você foi removido de um documento" msgstr "Você foi removido de um documento"
@@ -14011,6 +14042,10 @@ msgstr "Você revogou o acesso com sucesso."
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "Você tem o direito de retirar seu consentimento para usar assinaturas eletrônicas a qualquer momento antes de concluir o processo de assinatura. Para retirar seu consentimento, entre em contato com o remetente do documento. Se não conseguir entrar em contato com o remetente, você pode entrar em contato com <0>{SUPPORT_EMAIL}</0> para obter assistência. Esteja ciente de que a retirada do consentimento pode atrasar ou interromper a conclusão da transação ou serviço relacionado." msgstr "Você tem o direito de retirar seu consentimento para usar assinaturas eletrônicas a qualquer momento antes de concluir o processo de assinatura. Para retirar seu consentimento, entre em contato com o remetente do documento. Se não conseguir entrar em contato com o remetente, você pode entrar em contato com <0>{SUPPORT_EMAIL}</0> para obter assistência. Esteja ciente de que a retirada do consentimento pode atrasar ou interromper a conclusão da transação ou serviço relacionado."
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "Você atualizou {memberName}." msgstr "Você atualizou {memberName}."
+31 -18
View File
@@ -2441,6 +2441,7 @@ msgstr "品牌 Logo"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Branding Preferences" msgid "Branding Preferences"
msgstr "品牌偏好设置" msgstr "品牌偏好设置"
@@ -3572,6 +3573,7 @@ msgid "Currently all organisation members can access this team"
msgstr "目前所有组织成员都可以访问此团队" msgstr "目前所有组织成员都可以访问此团队"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Currently branding can only be configured for Teams and above plans." msgid "Currently branding can only be configured for Teams and above plans."
msgstr "目前仅 Teams 及以上套餐可以配置品牌。" msgstr "目前仅 Teams 及以上套餐可以配置品牌。"
@@ -4214,8 +4216,8 @@ msgstr "文档已被取消"
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts
#: packages/lib/server-only/admin/admin-super-delete-document.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts
#: packages/lib/server-only/document/delete-document.ts
msgid "Document Cancelled" msgid "Document Cancelled"
msgstr "文档已取消" msgstr "文档已取消"
@@ -7942,6 +7944,11 @@ msgstr "原始"
msgid "Otherwise, the document will be created as a draft." msgid "Otherwise, the document will be created as a draft."
msgstr "否则将把文档创建为草稿。" msgstr "否则将把文档创建为草稿。"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Overlapping fields detected"
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx
msgid "Override organisation settings" msgid "Override organisation settings"
@@ -8917,6 +8924,11 @@ msgstr "重定向 URL"
msgid "Redirecting" msgid "Redirecting"
msgstr "正在重定向" msgstr "正在重定向"
#. placeholder {0}: oidcProviderLabel || 'OIDC'
#: apps/remix/app/routes/_unauthenticated+/signin.tsx
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx #: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful" msgid "Registration Successful"
@@ -9192,9 +9204,7 @@ msgstr "重新发送信封"
msgid "Resend verification" msgid "Resend verification"
msgstr "重新发送验证" msgstr "重新发送验证"
#: apps/remix/app/components/forms/organisation-update-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/forms/team-update-form.tsx
#: apps/remix/app/components/general/organisation-usage-reset-button.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx
msgid "Reset" msgid "Reset"
msgstr "重置" msgstr "重置"
@@ -9379,6 +9389,7 @@ msgid "Save as Template"
msgstr "另存为模板" msgstr "另存为模板"
#: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Save changes" msgid "Save changes"
msgstr "保存更改" msgstr "保存更改"
@@ -10219,6 +10230,11 @@ msgstr "站点设置"
msgid "Skip" msgid "Skip"
msgstr "跳过" msgstr "跳过"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx
msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding."
msgstr "部分签署人尚未被分配签名字段。请在继续前为每位签署人至少分配 1 个签名字段。" msgstr "部分签署人尚未被分配签名字段。请在继续前为每位签署人至少分配 1 个签名字段。"
@@ -12249,6 +12265,7 @@ msgstr "未授权"
msgid "Uncompleted" msgid "Uncompleted"
msgstr "未完成" msgstr "未完成"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
#: packages/ui/primitives/signature-pad/signature-pad-draw.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx
msgid "Undo" msgid "Undo"
msgstr "撤销" msgstr "撤销"
@@ -12298,6 +12315,10 @@ msgstr "取消关联"
msgid "Unpin" msgid "Unpin"
msgstr "取消固定" msgstr "取消固定"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "Unsaved changes"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx
msgid "Unsealed Documents" msgid "Unsealed Documents"
@@ -12317,9 +12338,6 @@ msgstr "未命名组"
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/email-preferences-form.tsx
#: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx
@@ -12342,6 +12360,7 @@ msgstr "更新横幅"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx
msgid "Update Billing" msgid "Update Billing"
msgstr "更新计费" msgstr "更新计费"
@@ -12369,10 +12388,6 @@ msgstr "更新邮箱"
msgid "Update Fields" msgid "Update Fields"
msgstr "更新字段" msgstr "更新字段"
#: apps/remix/app/components/forms/organisation-update-form.tsx
msgid "Update organisation"
msgstr "更新组织"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
@@ -12408,10 +12423,6 @@ msgstr "更新角色"
msgid "Update Subscription Claim" msgid "Update Subscription Claim"
msgstr "更新订阅声明" msgstr "更新订阅声明"
#: apps/remix/app/components/forms/team-update-form.tsx
msgid "Update team"
msgstr "更新团队"
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "Update team email" msgid "Update team email"
@@ -12942,7 +12953,7 @@ msgstr "等待中"
msgid "Waiting for others" msgid "Waiting for others"
msgstr "等待其他人" msgstr "等待其他人"
#: packages/lib/server-only/document/send-pending-email.ts #: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing." msgid "Waiting for others to complete signing."
msgstr "正在等待其他人完成签署。" msgstr "正在等待其他人完成签署。"
@@ -13916,8 +13927,7 @@ msgstr "您已被邀请加入 Documenso 上的 {0}"
msgid "You have been invited to join the following organisation" msgid "You have been invited to join the following organisation"
msgstr "您已被邀请加入以下组织" msgstr "您已被邀请加入以下组织"
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts #: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts
#: packages/lib/server-only/recipient/set-document-recipients.ts
msgid "You have been removed from a document" msgid "You have been removed from a document"
msgstr "您已被从某个文档中移除" msgstr "您已被从某个文档中移除"
@@ -14037,6 +14047,10 @@ msgstr "你已成功撤销访问权限。"
msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL}</0> for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service."
msgstr "在完成签署流程之前,你有权随时撤回对使用电子签名的同意。要撤回同意,请联系文档的发送方。如果无法联系发送方,你可以通过 <0>{SUPPORT_EMAIL}</0> 与我们联系以获得协助。请注意,撤回同意可能会延迟或中止相关交易或服务的完成。" msgstr "在完成签署流程之前,你有权随时撤回对使用电子签名的同意。要撤回同意,请联系文档的发送方。如果无法联系发送方,你可以通过 <0>{SUPPORT_EMAIL}</0> 与我们联系以获得协助。请注意,撤回同意可能会延迟或中止相关交易或服务的完成。"
#: apps/remix/app/components/forms/form-sticky-save-bar.tsx
msgid "You have unsaved changes"
msgstr ""
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
msgid "You have updated {memberName}." msgid "You have updated {memberName}."
msgstr "您已更新 {memberName}。" msgstr "您已更新 {memberName}。"
@@ -14716,4 +14730,3 @@ msgstr "您的验证码:"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "your-domain.com another-domain.com" msgid "your-domain.com another-domain.com"
msgstr "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com"
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { ZNameSchema } from './name';
describe('ZNameSchema', () => {
describe('valid names', () => {
it('accepts a normal name', () => {
expect(ZNameSchema.safeParse('Example User')).toEqual({
success: true,
data: 'Example User',
});
});
it('accepts international characters', () => {
expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({
success: true,
data: 'Døcumensø Üser',
});
});
it('trims surrounding whitespace', () => {
expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({
success: true,
data: 'Documenso User',
});
});
it('accepts names at the minimum length', () => {
expect(ZNameSchema.safeParse('DU')).toEqual({
success: true,
data: 'DU',
});
});
it('accepts names at the maximum length', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do';
expect(name.length).toBe(100);
expect(ZNameSchema.safeParse(name)).toEqual({
success: true,
data: name,
});
});
});
describe('length validation', () => {
it('rejects names shorter than 2 characters', () => {
expect(ZNameSchema.safeParse('D')).toMatchObject({
success: false,
error: {
issues: [{ message: 'Please enter a valid name.' }],
},
});
});
it('rejects names longer than 100 characters', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc';
expect(name.length).toBe(101);
expect(ZNameSchema.safeParse(name)).toMatchObject({
success: false,
error: {
issues: [{ message: 'Name cannot be more than 100 characters.' }],
},
});
});
it('rejects whitespace-only input after trim', () => {
expect(ZNameSchema.safeParse(' ')).toMatchObject({
success: false,
});
});
});
describe('URL validation', () => {
it.each([
'https://example.com',
'http://example.com',
'HTTPS://EXAMPLE.COM',
'Northwind www.example.com',
'www.example.com',
])('rejects URLs in names: %s', (value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]),
},
});
});
});
describe('invalid character validation', () => {
it.each([
['NUL character', 'Acme\u0000Corp'],
['zero-width space', 'Acme\u200bCorp'],
['bidi override', 'Acme\u202eCorp'],
['byte order mark', 'Acme\ufeffCorp'],
['lone surrogate', 'Acme\ud800Corp'],
['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`],
['noncharacter', 'Acme\ufffeCorp'],
['private use character', 'Acme\ue000Corp'],
['Hangul filler', 'Acme\u3164Corp'],
['braille blank', 'Acme\u2800Corp'],
['combining grapheme joiner', 'Acme\u034fCorp'],
])('rejects names containing a %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['fixed form', String.raw`Acme\u200bCorp`],
['uppercase U', String.raw`Acme\U200BCorp`],
['braced form', String.raw`Acme\u{200b}Corp`],
['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`],
['lone surrogate', String.raw`Acme\ud800Corp`],
])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['escape of a valid code point', String.raw`Acme\u0041Corp`],
['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`],
['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`],
['incomplete escape sequence', String.raw`Acme\u00 Corp`],
['unterminated braced escape', String.raw`Acme\u{200bCorp`],
['astral characters such as emoji', 'Acme 😀 Corp'],
['emoji with a variation selector', 'I ❤️ Docs'],
])('accepts %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: true,
});
});
});
});
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Characters that render as empty/invisible or break text layout:
*
* - `\p{C}` - control, format, lone surrogate, private use and
* unassigned code points (NUL, zero-width spaces, bidi
* overrides, BOM, tag characters, noncharacters).
* - `\p{Zl}\p{Zp}` - line and paragraph separators.
* - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the
* character class because it is a combining mark, which
* lint rules reject inside classes.
* - remaining - letters that render as blank (Hangul fillers, braille blank).
*
* The `\p{...}` classes are maintained by the Unicode database, so newly
* assigned characters in these categories are covered automatically.
*/
const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u;
const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value);
/**
* Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as
* text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still
* break rendering downstream if anything decodes them.
*/
const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi;
const hasInvalidEscapeSequence = (value: string) => {
for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) {
const codePoint = parseInt(fixedHex ?? bracedHex, 16);
if (codePoint > 0x10ffff) {
continue;
}
// Decode the escape and run it through the same character policy as the
// unescaped check, so the two can never drift apart.
if (hasInvalidCharacter(String.fromCodePoint(codePoint))) {
return true;
}
}
return false;
};
export const hasInvalidTextCharacters = (value: string) =>
hasInvalidCharacter(value) || hasInvalidEscapeSequence(value);
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering,
* and invisible/control characters that render as empty or break the UI.
*/
export const ZNameSchema = z
.string()
.trim()
.min(2, { message: 'Please enter a valid name.' })
.max(100, { message: 'Name cannot be more than 100 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
})
.refine((value) => !hasInvalidTextCharacters(value), {
message: 'Name contains invalid characters.',
});
export type TName = z.infer<typeof ZNameSchema>;
+32 -7
View File
@@ -6,14 +6,39 @@ import { z } from 'zod';
* *
* Example: "5m", "1h", "1d" * Example: "5m", "1h", "1d"
*/ */
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/); export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
export const ZRateLimitArraySchema = z.array( const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
z.object({ const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
window: ZRateLimitWindowSchema,
max: z.number().int().positive(), export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, {
}), message: RATE_LIMIT_WINDOW_ERROR_MESSAGE,
); });
export const ZRateLimitArraySchema = z
.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
)
.superRefine((entries, ctx) => {
const windows = new Set<string>();
entries.forEach((entry, index) => {
const window = entry.window.trim();
if (windows.has(window)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE,
path: [index, 'window'],
});
}
windows.add(window);
});
});
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>; export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
getQuotaUsagePercent,
getQuotaWarningCount,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from './quota-usage';
describe('isQuotaExceeded', () => {
it('treats null quota as unlimited (never exceeded)', () => {
expect(isQuotaExceeded(null, 0)).toBe(false);
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
});
it('treats a zero quota as blocked (always exceeded)', () => {
expect(isQuotaExceeded(0, 0)).toBe(true);
expect(isQuotaExceeded(0, 5)).toBe(true);
});
it('is exceeded once usage reaches the quota (>= boundary)', () => {
expect(isQuotaExceeded(10, 9)).toBe(false);
expect(isQuotaExceeded(10, 10)).toBe(true);
expect(isQuotaExceeded(10, 11)).toBe(true);
});
});
describe('getQuotaWarningCount', () => {
it('rounds the 80% threshold up', () => {
expect(getQuotaWarningCount(10)).toBe(8);
expect(getQuotaWarningCount(100)).toBe(80);
// 5 * 0.8 = 4 exactly.
expect(getQuotaWarningCount(5)).toBe(4);
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
expect(getQuotaWarningCount(3)).toBe(3);
});
});
describe('isQuotaNearing', () => {
it('is never nearing for unlimited or blocked quotas', () => {
expect(isQuotaNearing(null, 5)).toBe(false);
expect(isQuotaNearing(0, 5)).toBe(false);
});
it('is nearing from the warning threshold up to (but not including) the quota', () => {
expect(isQuotaNearing(10, 7)).toBe(false);
expect(isQuotaNearing(10, 8)).toBe(true);
expect(isQuotaNearing(10, 9)).toBe(true);
});
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
expect(isQuotaNearing(10, 10)).toBe(false);
expect(isQuotaNearing(10, 11)).toBe(false);
});
it('can never fire for tiny quotas where the warning count equals the quota', () => {
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
expect(isQuotaNearing(3, 2)).toBe(false);
expect(isQuotaNearing(3, 3)).toBe(false);
});
it('agrees with the warning-count helper at the boundary', () => {
const quota = 250;
const warningCount = getQuotaWarningCount(quota);
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
expect(isQuotaNearing(quota, warningCount)).toBe(true);
});
});
describe('getQuotaUsagePercent', () => {
it('returns 0 for unlimited or non-positive quotas', () => {
expect(getQuotaUsagePercent(5, null)).toBe(0);
expect(getQuotaUsagePercent(5, 0)).toBe(0);
expect(getQuotaUsagePercent(5, -10)).toBe(0);
});
it('rounds the percentage to the nearest integer', () => {
expect(getQuotaUsagePercent(1, 3)).toBe(33);
expect(getQuotaUsagePercent(2, 3)).toBe(67);
expect(getQuotaUsagePercent(50, 100)).toBe(50);
});
it('clamps the percentage to 100 when usage exceeds the quota', () => {
expect(getQuotaUsagePercent(150, 100)).toBe(100);
});
});
describe('normalizeCapacityLimit', () => {
it('maps 0 (unlimited for capacity limits) to null', () => {
expect(normalizeCapacityLimit(0)).toBeNull();
});
it('passes positive limits through unchanged', () => {
expect(normalizeCapacityLimit(1)).toBe(1);
expect(normalizeCapacityLimit(25)).toBe(25);
});
});
+57
View File
@@ -0,0 +1,57 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
/**
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
*/
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* The usage count at which a positive quota starts "nearing" (80% rounded up).
* The single source for the warning threshold math so the UI panel, quota flags,
* and the per-request alert path can't drift apart.
*/
export const getQuotaWarningCount = (quota: number): number => {
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
/**
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
*/
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= getQuotaWarningCount(quota);
};
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
if (quota === null || quota <= 0) {
return 0;
}
return Math.min(100, Math.round((usage / quota) * 100));
};
/** Member/team capacity limits use `0` for unlimited. */
export const normalizeCapacityLimit = (limit: number): number | null => {
if (limit === 0) {
return null;
}
return limit;
};
+1 -71
View File
@@ -1,10 +1,5 @@
import { env } from '@documenso/lib/utils/env'; import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
type File = { type File = {
@@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
return result; return result;
}; };
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File, options?: PutFileOptions) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInObjectStorage(file, {}, options))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
.otherwise(async () => putFileInDatabase(file));
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildUploadAuthHeaders(options),
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
});
if (!getPresignedUrlResponse.ok) {
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
}
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
const body = await file.arrayBuffer();
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...extraHeaders,
},
body,
});
if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};
+12
View File
@@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => {
content, content,
}; };
}; };
/**
* Validate and sanitise an uploaded branding logo. Re-encoding through `sharp`
* proves the bytes are a real raster image and strips any embedded payloads.
* Throws if the input cannot be parsed as an image.
*/
export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise<Buffer> => {
return await sharp(input)
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
.png({ quality: 80 })
.toBuffer();
};
@@ -1,11 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
export const ZCreateAdminOrganisationRequestSchema = z.object({ export const ZCreateAdminOrganisationRequestSchema = z.object({
ownerUserId: z.number(), ownerUserId: z.number(),
data: z.object({ data: z.object({
name: ZOrganisationNameSchema, name: ZNameSchema,
}), }),
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription'; import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateSubscriptionClaimRequestSchema = z.object({ export const ZCreateSubscriptionClaimRequestSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
teamCount: z.number().int().min(0), teamCount: z.number().int().min(0),
memberCount: z.number().int().min(0), memberCount: z.number().int().min(0),
envelopeItemCount: z.number().int().min(1), envelopeItemCount: z.number().int().min(1),
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/constants/auth'; import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateUserRequestSchema = z.object({ export const ZCreateUserRequestSchema = z.object({
@@ -1,9 +1,10 @@
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config'; import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateEmailTransportRequestSchema = z.object({ export const ZCreateEmailTransportRequestSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
fromName: z.string().min(1), fromName: ZNameSchema,
fromAddress: z.string().email(), fromAddress: z.string().email(),
config: ZEmailTransportConfigSchema, config: ZEmailTransportConfigSchema,
}); });
@@ -4,6 +4,7 @@ import {
ZSmtpApiConfigSchema, ZSmtpApiConfigSchema,
ZSmtpAuthConfigSchema, ZSmtpAuthConfigSchema,
} from '@documenso/lib/server-only/email/email-transport-config'; } from '@documenso/lib/server-only/email/email-transport-config';
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
// Reuses the canonical transport config schemas, but relaxes the secret field so // Reuses the canonical transport config schemas, but relaxes the secret field so
@@ -21,8 +22,8 @@ const ZUpdateConfigSchema = z.discriminatedUnion('type', [
export const ZUpdateEmailTransportRequestSchema = z.object({ export const ZUpdateEmailTransportRequestSchema = z.object({
id: z.string(), id: z.string(),
data: z.object({ data: z.object({
name: z.string().min(1), name: ZNameSchema,
fromName: z.string().min(1), fromName: ZNameSchema,
fromAddress: z.string().email(), fromAddress: z.string().email(),
config: ZUpdateConfigSchema, config: ZUpdateConfigSchema,
}), }),
@@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure
name: true, name: true,
url: true, url: true,
ownerUserId: true, ownerUserId: true,
organisationGlobalSettings: true,
}, },
}, },
teamEmail: true, teamEmail: true,
@@ -1,5 +1,6 @@
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema'; import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema'; import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema'; import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema'; import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema'; import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
name: true, name: true,
url: true, url: true,
ownerUserId: true, ownerUserId: true,
}).extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
}), }),
teamEmail: TeamEmailSchema.nullable(), teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(), teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
@@ -1,13 +1,13 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
import { ZTeamUrlSchema } from '../team-router/schema'; import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types'; import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
export const ZUpdateAdminOrganisationRequestSchema = z.object({ export const ZUpdateAdminOrganisationRequestSchema = z.object({
organisationId: z.string(), organisationId: z.string(),
data: z.object({ data: z.object({
name: ZOrganisationNameSchema.optional(), name: ZNameSchema.optional(),
url: ZTeamUrlSchema.optional(), url: ZTeamUrlSchema.optional(),
claims: ZCreateSubscriptionClaimRequestSchema.pick({ claims: ZCreateSubscriptionClaimRequestSchema.pick({
teamCount: true, teamCount: true,
@@ -1,10 +1,11 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
export const ZUpdateUserRequestSchema = z.object({ export const ZUpdateUserRequestSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
name: z.string().nullish(), name: ZNameSchema.nullish(),
email: zEmail().optional(), email: zEmail().optional(),
roles: z.array(z.nativeEnum(Role)).optional(), roles: z.array(z.nativeEnum(Role)).optional(),
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateApiTokenRequestSchema = z.object({ export const ZCreateApiTokenRequestSchema = z.object({
teamId: z.number(), teamId: z.number(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }), tokenName: ZNameSchema,
expirationDate: z.string().nullable(), expirationDate: z.string().nullable(),
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn'; import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreatePasskeyRequestSchema = z.object({ export const ZCreatePasskeyRequestSchema = z.object({
passkeyName: z.string().trim().min(1), passkeyName: ZNameSchema,
verificationResponse: ZRegistrationResponseJSONSchema, verificationResponse: ZRegistrationResponseJSONSchema,
}); });
@@ -1,8 +1,9 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { z } from 'zod'; import { z } from 'zod';
export const ZUpdatePasskeyRequestSchema = z.object({ export const ZUpdatePasskeyRequestSchema = z.object({
passkeyId: z.string().trim().min(1), passkeyId: z.string().trim().min(1),
name: z.string().trim().min(1), name: ZNameSchema,
}); });
export const ZUpdatePasskeyResponseSchema = z.void(); export const ZUpdatePasskeyResponseSchema = z.void();
@@ -1,9 +1,10 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod'; import { z } from 'zod';
export const ZCreateOrganisationEmailRequestSchema = z.object({ export const ZCreateOrganisationEmailRequestSchema = z.object({
emailDomainId: z.string(), emailDomainId: z.string(),
emailName: z.string().min(1).max(100), emailName: ZNameSchema,
email: zEmail().toLowerCase(), email: zEmail().toLowerCase(),
// This does not need to be validated to be part of the domain. // This does not need to be validated to be part of the domain.
+3 -2
View File
@@ -1,4 +1,5 @@
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type'; import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentVisibility } from '@documenso/prisma/generated/types'; import { DocumentVisibility } from '@documenso/prisma/generated/types';
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema'; import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
@@ -42,7 +43,7 @@ const ZFolderParentIdSchema = z
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.'); .describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
export const ZCreateFolderRequestSchema = z.object({ export const ZCreateFolderRequestSchema = z.object({
name: z.string(), name: ZNameSchema,
parentId: ZFolderParentIdSchema.optional(), parentId: ZFolderParentIdSchema.optional(),
type: ZFolderTypeSchema.optional(), type: ZFolderTypeSchema.optional(),
}); });
@@ -52,7 +53,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema;
export const ZUpdateFolderRequestSchema = z.object({ export const ZUpdateFolderRequestSchema = z.object({
folderId: z.string().describe('The ID of the folder to update'), folderId: z.string().describe('The ID of the folder to update'),
data: z.object({ data: z.object({
name: z.string().optional().describe('The name of the folder'), name: ZNameSchema.optional().describe('The name of the folder'),
parentId: ZFolderParentIdSchema.optional().nullable(), parentId: ZFolderParentIdSchema.optional().nullable(),
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'), visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
pinned: z.boolean().optional().describe('Whether the folder should be pinned'), pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
@@ -1,3 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { OrganisationMemberRole } from '@prisma/client'; import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -14,7 +15,7 @@ import { z } from 'zod';
export const ZCreateOrganisationGroupRequestSchema = z.object({ export const ZCreateOrganisationGroupRequestSchema = z.object({
organisationId: z.string(), organisationId: z.string(),
organisationRole: z.nativeEnum(OrganisationMemberRole), organisationRole: z.nativeEnum(OrganisationMemberRole),
name: z.string().max(100), name: ZNameSchema,
memberIds: z.array(z.string()), memberIds: z.array(z.string()),
}); });

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