diff --git a/.env.example b/.env.example index be3ecaab3..5f3da7c1f 100644 --- a/.env.example +++ b/.env.example @@ -180,6 +180,20 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP= NEXT_PUBLIC_DISABLE_OIDC_SIGNUP= # OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org). NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS= +# OPTIONAL: Set to "true" to 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. NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx index 4ec25e213..4b910b43b 100644 --- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx @@ -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_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_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_FEATURE_BILLING_ENABLED` | Enable billing features | `false` | @@ -303,6 +309,44 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_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 @@ -446,6 +490,16 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" # NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true" # NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true" # 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" ``` --- diff --git a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx index b3590a7f2..84e228115 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx @@ -163,6 +163,19 @@ NEXT_PUBLIC_DISABLE_SIGNUP=false # NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true # NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true # 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 ``` Generate secure secrets using: `openssl rand -base64 32` diff --git a/apps/docs/content/docs/self-hosting/deployment/docker.mdx b/apps/docs/content/docs/self-hosting/deployment/docker.mdx index 7d6d4b9cd..68508e767 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker.mdx @@ -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_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_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). diff --git a/apps/docs/content/docs/self-hosting/deployment/railway.mdx b/apps/docs/content/docs/self-hosting/deployment/railway.mdx index 81392a37d..501edca1c 100644 --- a/apps/docs/content/docs/self-hosting/deployment/railway.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/railway.mdx @@ -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_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_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 | - | | `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` | diff --git a/apps/remix/app/components/dialogs/folder-create-dialog.tsx b/apps/remix/app/components/dialogs/folder-create-dialog.tsx index c2cf21eaa..395feaf37 100644 --- a/apps/remix/app/components/dialogs/folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-create-dialog.tsx @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,7 +24,7 @@ import { useParams } from 'react-router'; import { z } from 'zod'; const ZCreateFolderFormSchema = z.object({ - name: z.string().min(1, { message: 'Folder name is required' }), + name: ZNameSchema, }); type TCreateFolderFormSchema = z.infer; @@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }: toast({ description: t`Folder created successfully`, }); - } catch (err) { + } catch (_err) { toast({ title: t`Failed to create folder`, description: t`An unknown error occurred while creating the folder.`, diff --git a/apps/remix/app/components/dialogs/folder-update-dialog.tsx b/apps/remix/app/components/dialogs/folder-update-dialog.tsx index 1c57bc27d..db859431a 100644 --- a/apps/remix/app/components/dialogs/folder-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-update-dialog.tsx @@ -1,5 +1,6 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { Button } from '@documenso/ui/primitives/button'; @@ -23,8 +24,6 @@ import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { useOptionalCurrentTeam } from '~/providers/team'; - export type FolderUpdateDialogProps = { folder: TFolderWithSubfolders | null; isOpen: boolean; @@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = { } & Omit; export const ZUpdateFolderFormSchema = z.object({ - name: z.string().min(1), + name: ZNameSchema, visibility: z.nativeEnum(DocumentVisibility).optional(), }); @@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer; export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => { const { t } = useLingui(); - const team = useOptionalCurrentTeam(); const { toast } = useToast(); const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation(); diff --git a/apps/remix/app/components/dialogs/passkey-create-dialog.tsx b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx index 5a51cd8d4..3fe62a653 100644 --- a/apps/remix/app/components/dialogs/passkey-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx @@ -1,5 +1,6 @@ import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; import { AppError } from '@documenso/lib/errors/app-error'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; @@ -25,14 +26,13 @@ import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; import { z } from 'zod'; - export type PasskeyCreateDialogProps = { trigger?: React.ReactNode; onSuccess?: () => void; } & Omit; const ZCreatePasskeyFormSchema = z.object({ - passkeyName: z.string().min(3), + passkeyName: ZNameSchema, }); type TCreatePasskeyFormSchema = z.infer; diff --git a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx index 6eb5b0e88..3fbddc3c2 100644 --- a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx @@ -1,4 +1,5 @@ import { trpc } from '@documenso/trpc/react'; +import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -19,16 +20,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useRevalidator } from 'react-router'; -import { z } from 'zod'; +import type { z } from 'zod'; export type TeamEmailUpdateDialogProps = { teamEmail: TeamEmail; trigger?: React.ReactNode; } & Omit; -const ZUpdateTeamEmailFormSchema = z.object({ - name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), -}); +const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({ + data: true, +}).shape.data; type TUpdateTeamEmailFormSchema = z.infer; @@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai defaultValues: { name: teamEmail.name, }, + mode: 'onSubmit', }); const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation(); diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index e420fdae7..ef3ff6b34 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -1,5 +1,10 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; 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 { ZCssVarsSchema } from '@documenso/lib/types/css-vars'; import { cn } from '@documenso/ui/lib/utils'; @@ -21,15 +26,17 @@ import { z } from 'zod'; import { useOptionalCurrentTeam } from '~/providers/team'; import { useCspNonce } from '~/utils/nonce'; -const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB -const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; +import { FormStickySaveBar } from './form-sticky-save-bar'; const ZBrandingPreferencesFormSchema = z.object({ brandingEnabled: z.boolean().nullable(), brandingLogo: z .instanceof(File) - .refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') - .refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted') + .refine( + (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(), brandingUrl: z.string().url().optional().or(z.literal('')), brandingCompanyDetails: z.string().max(500).optional(), @@ -71,38 +78,82 @@ export function BrandingPreferencesForm({ const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors); 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({ - values: { - brandingEnabled: settings.brandingEnabled ?? null, - brandingUrl: settings.brandingUrl ?? '', - brandingLogo: undefined, - brandingCompanyDetails: settings.brandingCompanyDetails ?? '', - brandingColors: initialColors, - brandingCss: settings.brandingCss ?? '', - }, + values: savedValues, resolver: zodResolver(ZBrandingPreferencesFormSchema), }); 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(() => { - if (settings.brandingLogo) { - const file = JSON.parse(settings.brandingLogo); + const savedLogoPreviewUrl = getSavedLogoPreviewUrl(); - if ('type' in file && 'data' in file) { - const logoUrl = - 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); - } + if (savedLogoPreviewUrl) { + setPreviewUrl(savedLogoPreviewUrl); } setHasLoadedPreview(true); }, [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 useEffect(() => { return () => { @@ -114,7 +165,7 @@ export function BrandingPreferencesForm({ return (
- +
- {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
} { const file = e.target.files?.[0]; @@ -321,7 +372,7 @@ export function BrandingPreferencesForm({ {hasAdvancedBranding && (
- {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
}
@@ -538,11 +589,11 @@ export function BrandingPreferencesForm({
)} -
- -
+
diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index ee552d043..f1a0b6d8a 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -21,7 +21,6 @@ import { ReminderSettingsPicker } from '@documenso/ui/components/document/remind import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { Alert } from '@documenso/ui/primitives/alert'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; import { Combobox } from '@documenso/ui/primitives/combobox'; import { Form, @@ -46,6 +45,7 @@ import { z } from 'zod'; import { useOptionalCurrentTeam } from '~/providers/team'; 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 @@ -147,9 +147,21 @@ export const DocumentPreferencesForm = ({ 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 (
- +
{!isPersonalLayoutMode && ( )} -
- -
+ form.reset()} + />
diff --git a/apps/remix/app/components/forms/email-preferences-form.tsx b/apps/remix/app/components/forms/email-preferences-form.tsx index 4ab981d93..818868005 100644 --- a/apps/remix/app/components/forms/email-preferences-form.tsx +++ b/apps/remix/app/components/forms/email-preferences-form.tsx @@ -4,7 +4,6 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@ import { zEmail } from '@documenso/lib/utils/zod'; import { trpc } from '@documenso/trpc/react'; import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes'; -import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, @@ -22,6 +21,8 @@ import type { TeamGlobalSettings } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + const ZEmailPreferencesFormSchema = z.object({ emailId: z.string().nullable(), emailReplyTo: zEmail().nullable(), @@ -59,9 +60,21 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema 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 (
- +
{organisation.organisationClaim.flags.emailDomains && ( -
- -
+ form.reset()} + />
diff --git a/apps/remix/app/components/forms/email-transport-form.tsx b/apps/remix/app/components/forms/email-transport-form.tsx index 2e20fdb59..c8af2f478 100644 --- a/apps/remix/app/components/forms/email-transport-form.tsx +++ b/apps/remix/app/components/forms/email-transport-form.tsx @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { Form, FormControl, @@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; const ZEmailTransportFormSchema = z.object({ - name: z.string().min(1), - fromName: z.string().min(1), + name: ZNameSchema, + fromName: ZNameSchema, fromAddress: z.string().email(), type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']), host: z.string().optional(), diff --git a/apps/remix/app/components/forms/form-sticky-save-bar.tsx b/apps/remix/app/components/forms/form-sticky-save-bar.tsx new file mode 100644 index 000000000..1d37f9482 --- /dev/null +++ b/apps/remix/app/components/forms/form-sticky-save-bar.tsx @@ -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(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 ( + <> +
+ + {isFloating && ( + + + + You have unsaved changes + + + )} + + +
+ {isDirty && ( + + )} + + +
+
+ + {/* Sentinel: detects when the sticky bar is floating (stuck) vs settled (docked). */} +
+ + ); +}; diff --git a/apps/remix/app/components/forms/organisation-update-form.tsx b/apps/remix/app/components/forms/organisation-update-form.tsx index 7ac15b1c7..5d4c2c88f 100644 --- a/apps/remix/app/components/forms/organisation-update-form.tsx +++ b/apps/remix/app/components/forms/organisation-update-form.tsx @@ -4,7 +4,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; 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 { Input } from '@documenso/ui/primitives/input'; 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 { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({ name: true, url: true, @@ -137,36 +137,11 @@ export const OrganisationUpdateForm = () => { )} /> -
- - {form.formState.isDirty && ( - - - - )} - - - -
+ form.reset()} + /> diff --git a/apps/remix/app/components/forms/profile.tsx b/apps/remix/app/components/forms/profile.tsx index a9b81bc02..3449a747f 100644 --- a/apps/remix/app/components/forms/profile.tsx +++ b/apps/remix/app/components/forms/profile.tsx @@ -1,5 +1,5 @@ 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 { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; diff --git a/apps/remix/app/components/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx index 09e72d7b2..16b9943ca 100644 --- a/apps/remix/app/components/forms/signin.tsx +++ b/apps/remix/app/components/forms/signin.tsx @@ -58,6 +58,7 @@ export type TSignInFormSchema = z.infer; export type SignInFormProps = { className?: string; initialEmail?: string; + isEmailPasswordSigninEnabled?: boolean; isGoogleSSOEnabled?: boolean; isMicrosoftSSOEnabled?: boolean; isOIDCSSOEnabled?: boolean; @@ -68,6 +69,7 @@ export type SignInFormProps = { export const SignInForm = ({ className, initialEmail, + isEmailPasswordSigninEnabled = true, isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, @@ -324,66 +326,78 @@ export const SignInForm = ({
- ( - - - Email - + {isEmailPasswordSigninEnabled && ( + <> + ( + + + Email + - - - + + + - - - )} - /> + + + )} + /> - ( - - - Password - + ( + + + Password + - - - + + + - + -

- - Forgot your password? - -

-
- )} - /> +

+ + Forgot your password? + +

+
+ )} + /> - {turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && ( - + {turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && ( + + )} + + + )} - - {!isEmbeddedRedirect && ( <> - {hasSocialAuthEnabled && ( + {isEmailPasswordSigninEnabled && hasSocialAuthEnabled && (
diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index a53131f08..4c7a18aec 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -1,8 +1,8 @@ import communityCardsImage from '@documenso/assets/images/community-cards.png'; import { authClient } from '@documenso/auth/client'; 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 { ZNameSchema } from '@documenso/lib/types/name'; import { env } from '@documenso/lib/utils/env'; import { zEmail } from '@documenso/lib/utils/zod'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; diff --git a/apps/remix/app/components/forms/team-update-form.tsx b/apps/remix/app/components/forms/team-update-form.tsx index fdf569f95..8dddaee2b 100644 --- a/apps/remix/app/components/forms/team-update-form.tsx +++ b/apps/remix/app/components/forms/team-update-form.tsx @@ -2,7 +2,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; 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 { Input } from '@documenso/ui/primitives/input'; 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 { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + export type UpdateTeamDialogProps = { teamId: number; teamName: string; @@ -135,36 +135,11 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr )} /> -
- - {form.formState.isDirty && ( - - - - )} - - - -
+ form.reset()} + />
diff --git a/apps/remix/app/components/general/admin-global-settings-section.tsx b/apps/remix/app/components/general/admin-global-settings-section.tsx index 5f21e7900..760684077 100644 --- a/apps/remix/app/components/general/admin-global-settings-section.tsx +++ b/apps/remix/app/components/general/admin-global-settings-section.tsx @@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client'; +import type { ReactNode } from 'react'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; @@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument type AdminGlobalSettingsSectionProps = { settings: TeamGlobalSettings | OrganisationGlobalSettings | null; 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 notSetLabel = isTeam ? Inherited : Not set; if (!settings) { return null; } - const textValue = (value: string | null | undefined) => { - if (value === null || value === undefined) { - return notSetLabel; + const notSet = Not set; + + const inheritedValue = (value: ReactNode) => { + if (!isTeam || value === null) { + return notSet; } - return value; + return ( + + + Inherited: + + {value} + + ); }; - const brandingTextValue = (value: string | null | undefined) => { - if (value === null || value === undefined || value.trim() === '') { - return notSetLabel; + const textValue = (value: string | null | undefined, inherited?: string | null) => { + if (value && value.trim() !== '') { + return value; } - return value; + if (inherited && inherited.trim() !== '') { + return inheritedValue(inherited); + } + + return notSet; }; - const booleanValue = (value: boolean | null | undefined) => { - if (value === null || value === undefined) { - return notSetLabel; + const booleanLabel = (value: boolean) => (value ? Enabled : Disabled); + + const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => { + if (value !== null && value !== undefined) { + return booleanLabel(value); } - return value ? Enabled : Disabled; + 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); @@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
Document visibility}> - {settings.documentVisibility != null - ? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value) - : notSetLabel} + {visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)} Document language}> - {textValue(settings.documentLanguage)} + {textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)} Document timezone}> - {textValue(settings.documentTimezone)} + {textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)} Date format}> - {textValue(settings.documentDateFormat)} + {textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)} Include sender details}> - {booleanValue(settings.includeSenderDetails)} + + {booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)} + Include signing certificate}> - {booleanValue(settings.includeSigningCertificate)} + + {booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)} + Include audit log}> - {booleanValue(settings.includeAuditLog)} + {booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)} Delegate document ownership}> - {booleanValue(settings.delegateDocumentOwnership)} + + {booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)} + Typed signature}> - {booleanValue(settings.typedSignatureEnabled)} + + {booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)} + Upload signature}> - {booleanValue(settings.uploadSignatureEnabled)} + + {booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)} + Draw signature}> - {booleanValue(settings.drawSignatureEnabled)} + + {booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)} + Branding}> - {booleanValue(settings.brandingEnabled)} + {booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)} Branding logo}> - {brandingTextValue(settings.brandingLogo)} + {textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)} Branding URL}> - {brandingTextValue(settings.brandingUrl)} + {textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)} Branding company details}> - {brandingTextValue(settings.brandingCompanyDetails)} + + {textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)} + Email reply-to}> - {textValue(settings.emailReplyTo)} + {textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)} {isTeam && parsedEmailSettings.success && ( @@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl )} AI features}> - {booleanValue(settings.aiFeaturesEnabled)} + {booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}
); diff --git a/apps/remix/app/components/general/claim-account.tsx b/apps/remix/app/components/general/claim-account.tsx index 110549bc7..4301391e6 100644 --- a/apps/remix/app/components/general/claim-account.tsx +++ b/apps/remix/app/components/general/claim-account.tsx @@ -1,6 +1,7 @@ import { authClient } from '@documenso/auth/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { AppError } from '@documenso/lib/errors/app-error'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { env } from '@documenso/lib/utils/env'; import { zEmail } from '@documenso/lib/utils/zod'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; @@ -19,7 +20,6 @@ import { useRef } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { z } from 'zod'; - import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup'; export type ClaimAccountProps = { @@ -30,7 +30,7 @@ export type ClaimAccountProps = { export const ZClaimAccountFormSchema = z .object({ - name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }), + name: ZNameSchema, email: zEmail().min(1), password: ZPasswordSchema, }) diff --git a/apps/remix/app/components/general/claim-limit-fields.tsx b/apps/remix/app/components/general/claim-limit-fields.tsx index ed29c8fc3..c90a92430 100644 --- a/apps/remix/app/components/general/claim-limit-fields.tsx +++ b/apps/remix/app/components/general/claim-limit-fields.tsx @@ -1,11 +1,4 @@ -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { Trans, useLingui } from '@lingui/react/macro'; 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'; +/** + * 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 = { control: Control; /** e.g. '' for the claim form, 'claims.' for the org admin form. */ @@ -20,6 +20,12 @@ type ClaimLimitFieldsProps = { disabled?: boolean; }; +type LimitGroup = { + title: ReactNode; + quotaKey: string; + rateLimitKey: string; +}; + export const ClaimLimitFields = ({ control, prefix = '', @@ -30,13 +36,33 @@ export const ClaimLimitFields = ({ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const name = (key: string) => `${prefix}${key}` as Path; - const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => ( + const limitGroups: LimitGroup[] = [ + { + title: Documents, + quotaKey: 'documentQuota', + rateLimitKey: 'documentRateLimits', + }, + { + title: Emails, + quotaKey: 'emailQuota', + rateLimitKey: 'emailRateLimits', + }, + { + title: API, + quotaKey: 'apiQuota', + rateLimitKey: 'apiRateLimits', + }, + ]; + + const renderQuotaField = (group: LimitGroup) => ( ( - {label} + + Monthly quota + ({ onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))} /> - {description} )} /> ); - const renderRateLimitField = (key: string, label: ReactNode) => ( + const renderRateLimitField = (group: LimitGroup) => ( ( - {label} @@ -71,27 +95,30 @@ export const ClaimLimitFields = ({ ); return ( -
- - Limits - +
+
+

+ Limits +

+

+ + Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h. + +

+
- {renderQuotaField( - 'documentQuota', - Monthly document quota, - Empty = Unlimited, 0 = Blocked, - )} - {renderRateLimitField('documentRateLimits', Document rate limits)} +
+
+ {limitGroups.map((group) => ( +
+

{group.title}

- {renderQuotaField( - 'emailQuota', - Monthly email quota, - Empty = Unlimited, 0 = Blocked, - )} - {renderRateLimitField('emailRateLimits', Email rate limits)} - - {renderQuotaField('apiQuota', Monthly API quota, Empty = Unlimited, 0 = Blocked)} - {renderRateLimitField('apiRateLimits', API rate limits)} + {renderQuotaField(group)} + {renderRateLimitField(group)} +
+ ))} +
+
); }; diff --git a/apps/remix/app/components/general/organisation-usage-panel.tsx b/apps/remix/app/components/general/organisation-usage-panel.tsx index c9ed06265..99a69a661 100644 --- a/apps/remix/app/components/general/organisation-usage-panel.tsx +++ b/apps/remix/app/components/general/organisation-usage-panel.tsx @@ -1,13 +1,38 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { Trans } from '@lingui/react/macro'; import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client'; -import { useState } from 'react'; -import { match } from 'ts-pattern'; +import type { LucideIcon } from 'lucide-react'; +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'; +type CapacityUsage = { + members: number; + teams: number; +}; + +type UsageRow = { + counter: 'document' | 'email' | 'api'; + label: ReactNode; + icon: LucideIcon; + used: number; + effectiveLimit: number | null; +}; + type OrganisationUsagePanelProps = { organisationId: string; monthlyStats: Pick< @@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = { 'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' >[]; organisationClaim: OrganisationClaim; + capacityUsage?: CapacityUsage; +}; + +type UsageCardState = { + status: { + label: ReactNode; + variant: NonNullable; + }; + 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: Unlimited, variant: 'neutral' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? null, + }; + } + + if (limit === 0) { + return { + status: { label: Blocked, variant: 'destructive' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? Resource blocked, + }; + } + + if (used > limit) { + return { + status: { label: Exceeded, variant: 'destructive' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-destructive', + subtext: footnote ?? null, + }; + } + + if (isQuotaExceeded(limit, used)) { + return { + status: { label: Limit reached, variant: 'orange' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400', + subtext: footnote ?? null, + }; + } + + if (isQuotaNearing(limit, used)) { + return { + status: { label: Near limit, variant: 'warning' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400', + subtext: footnote ?? null, + }; + } + + return { + status: { label: Within limit, 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 ( +
+
+
+ + {label} +
+ + {!countOnly && ( + + {status.label} + + )} +
+ +
+
+
+ + {used.toLocaleString()} + + {hasFiniteLimit ? ( + / {limit?.toLocaleString()} + ) : null} +
+ + {hasFiniteLimit ? ( + {percent}% + ) : null} +
+ + {hasFiniteLimit ? : null} + + {subtext ?

{subtext}

: null} +
+ + {action ?
{action}
: null} +
+ ); }; export const OrganisationUsagePanel = ({ organisationId, monthlyStats, organisationClaim, + capacityUsage, }: OrganisationUsagePanelProps) => { + const monthlyUsagePeriodId = useId(); const [selectedPeriod, setSelectedPeriod] = useState(() => monthlyStats[0]?.period); 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. const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod(); - const rows = [ + const capacityRows = capacityUsage + ? [ + { + key: 'members', + label: Members, + icon: UsersIcon, + used: capacityUsage.members, + limit: normalizeCapacityLimit(organisationClaim.memberCount), + }, + { + key: 'teams', + label: Teams, + icon: UsersRoundIcon, + used: capacityUsage.teams, + limit: normalizeCapacityLimit(organisationClaim.teamCount), + }, + ] + : []; + + const monthlyRows: UsageRow[] = [ { - counter: 'document' as const, + counter: 'document', label: Documents, + icon: FileIcon, used: selectedStat?.documentCount ?? 0, effectiveLimit: organisationClaim.documentQuota, }, { - counter: 'email' as const, + counter: 'email', label: Emails, + icon: MailIcon, used: selectedStat?.emailCount ?? 0, effectiveLimit: organisationClaim.emailQuota, }, { - counter: 'api' as const, + counter: 'api', label: API requests, + icon: PlugIcon, used: selectedStat?.apiCount ?? 0, effectiveLimit: organisationClaim.apiQuota, }, ]; return ( -
-
-

- Usage for period: {selectedStat?.period || 'N/A'} -

+
+ {capacityRows.length > 0 ? ( +
+ {capacityRows.map((row) => ( + + ))} +
+ ) : null} - {monthlyStats.length > 0 && ( - - )} -
+
+
+

+ Monthly usage +

- {rows.map((row) => { - const percent = - row.effectiveLimit && row.effectiveLimit > 0 - ? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100)) - : 0; + {monthlyStats.length > 0 ? ( + + ) : null} +
- return ( -
-
- {row.label} - - {row.used} /{' '} - {match(row.effectiveLimit) - .with(null, () => Unlimited) - .with(0, () => Blocked) - .otherwise(String)} - -
+
+ {monthlyRows.map((row) => ( + + ) : undefined + } + /> + ))} - {row.effectiveLimit && row.effectiveLimit > 0 ? : null} - - {selectedStat && isCurrentPeriod && ( -
- -
- )} -
- ); - })} - -
-
- - Reports - - {selectedStat?.emailReports ?? 0} + Reports} + icon={MailOpenIcon} + used={selectedStat?.emailReports ?? 0} + limit={null} + countOnly + footnote={Sent this period} + />
diff --git a/apps/remix/app/components/general/organisation-usage-reset-button.tsx b/apps/remix/app/components/general/organisation-usage-reset-button.tsx index 7451f6e1c..bec8b8dd6 100644 --- a/apps/remix/app/components/general/organisation-usage-reset-button.tsx +++ b/apps/remix/app/components/general/organisation-usage-reset-button.tsx @@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { Trans, useLingui } from '@lingui/react/macro'; +import { RotateCcwIcon } from 'lucide-react'; import { useRevalidator } from 'react-router'; type OrganisationUsageResetButtonProps = { @@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi loading={isPending} onClick={() => reset({ organisationId, counter })} > + Reset ); diff --git a/apps/remix/app/components/general/rate-limit-array-input.tsx b/apps/remix/app/components/general/rate-limit-array-input.tsx index a2adaad38..a0197764f 100644 --- a/apps/remix/app/components/general/rate-limit-array-input.tsx +++ b/apps/remix/app/components/general/rate-limit-array-input.tsx @@ -1,7 +1,9 @@ +import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription'; import { Button } from '@documenso/ui/primitives/button'; 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 { useState } from 'react'; type RateLimitEntryValue = { window: string; max: number }; @@ -11,50 +13,153 @@ type RateLimitArrayInputProps = { 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) => { - const entries = value ?? []; + const { t } = useLingui(); + const [draftEntry, setDraftEntry] = useState(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) => { - const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); - onChange(next); + if (index >= value.length) { + 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) => { - 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 = () => { - 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 (
- {entries.map((entry, index) => ( -
- updateEntry(index, { window: e.target.value })} - /> - updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })} - /> - -
- ))} +
+ + Window + + + Max requests + +
- +
+ + {windowError ?

{windowError}

: null} + {maxError ?

{maxError}

: null} +
+ ); + })} + +
); diff --git a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx index 7feb0e191..180dc3e44 100644 --- a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx +++ b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = { }; const ZUpdatePasskeySchema = z.object({ - name: z.string(), + name: ZNameSchema, }); type TUpdatePasskeySchema = z.infer; diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index ac668ee3f..d895b338d 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa import { trpc } from '@documenso/trpc/react'; 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 { cn } from '@documenso/ui/lib/utils'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; 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 { msg } from '@lingui/core/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 { useMemo } from 'react'; 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 { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-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 { ClaimLimitFields } from '~/components/general/claim-limit-fields'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; @@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro -
-
-
-

- Organisation usage -

-

- Current usage against organisation limits. -

-
-
+ -
- Members}> - - {organisation.members.length} /{' '} - {organisation.organisationClaim.memberCount === 0 - ? t`Unlimited` - : organisation.organisationClaim.memberCount} - - - - Teams}> - - {organisation.teams.length} /{' '} - {organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount} - - -
- -
- -
-
+
-

+

Global Settings

-

+

Default settings applied to this organisation.

@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro className="mt-16" /> - +
Subscription @@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro {organisation.subscription ? ( - {i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found + + {organisation.subscription.status === SubscriptionStatus.ACTIVE && ( + ) : ( No subscription found @@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
} /> -