feat: enhance admin global settings section with inherited settings support

This commit is contained in:
Catalin Pit
2026-06-23 11:50:48 +03:00
parent 90e5926e2f
commit 4996c955cc
7 changed files with 130 additions and 47 deletions
@@ -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 ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
const notSet = <Trans>Not set</Trans>;
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) => {
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 ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
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);
@@ -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">
<DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue>
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
<DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
<DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
@@ -6,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<T extends FieldValues> = {
control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -81,6 +88,7 @@ export const ClaimLimitFields = <T extends FieldValues>({
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -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';
@@ -312,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
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">
<AlertTitle>
<Trans>Subscription</Trans>
@@ -320,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2">
{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>
<Trans>No subscription found</Trans>
@@ -333,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div>
<Button
variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })}
>
@@ -343,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && (
<div>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -360,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId}
trigger={
<Button variant="outline">
<Button variant="outline" className="bg-background">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" asChild>
<Button variant="outline" className="bg-background" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -631,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
@@ -664,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input disabled {...field} />
</FormControl>
<FormMessage />
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
{field.value ? (
<span className="font-mono text-foreground">{field.value}</span>
) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
</FormItem>
)}
/>
@@ -287,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
<AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -1,4 +1,4 @@
import { QUOTA_WARNING_THRESHOLD } from '../../universal/quota-usage';
import { getQuotaWarningCount, QUOTA_WARNING_THRESHOLD } from '../../universal/quota-usage';
export { QUOTA_WARNING_THRESHOLD };
@@ -34,7 +34,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
// 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
// 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;
@@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure
name: true,
url: true,
ownerUserId: true,
organisationGlobalSettings: true,
},
},
teamEmail: true,
@@ -1,5 +1,6 @@
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
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 OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
name: true,
url: true,
ownerUserId: true,
}).extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
}),
teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),