feat: unify settings (#3128)

This commit is contained in:
David Nguyen
2026-08-09 16:00:55 +10:00
committed by GitHub
parent f0ab7c112e
commit d6cf3fec4b
120 changed files with 4181 additions and 1859 deletions
@@ -18,7 +18,6 @@ export type DocumentPreferencesResetDialogProps = {
onReset: () => Promise<void>;
showAiFeatures?: boolean;
showDocumentVisibility?: boolean;
showIncludeSenderDetails?: boolean;
};
export const DocumentPreferencesResetDialog = ({
@@ -26,7 +25,6 @@ export const DocumentPreferencesResetDialog = ({
onReset,
showAiFeatures = false,
showDocumentVisibility = false,
showIncludeSenderDetails = false,
}: DocumentPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
@@ -92,29 +90,12 @@ export const DocumentPreferencesResetDialog = ({
<li>
<Trans>Default signature settings</Trans>
</li>
{showIncludeSenderDetails && (
<li>
<Trans>Send on behalf of team</Trans>
</li>
)}
<li>
<Trans>Include the signing certificate in the document</Trans>
</li>
<li>
<Trans>Include the audit logs in the document</Trans>
</li>
<li>
<Trans>Default recipients</Trans>
</li>
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Default envelope expiration</Trans>
</li>
<li>
<Trans>Default signing reminders</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
@@ -17,28 +17,25 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Prisma } from '@prisma/client';
import type { Team, TeamEmail, TeamEmailVerification } from '@prisma/client';
import { useState } from 'react';
import { useRevalidator } from 'react-router';
export type TeamEmailDeleteDialogProps = {
trigger?: React.ReactNode;
teamName: string;
team: Prisma.TeamGetPayload<{
include: {
teamEmail: true;
emailVerification: {
select: {
expiresAt: true;
name: true;
email: true;
};
};
};
}>;
team: Pick<Team, 'id' | 'avatarImageId' | 'name'>;
teamEmail: Pick<TeamEmail, 'email' | 'name'> | null;
emailVerification: Pick<TeamEmailVerification, 'email' | 'name' | 'expiresAt'> | null;
};
export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => {
export const TeamEmailDeleteDialog = ({
trigger,
teamName,
team,
teamEmail,
emailVerification,
}: TeamEmailDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
@@ -83,11 +80,11 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
});
const onRemove = async () => {
if (team.teamEmail) {
if (teamEmail) {
await deleteTeamEmail({ teamId: team.id });
}
if (team.emailVerification) {
if (emailVerification) {
await deleteTeamEmailVerification({ teamId: team.id });
}
@@ -121,13 +118,13 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
avatarFallback={extractInitials((teamEmail?.name || emailVerification?.name) ?? '')}
primaryText={
<span className="font-semibold text-foreground/80 text-sm">
{team.teamEmail?.name || team.emailVerification?.name}
{teamEmail?.name || emailVerification?.name}
</span>
}
secondaryText={<span className="text-sm">{team.teamEmail?.email || team.emailVerification?.email}</span>}
secondaryText={<span className="text-sm">{teamEmail?.email || emailVerification?.email}</span>}
/>
</Alert>
@@ -23,7 +23,8 @@ import { useRevalidator } from 'react-router';
import type { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
teamId: number;
teamEmail: Pick<TeamEmail, 'email' | 'name'>;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@@ -33,7 +34,7 @@ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
@@ -53,7 +54,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
try {
await updateTeamEmail({
teamId: teamEmail.teamId,
teamId,
data: {
name,
},
@@ -29,6 +29,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(),
@@ -210,11 +211,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enable Custom Branding</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Enable Custom Branding</Trans>}
testId="branding-enabled"
>
<FormControl>
<Select
{...field}
@@ -252,7 +255,7 @@ export function BrandingPreferencesForm({
<Trans>Enable custom branding for all documents in this organisation</Trans>
)}
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -263,11 +266,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingLogo"
render={({ field: { value: _value, onChange, ...field } }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Branding Logo</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={!previewUrl}
label={<Trans>Branding Logo</Trans>}
testId="branding-logo"
>
<div className="flex flex-col gap-4">
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-background">
{previewUrl ? (
@@ -345,7 +350,7 @@ export function BrandingPreferencesForm({
)}
</FormDescription>
</div>
</FormItem>
</InheritableField>
)}
/>
@@ -353,11 +358,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Brand Website</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={!field.value}
label={<Trans>Brand Website</Trans>}
testId="branding-url"
>
<FormControl>
<Input type="url" placeholder="https://example.com" disabled={!isBrandingEnabled} {...field} />
</FormControl>
@@ -372,7 +379,7 @@ export function BrandingPreferencesForm({
</span>
)}
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -380,11 +387,13 @@ export function BrandingPreferencesForm({
control={form.control}
name="brandingCompanyDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Brand Details</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={!field.value}
label={<Trans>Brand Details</Trans>}
testId="branding-company-details"
>
<FormControl>
<Textarea
placeholder={t`Enter your brand details`}
@@ -404,7 +413,7 @@ export function BrandingPreferencesForm({
</span>
)}
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
</div>
@@ -0,0 +1,169 @@
import { Form, FormControl, FormDescription, FormField } from '@documenso/ui/primitives/form/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
const ZCertificatePreferencesFormSchema = z.object({
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
});
export type TCertificatePreferencesFormSchema = z.infer<typeof ZCertificatePreferencesFormSchema>;
type SettingsSubset = Pick<TeamGlobalSettings, 'includeSigningCertificate' | 'includeAuditLog'>;
export type CertificatePreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
onFormSubmit: (data: TCertificatePreferencesFormSchema) => Promise<void>;
};
export const CertificatePreferencesForm = ({ settings, canInherit, onFormSubmit }: CertificatePreferencesFormProps) => {
const form = useForm<TCertificatePreferencesFormSchema>({
defaultValues: {
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
},
resolver: zodResolver(ZCertificatePreferencesFormSchema),
});
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 (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Include the Signing Certificate in the Document</Trans>}
testId="include-signing-certificate"
>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-signing-certificate-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when it is downloaded. The
signing certificate can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</InheritableField>
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Include the Audit Logs in the Document</Trans>}
testId="include-audit-log"
>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-audit-log-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the audit logs will be included in the document when it is downloaded. The audit
logs can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</InheritableField>
)}
/>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
</fieldset>
</form>
</Form>
);
};
@@ -2,11 +2,6 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
type TEnvelopeExpirationPeriod,
ZEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
import { type TEnvelopeReminderSettings, ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
@@ -16,28 +11,17 @@ import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documens
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
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 { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormDescription, FormField, FormMessage } from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -46,6 +30,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
@@ -56,15 +41,10 @@ export type TDocumentPreferencesFormSchema = {
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
documentTimezone: string | null;
documentDateFormat: TDocumentMetaDateFormat | null;
includeSenderDetails: boolean | null;
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
reminderSettings: TEnvelopeReminderSettings | null;
};
type SettingsSubset = Pick<
@@ -73,17 +53,12 @@ type SettingsSubset = Pick<
| 'documentLanguage'
| 'documentTimezone'
| 'documentDateFormat'
| 'includeSenderDetails'
| 'includeSigningCertificate'
| 'includeAuditLog'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
| 'envelopeExpirationPeriod'
| 'reminderSettings'
>;
export type DocumentPreferencesFormProps = {
@@ -101,15 +76,10 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
};
};
@@ -120,31 +90,23 @@ export const DocumentPreferencesForm = ({
isAiFeaturesConfigured = false,
}: DocumentPreferencesFormProps) => {
const { _ } = useLingui();
const { user, organisations } = useSession();
const { organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const optionalTeam = useOptionalCurrentTeam();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
const placeholderEmail = user.email ?? 'user@example.com';
const ZDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const defaultValues = getDocumentPreferencesFormValues(settings);
@@ -189,17 +151,19 @@ export const DocumentPreferencesForm = ({
return (
<Form {...form}>
<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 flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{!isPersonalLayoutMode && (
<FormField
control={form.control}
name="documentVisibility"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Visibility</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Document Visibility</Trans>}
testId="document-visibility"
>
<FormControl>
<Select
{...field}
@@ -236,7 +200,7 @@ export const DocumentPreferencesForm = ({
<FormDescription>
<Trans>Controls the default visibility of an uploaded document.</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
)}
@@ -245,11 +209,13 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="documentLanguage"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Document Language</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Document Language</Trans>}
testId="document-language"
>
<FormControl>
<Select
{...field}
@@ -283,7 +249,7 @@ export const DocumentPreferencesForm = ({
communications with the recipients.
</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -291,11 +257,12 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="documentDateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Date Format</Trans>
</FormLabel>
<InheritableField
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Date Format</Trans>}
testId="document-date-format"
>
<FormControl>
<Select
value={field.value === null ? '-1' : field.value}
@@ -322,7 +289,7 @@ export const DocumentPreferencesForm = ({
</FormControl>
<FormMessage />
</FormItem>
</InheritableField>
)}
/>
@@ -330,11 +297,12 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="documentTimezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Time Zone</Trans>
</FormLabel>
<InheritableField
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Time Zone</Trans>}
testId="document-timezone"
>
<FormControl>
<Combobox
triggerPlaceholder={canInherit ? t`Inherit from organisation` : t`Local timezone`}
@@ -347,7 +315,7 @@ export const DocumentPreferencesForm = ({
</FormControl>
<FormMessage />
</FormItem>
</InheritableField>
)}
/>
@@ -355,12 +323,18 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="signatureTypes"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="flex flex-row items-center">
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={canInherit && (field.value === null || field.value.length === 0)}
label={
<span className="flex flex-row items-center">
<Trans>Default Signature Settings</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
</span>
}
testId="signature-types"
>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
@@ -383,179 +357,7 @@ export const DocumentPreferencesForm = ({
<Trans>Controls which signatures are allowed to be used when signing a document.</Trans>
</FormDescription>
)}
</FormItem>
)}
/>
{!isPersonalLayoutMode && !isPersonalOrganisation && (
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Send on Behalf of Team</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-sender-details-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<div className="pt-2">
<div className="font-medium text-muted-foreground text-xs">
<Trans>Preview</Trans>
</div>
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
{field.value ? (
<Trans>
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign "example document".
</Trans>
) : (
<Trans>"Team Name" has invited you to sign "example document".</Trans>
)}
</Alert>
</div>
<FormDescription>
<Trans>
Controls the formatting of the message that will be sent when inviting a recipient to sign a
document. If a custom message has been provided while configuring the document, it will be used
instead.
</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Signing Certificate in the Document</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-signing-certificate-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when it is downloaded. The
signing certificate can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Audit Logs in the Document</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the audit logs will be included in the document when it is downloaded. The audit
logs can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -566,11 +368,13 @@ export const DocumentPreferencesForm = ({
const recipients = field.value ?? [];
return (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Recipients</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Recipients</Trans>}
testId="default-recipients"
>
{canInherit && (
<Select
value={field.value === null ? '-1' : '0'}
@@ -638,7 +442,7 @@ export const DocumentPreferencesForm = ({
<FormDescription>
<Trans>Recipients that will be automatically added to new documents.</Trans>
</FormDescription>
</FormItem>
</InheritableField>
);
}}
/>
@@ -647,11 +451,13 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="delegateDocumentOwnership"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Delegate Document Ownership</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Delegate Document Ownership</Trans>}
testId="delegate-document-ownership"
>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
@@ -681,65 +487,7 @@ export const DocumentPreferencesForm = ({
<FormDescription>
<Trans>Enable team API tokens to delegate document ownership to another team member.</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="envelopeExpirationPeriod"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Envelope Expiration</Trans>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls how long recipients have to complete signing before the document expires. After expiration,
recipients can no longer sign the document.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reminderSettings"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Signing Reminders</Trans>
</FormLabel>
<FormControl>
<ReminderSettingsPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls when and how often reminder emails are sent to recipients who have not yet completed
signing.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
</InheritableField>
)}
/>
@@ -748,11 +496,13 @@ export const DocumentPreferencesForm = ({
control={form.control}
name="aiFeaturesEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>AI Features</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>AI Features</Trans>}
testId="ai-features-enabled"
>
<FormControl>
<Select
{...field}
@@ -790,7 +540,7 @@ export const DocumentPreferencesForm = ({
prefer European regions where available.
</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
)}
@@ -806,7 +556,6 @@ export const DocumentPreferencesForm = ({
onReset={handleResetToDefaults}
showAiFeatures={isAiFeaturesConfigured}
showDocumentVisibility={!isPersonalLayoutMode}
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
/>
) : undefined
}
@@ -1,9 +1,11 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { FROM_ADDRESS } from '@documenso/lib/constants/email';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { Alert } from '@documenso/ui/primitives/alert';
import {
Form,
FormControl,
@@ -17,22 +19,27 @@ import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { OrganisationType, type TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
const ZEmailPreferencesFormSchema = z.object({
emailId: z.string().nullable(),
emailReplyTo: zEmail().nullable(),
// emailReplyToName: z.string(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
});
export type TEmailPreferencesFormSchema = z.infer<typeof ZEmailPreferencesFormSchema>;
type SettingsSubset = Pick<TeamGlobalSettings, 'emailId' | 'emailReplyTo' | 'emailDocumentSettings'>;
type SettingsSubset = Pick<
TeamGlobalSettings,
'emailId' | 'emailReplyTo' | 'emailDocumentSettings' | 'includeSenderDetails'
>;
export type EmailPreferencesFormProps = {
settings: SettingsSubset;
@@ -41,14 +48,20 @@ export type EmailPreferencesFormProps = {
};
export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: EmailPreferencesFormProps) => {
const { user } = useSession();
const organisation = useCurrentOrganisation();
const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
const placeholderEmail = user.email ?? 'user@example.com';
const form = useForm<TEmailPreferencesFormSchema>({
defaultValues: {
emailId: settings.emailId,
emailReplyTo: settings.emailReplyTo,
// emailReplyToName: settings.emailReplyToName,
emailDocumentSettings: settings.emailDocumentSettings,
includeSenderDetails: settings.includeSenderDetails,
},
resolver: zodResolver(ZEmailPreferencesFormSchema),
});
@@ -75,7 +88,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
return (
<Form {...form}>
<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 flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
@@ -122,10 +135,12 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
control={form.control}
name="emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply to email</Trans>
</FormLabel>
<InheritableField
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Reply to email</Trans>}
testId="email-reply-to"
>
<FormControl>
<Input
{...field}
@@ -146,7 +161,7 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
</span>
)}
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
@@ -170,10 +185,13 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
control={form.control}
name="emailDocumentSettings"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Email Settings</Trans>
</FormLabel>
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Email Settings</Trans>}
testId="email-document-settings"
>
{canInherit && (
<Select
value={field.value === null ? 'INHERIT' : 'CONTROLLED'}
@@ -212,10 +230,83 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
settings will not affect existing documents or templates.
</Trans>
</FormDescription>
</FormItem>
</InheritableField>
)}
/>
{!isPersonalOrganisation && (
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Send on Behalf of Team</Trans>}
testId="include-sender-details"
>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-sender-details-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<div className="pt-2">
<div className="font-medium text-muted-foreground text-xs">
<Trans>Preview</Trans>
</div>
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
{field.value ? (
<Trans>
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign "example document".
</Trans>
) : (
<Trans>"Team Name" has invited you to sign "example document".</Trans>
)}
</Alert>
</div>
<FormDescription>
<Trans>
Controls the formatting of the message that will be sent when inviting a recipient to sign a
document. If a custom message has been provided while configuring the document, it will be used
instead.
</Trans>
</FormDescription>
</InheritableField>
)}
/>
)}
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
@@ -5,6 +5,22 @@ import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon } from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
const getScrollParent = (node: HTMLElement): HTMLElement | null => {
let current = node.parentElement;
while (current) {
const { overflowY } = getComputedStyle(current);
if (overflowY === 'auto' || overflowY === 'scroll') {
return current;
}
current = current.parentElement;
}
return null;
};
export type FormStickySaveBarProps = {
isDirty: boolean;
isSubmitting: boolean;
@@ -43,14 +59,18 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefau
}
// 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.
// bar is stuck to the bottom of the scroll container the sentinel is scrolled past
// (out of view); once you reach the form's end it comes into view and the bar settles.
//
// Observe relative to the actual scroll container (not always the viewport) so a
// banner shifting the page can't desync the detection from the sticky bar — both
// then share the same reference box.
const observer = new IntersectionObserver(
([entry]) => {
setIsStuck(!entry.isIntersecting);
},
{
root: null,
root: getScrollParent(sentinel),
rootMargin: '0px 0px -24px 0px',
threshold: 0,
},
@@ -0,0 +1,51 @@
import { cn } from '@documenso/ui/lib/utils';
import { FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { Trans } from '@lingui/react/macro';
import type { ReactNode } from 'react';
export type InheritableFieldProps = {
isInherited: boolean;
canInherit: boolean;
label: ReactNode;
children: ReactNode;
className?: string;
testId?: string;
};
export const InheritableField = ({
isInherited,
canInherit,
label,
children,
className,
testId,
}: InheritableFieldProps) => {
if (!canInherit) {
return (
<FormItem className={className}>
<FormLabel>{label}</FormLabel>
{children}
</FormItem>
);
}
return (
<FormItem className={className} data-testid={testId ? `inheritable-${testId}` : undefined}>
<FormLabel className="flex items-center gap-2">
{label}
<span
className={cn(
'rounded px-1.5 py-0.5 font-bold text-[9px] uppercase tracking-wide',
isInherited
? 'bg-muted text-muted-foreground'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
)}
data-testid={testId ? `${testId}-status` : undefined}
>
{isInherited ? <Trans>Inherited</Trans> : <Trans>Override</Trans>}
</span>
</FormLabel>
{children}
</FormItem>
);
};
@@ -0,0 +1,134 @@
import {
type TEnvelopeExpirationPeriod,
ZEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
import { type TEnvelopeReminderSettings, ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import { Form, FormControl, FormDescription, FormField, FormMessage } from '@documenso/ui/primitives/form/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
import { InheritableField } from './inheritable-field';
const ZReminderPreferencesFormSchema = z.object({
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
export type TReminderPreferencesFormSchema = {
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
reminderSettings: TEnvelopeReminderSettings | null;
};
type SettingsSubset = Pick<TeamGlobalSettings, 'envelopeExpirationPeriod' | 'reminderSettings'>;
export type ReminderPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
onFormSubmit: (data: TReminderPreferencesFormSchema) => Promise<void>;
};
export const ReminderPreferencesForm = ({ settings, canInherit, onFormSubmit }: ReminderPreferencesFormProps) => {
const { t } = useLingui();
const form = useForm<TReminderPreferencesFormSchema>({
defaultValues: {
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
},
resolver: zodResolver(ZReminderPreferencesFormSchema),
});
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 (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="envelopeExpirationPeriod"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Envelope Expiration</Trans>}
testId="envelope-expiration-period"
>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls how long recipients have to complete signing before the document expires. After expiration,
recipients can no longer sign the document.
</Trans>
</FormDescription>
<FormMessage />
</InheritableField>
)}
/>
<FormField
control={form.control}
name="reminderSettings"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Default Signing Reminders</Trans>}
testId="reminder-settings"
>
<FormControl>
<ReminderSettingsPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls when and how often reminder emails are sent to recipients who have not yet completed
signing.
</Trans>
</FormDescription>
<FormMessage />
</InheritableField>
)}
/>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
</fieldset>
</form>
</Form>
);
};
@@ -1,5 +1,3 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { getRootHref } from '@documenso/lib/utils/params';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -14,16 +12,16 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { AppCommandMenu } from './app-command-menu';
import { AppNavDesktop } from './app-nav-desktop';
import { AppNavMobile } from './app-nav-mobile';
import { MenuSwitcher } from './menu-switcher';
import { OrgMenuSwitcher } from './org-menu-switcher';
export type HeaderProps = HTMLAttributes<HTMLDivElement>;
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
/** Span the full viewport width instead of the centered max-w-screen-xl container. */
fullWidth?: boolean;
};
export const Header = ({ className, ...props }: HeaderProps) => {
export const Header = ({ className, fullWidth = false, ...props }: HeaderProps) => {
const params = useParams();
const { organisations } = useSession();
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
@@ -51,12 +49,18 @@ export const Header = ({ className, ...props }: HeaderProps) => {
<header
className={cn(
'sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent bg-background/95 backdrop-blur duration-200 supports-backdrop-blur:bg-background/60',
scrollY > 5 && 'border-b-border',
(scrollY > 5 || fullWidth) && 'border-b-border',
className,
)}
{...props}
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<div
className={cn(
'mx-auto flex w-full items-center justify-between gap-x-4 px-4 md:justify-normal',
fullWidth ? 'md:px-6' : 'max-w-screen-xl md:px-8',
)}
data-testid="app-header-container"
>
<Link
to={getRootHref(params)}
className="hidden rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:inline"
@@ -78,7 +82,9 @@ export const Header = ({ className, ...props }: HeaderProps) => {
</Link>
</Button>
<div className="md:ml-4">{isPersonalLayout(organisations) ? <MenuSwitcher /> : <OrgMenuSwitcher />}</div>
<div className="md:ml-4">
<OrgMenuSwitcher />
</div>
<div className="flex flex-row items-center space-x-4 md:hidden">
<button onClick={() => setIsCommandMenuOpen(true)}>
@@ -1,5 +1,4 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -37,8 +36,8 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
if (!teamUrl && isPersonalLayout(organisations)) {
teamUrl = organisations[0].teams[0]?.url || null;
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
@@ -1,7 +1,6 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -40,8 +39,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
if (!teamUrl && isPersonalLayout(organisations)) {
teamUrl = organisations[0].teams[0]?.url || null;
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
@@ -317,7 +317,6 @@ export const IndividualPersonalLayoutCheckoutButton = ({
const createSubscriptionResponse = await createSubscription({
organisationId: organisations[0].id,
priceId,
isPersonalLayoutMode: true,
});
window.location.href = createSubscriptionResponse.redirectUrl;
@@ -1,104 +0,0 @@
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronsUpDown, Plus } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
export const MenuSwitcher = () => {
const { _ } = useLingui();
const { user } = useSession();
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
const isUserAdmin = isAdmin(user);
const formatAvatarFallback = (name?: string) => {
if (name !== undefined) {
return name.slice(0, 1).toUpperCase();
}
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="menu-switcher"
variant="none"
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
>
<AvatarWithText
avatarSrc={formatAvatarUrl(user.avatarImageId)}
avatarFallback={formatAvatarFallback(user.name || user.email)}
primaryText={user.name}
secondaryText={_(msg`Personal Account`)}
rightSideComponent={<ChevronsUpDown className="ml-auto h-4 w-4 text-muted-foreground" />}
textSectionClassName="hidden lg:flex"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className={cn('z-[60] ml-6 w-full min-w-[12rem] md:ml-0')} align="end" forceMount>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/organisations?action=add-organisation" className="flex items-center justify-between">
<Trans>Create Organisation</Trans>
<Plus className="ml-2 h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{isUserAdmin && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/admin">
<Trans>Admin panel</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/profile">
<Trans>User settings</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" onClick={() => setLanguageSwitcherOpen(true)}>
<Trans>Language</Trans>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:!text-destructive px-4 py-2 text-destructive/90"
onSelect={async () => authClient.signOut()}
>
<Trans>Sign Out</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
</DropdownMenu>
);
};
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
@@ -55,6 +56,12 @@ export const OrgMenuSwitcher = () => {
const currentOrganisation = useOptionalCurrentOrganisation();
const currentTeam = useOptionalCurrentTeam();
const canAccessOrganisationSettings =
currentOrganisation &&
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole);
const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole);
// Use hovered org for teams display if available,
// otherwise use current team's org if in a team,
// finally fallback to selected org
@@ -258,26 +265,23 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem>
)}
{currentOrganisation &&
canExecuteOrganisationAction('MANAGE_ORGANISATION', currentOrganisation.currentOrganisationRole) && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/o/${currentOrganisation.url}/settings`}>
<Trans>Organisation settings</Trans>
</Link>
</DropdownMenuItem>
)}
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={`/t/${currentTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
<Trans>Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link
to={
canAccessOrganisationSettings
? `/o/${currentOrganisation?.url}/settings`
: canAccessTeamSettings
? `/t/${currentTeam?.url}/settings`
: '/settings'
}
>
<Trans>Settings</Trans>
</Link>
</DropdownMenuItem>
@@ -287,6 +291,14 @@ export const OrgMenuSwitcher = () => {
</Link>
</DropdownMenuItem>
{IS_BILLING_ENABLED() && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to="/settings/billing">
<Trans>Billing</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="px-4 py-2 text-muted-foreground"
onClick={() => setLanguageSwitcherOpen(true)}
@@ -1,6 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -13,8 +12,6 @@ export type OrganisationBillingPortalButtonProps = {
};
export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBillingPortalButtonProps) => {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { _ } = useLingui();
@@ -28,11 +25,10 @@ export const OrganisationBillingPortalButton = ({ buttonProps }: OrganisationBil
try {
const { redirectUrl } = await manageSubscription({
organisationId: organisation.id,
isPersonalLayoutMode: isPersonalLayout(organisations),
});
window.open(redirectUrl, '_blank');
} catch (err) {
} catch (_err) {
toast({
title: _(msg`Something went wrong`),
description: _(
@@ -12,9 +12,9 @@ export type SettingsHeaderProps = {
export const SettingsHeader = ({ children, title, subtitle, className, hideDivider }: SettingsHeaderProps) => {
return (
<>
<div className={cn('flex flex-row items-center justify-between', className)}>
<div className={cn('mb-4 flex flex-row items-center justify-between', className)}>
<div>
<h3 className="font-medium text-lg">{title}</h3>
<h2 className="font-bold text-xl">{title}</h2>
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
</div>
@@ -22,7 +22,7 @@ export const SettingsHeader = ({ children, title, subtitle, className, hideDivid
{children}
</div>
{!hideDivider && <hr className="my-4" />}
{!hideDivider && <hr className="mb-4" />}
</>
);
};
@@ -1,137 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { BracesIcon, CreditCardIcon, Globe2Icon, Lock, Settings2Icon, User, Users, WebhookIcon } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button variant="ghost" className={cn('w-full justify-start')}>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Trans>Document</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<Trans>Branding</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<Trans>Email</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -1,144 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import {
BracesIcon,
CreditCardIcon,
Globe2Icon,
Lock,
MailIcon,
PaletteIcon,
Settings2Icon,
User,
Users,
WebhookIcon,
} from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { Link, useLocation } from 'react-router';
export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
const { pathname } = useLocation();
const { organisations } = useSession();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const hasManageableBillingOrgs = organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
return (
<div className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)} {...props}>
<Link to="/settings/profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/profile') && 'bg-secondary')}
>
<User className="mr-2 h-5 w-5" />
<Trans>Profile</Trans>
</Button>
</Link>
{isPersonalLayoutMode && (
<>
<Link to="/settings/document">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/document') && 'bg-secondary')}
>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Document Preferences</Trans>
</Button>
</Link>
<Link to="/settings/branding">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/branding') && 'bg-secondary')}
>
<PaletteIcon className="mr-2 h-5 w-5" />
<Trans>Branding Preferences</Trans>
</Button>
</Link>
<Link to="/settings/email">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/email') && 'bg-secondary')}
>
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Preferences</Trans>
</Button>
</Link>
<Link to="/settings/public-profile">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/public-profile') && 'bg-secondary')}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to="/settings/tokens">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/tokens') && 'bg-secondary')}
>
<BracesIcon className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to="/settings/webhooks">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/webhooks') && 'bg-secondary')}
>
<WebhookIcon className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</>
)}
<Link to="/settings/organisations">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/organisations') && 'bg-secondary')}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Organisations</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && hasManageableBillingOrgs && (
<Link to={isPersonalLayoutMode ? '/settings/billing-personal' : `/settings/billing`}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/billing') && 'bg-secondary')}
>
<CreditCardIcon className="mr-2 h-5 w-5" />
<Trans>Billing</Trans>
</Button>
</Link>
)}
<Link to="/settings/security">
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith('/settings/security') && 'bg-secondary')}
>
<Lock className="mr-2 h-5 w-5" />
<Trans>Security</Trans>
</Button>
</Link>
</div>
);
};
@@ -0,0 +1,200 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { type INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Trans, useLingui } from '@lingui/react/macro';
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
const SEARCH_THRESHOLD = 5;
export type SettingsOrgSwitcherProps = {
currentOrgUrl: string;
};
export const SettingsOrgSwitcher = ({ currentOrgUrl }: SettingsOrgSwitcherProps) => {
const { t } = useLingui();
const { organisations } = useSession();
const { pathname } = useLocation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const manageableOrgs = useMemo(
() =>
organisations.filter(
(org) =>
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
org.teams.some((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)),
),
[organisations],
);
const currentOrg = manageableOrgs.find((org) => org.url === currentOrgUrl);
const hasManageableBillingOrgs = useMemo(
() => organisations.some((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole)),
[organisations],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) {
return manageableOrgs;
}
return manageableOrgs.filter((org) => org.name.toLowerCase().includes(q));
}, [manageableOrgs, query]);
const isBillingEnabled = IS_BILLING_ENABLED();
const handleSelect = (orgUrl: string) => {
const destinationOrg = manageableOrgs.find((org) => org.url === orgUrl);
if (!destinationOrg) {
return;
}
const manageableTeam = destinationOrg.teams.find((team) =>
canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole),
);
const destinationGroups = getSettingsNavGroups({
organisation: {
url: destinationOrg.url,
currentOrganisationRole: destinationOrg.currentOrganisationRole,
organisationClaim: destinationOrg.organisationClaim,
},
team: manageableTeam ? { url: manageableTeam.url, currentTeamRole: manageableTeam.currentTeamRole } : null,
hasManageableBillingOrgs,
});
// The list also contains organisations the user can only reach through a team they
// manage — for those `getSettingsNavGroups` returns no organisation group, so we land
// them in the team group instead of on an organisation page they aren't authorised for.
const destinationGroup = destinationGroups.organisation ?? destinationGroups.team;
if (!destinationGroup) {
return;
}
setIsOpen(false);
void navigate(
computeSwitcherContinuityPath({
currentPath: pathname,
destinationPaths: destinationGroup.items.map((item) => item.path),
fallbackPath: destinationGroup.items[0].path,
}),
);
};
if (!currentOrg) {
return null;
}
// Resolve an organisation's plan label. Unknown or custom claims (including
// self-hosted custom claim IDs) fall back to "Custom Plan".
const getPlanName = (organisationClaimId: string | null) => {
const planClaim =
organisationClaimId && organisationClaimId in internalClaims
? internalClaims[organisationClaimId as INTERNAL_CLAIM_ID]
: undefined;
return planClaim ? t`${planClaim.name} Plan` : t`Custom Plan`;
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
data-testid="settings-org-switcher-trigger"
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
>
<AvatarWithText
className="max-w-none"
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(currentOrg.avatarImageId)}
avatarFallback={currentOrg.name.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold text-muted-foreground">{currentOrg.name}</span>}
secondaryText={
isBillingEnabled ? getPlanName(currentOrg.organisationClaim.originalSubscriptionClaimId) : undefined
}
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
data-testid="settings-org-switcher-content"
>
{manageableOrgs.length >= SEARCH_THRESHOLD && (
<div className="border-b p-2">
<div className="relative">
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t`Search organisations…`}
className="h-8 pl-7"
data-testid="settings-org-switcher-search"
/>
</div>
</div>
)}
<ul className="max-h-72 space-y-1 overflow-auto p-1">
{filtered.map((org) => {
const isCurrent = org.url === currentOrgUrl;
return (
<li key={org.id}>
<button
type="button"
onClick={() => handleSelect(org.url)}
className={cn(
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
isCurrent && 'bg-muted',
)}
data-testid={`settings-org-switcher-item-${org.url}`}
>
<AvatarWithText
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(org.avatarImageId)}
avatarFallback={org.name.slice(0, 1).toUpperCase()}
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{org.name}</span>}
secondaryText={
isBillingEnabled ? getPlanName(org.organisationClaim.originalSubscriptionClaimId) : undefined
}
/>
</button>
</li>
);
})}
</ul>
<div className="border-t p-1">
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-org-switcher-create">
<a href="/settings/organisations?action=add-organisation">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Create organisation</Trans>
</a>
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,58 @@
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Trans } from '@lingui/react/macro';
import { Building2Icon, ChevronRightIcon, UserIcon, Users2Icon } from 'lucide-react';
export type SettingsScopeBreadcrumbProps = {
scope: 'organisation' | 'team' | 'account';
scopeName: string;
crumbs: string[];
};
export const SettingsScopeBreadcrumb = ({ scope, scopeName, crumbs }: SettingsScopeBreadcrumbProps) => {
return (
<nav
aria-label="settings-scope-breadcrumb"
className="mb-4 flex flex-wrap items-center gap-2 text-muted-foreground text-sm"
data-testid="settings-scope-breadcrumb"
>
<span>{scopeName}</span>
{crumbs.map((crumb, idx) => {
const isLeaf = idx === crumbs.length - 1;
return (
<span key={`${crumb}-${idx}`} className="flex items-center gap-2">
<ChevronRightIcon className="h-3.5 w-3.5 opacity-50" />
<span className={cn(isLeaf && 'font-semibold text-foreground')}>{crumb}</span>
</span>
);
})}
<Badge
variant={scope === 'organisation' ? 'default' : scope === 'team' ? 'secondary' : 'neutral'}
role="presentation"
className="ml-auto gap-1.5"
data-testid="settings-scope-breadcrumb-chip"
>
{scope === 'organisation' && (
<>
<Building2Icon className="h-3.5 w-3.5" />
<Trans>Organisation Settings</Trans>
</>
)}
{scope === 'team' && (
<>
<Users2Icon className="h-3.5 w-3.5" />
<Trans>Team Settings</Trans>
</>
)}
{scope === 'account' && (
<>
<UserIcon className="h-3.5 w-3.5" />
<Trans>Account Settings</Trans>
</>
)}
</Badge>
</nav>
);
};
@@ -0,0 +1,166 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { getSettingsNavGroups } from '@documenso/lib/utils/settings-nav';
import { computeSwitcherContinuityPath } from '@documenso/lib/utils/settings-switcher';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Trans, useLingui } from '@lingui/react/macro';
import { ChevronsUpDownIcon, PlusIcon, SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
const SEARCH_THRESHOLD = 5;
export type SettingsTeamSwitcherProps = {
currentOrgUrl: string;
currentTeamUrl: string | null;
};
export const SettingsTeamSwitcher = ({ currentOrgUrl, currentTeamUrl }: SettingsTeamSwitcherProps) => {
const { t } = useLingui();
const { organisations } = useSession();
const { pathname } = useLocation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const currentOrg = organisations.find((org) => org.url === currentOrgUrl);
const manageableTeams = useMemo(
() =>
currentOrg ? currentOrg.teams.filter((team) => canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) : [],
[currentOrg],
);
const currentTeam = manageableTeams.find((team) => team.url === currentTeamUrl) ?? manageableTeams[0];
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) {
return manageableTeams;
}
return manageableTeams.filter((team) => team.name.toLowerCase().includes(q));
}, [manageableTeams, query]);
const handleSelect = (teamUrl: string) => {
const destinationTeam = manageableTeams.find((team) => team.url === teamUrl);
if (!currentOrg || !destinationTeam) {
return;
}
const destinationGroup = getSettingsNavGroups({
organisation: {
url: currentOrg.url,
currentOrganisationRole: currentOrg.currentOrganisationRole,
organisationClaim: currentOrg.organisationClaim,
},
team: { url: destinationTeam.url, currentTeamRole: destinationTeam.currentTeamRole },
hasManageableBillingOrgs: false,
}).team;
if (!destinationGroup) {
return;
}
setIsOpen(false);
void navigate(
computeSwitcherContinuityPath({
currentPath: pathname,
destinationPaths: destinationGroup.items.map((item) => item.path),
fallbackPath: destinationGroup.items[0].path,
}),
);
};
if (!currentOrg || !currentTeam) {
return null;
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
data-testid="settings-team-switcher-trigger"
className="flex h-auto w-full items-center justify-start gap-2 rounded-lg border bg-background px-1.5 py-1 hover:bg-muted"
>
<AvatarWithText
className="max-w-none"
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(currentTeam.avatarImageId)}
avatarFallback={currentTeam.name.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold text-muted-foreground">{currentTeam.name}</span>}
rightSideComponent={<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" />}
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
data-testid="settings-team-switcher-content"
>
{manageableTeams.length >= SEARCH_THRESHOLD && (
<div className="border-b p-2">
<div className="relative">
<SearchIcon className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t`Search teams…`}
className="h-8 pl-7"
data-testid="settings-team-switcher-search"
/>
</div>
</div>
)}
<ul className="max-h-72 space-y-1 overflow-auto p-1">
{filtered.map((team) => {
const isCurrent = team.url === currentTeam.url;
return (
<li key={team.id}>
<button
type="button"
onClick={() => handleSelect(team.url)}
className={cn(
'flex w-full items-center rounded-md px-2 py-2 text-left hover:bg-muted',
isCurrent && 'bg-muted',
)}
data-testid={`settings-team-switcher-item-${team.url}`}
>
<AvatarWithText
avatarClass="h-8 w-8"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={team.name.slice(0, 1).toUpperCase()}
primaryText={<span className={cn(isCurrent && 'font-semibold')}>{team.name}</span>}
secondaryText={t(EXTENDED_TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}
/>
</button>
</li>
);
})}
</ul>
<div className="border-t p-1">
<Button variant="ghost" asChild className="w-full justify-start" data-testid="settings-team-switcher-create">
<a href={`/o/${currentOrg.url}/settings/teams?action=add-team`}>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Create team</Trans>
</a>
</Button>
</div>
</PopoverContent>
</Popover>
);
};
@@ -1,94 +0,0 @@
import type { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog';
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
export type TeamEmailDropdownProps = {
team: Awaited<ReturnType<typeof getTeamWithEmail>>;
};
export const TeamEmailDropdown = ({ team }: TeamEmailDropdownProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
trpc.team.email.verification.resend.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Email verification has been resent`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to resend verification at this time. Please try again.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
{!team.teamEmail && team.emailVerification && (
<DropdownMenuItem
disabled={isResendingEmailVerification}
onClick={(e) => {
e.preventDefault();
void resendEmailVerification({ teamId: team.id });
}}
>
{isResendingEmailVerification ? (
<Loader className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
<Trans>Resend verification</Trans>
</DropdownMenuItem>
)}
{team.teamEmail && (
<TeamEmailUpdateDialog
teamEmail={team.teamEmail}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
}
/>
)}
<TeamEmailDeleteDialog
team={team}
teamName={team.name}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<X className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,211 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { getSettingsNavGroups, type SettingsNavGroup, type SettingsNavItem } from '@documenso/lib/utils/settings-nav';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useEffect, useRef } from 'react';
import { Link, Outlet, useLocation } from 'react-router';
import { match } from 'ts-pattern';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useOptionalCurrentTeam } from '~/providers/team';
import { SettingsScopeBreadcrumb } from './settings-scope-breadcrumb';
import { UnifiedSettingsSidebar } from './unified-settings-sidebar';
import { UnifiedSettingsSidebarMobile } from './unified-settings-sidebar-mobile';
export type UnifiedSettingsScope = 'organisation' | 'team' | 'account';
export type UnifiedSettingsLayoutProps = {
activeScope: UnifiedSettingsScope;
/**
* The team the user last worked in, read from the `preferred-team-url` cookie by the
* layout's loader. Used to keep the sidebar's team switcher stable at organisation and
* account scope, where the URL carries no team.
*/
preferredTeamUrl?: string | null;
};
/**
* Walk a group's items and find the item (and its parent if it's a sub-nav child)
* that matches the current pathname most specifically. Returns the labels to use
* as breadcrumb crumbs (e.g. ['Preferences', 'Document'] or just ['Members']).
*/
const findActiveCrumbs = (group: SettingsNavGroup | null, pathname: string): MessageDescriptor[] => {
if (!group) {
return [];
}
let bestMatch: SettingsNavItem | null = null;
for (const item of group.items) {
if (item.isSubNavParent) {
continue;
}
if (pathname === item.path || pathname.startsWith(`${item.path}/`)) {
if (!bestMatch || item.path.length > bestMatch.path.length) {
bestMatch = item;
}
}
}
if (!bestMatch) {
return [];
}
if (bestMatch.isSubNav) {
const parent = group.items.find((it) => it.isSubNavParent);
return parent ? [parent.label, bestMatch.label] : [bestMatch.label];
}
return [bestMatch.label];
};
export const UnifiedSettingsLayout = ({ activeScope, preferredTeamUrl = null }: UnifiedSettingsLayoutProps) => {
const { _ } = useLingui();
const { organisations } = useSession();
const { pathname } = useLocation();
const currentOrganisation = useOptionalCurrentOrganisation();
const team = useOptionalCurrentTeam();
const contentPaneRef = useRef<HTMLElement>(null);
// Scroll back to the top when navigating between settings pages.
useEffect(() => {
contentPaneRef.current?.scrollTo(0, 0);
}, [pathname]);
// An organisation is worth showing in the sidebar if it has settings the user can reach —
// either the organisation's own, or those of a team inside it.
const hasReachableSettings = (org: (typeof organisations)[number]) =>
canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) ||
org.teams.some((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
const organisation =
currentOrganisation ??
organisations.find((org) => org.teams.some((t) => t.url === preferredTeamUrl) && hasReachableSettings(org)) ??
organisations.find(hasReachableSettings) ??
null;
const manageableTeams = organisation?.teams.filter((t) => canExecuteTeamAction('MANAGE_TEAM', t.currentTeamRole));
const teamForSidebar =
team ?? manageableTeams?.find((t) => t.url === preferredTeamUrl) ?? manageableTeams?.[0] ?? null;
const sidebarTeamUrl = teamForSidebar?.url ?? null;
// Sync the selected team URL in the sidebar into the preferred team URL cookie.
useEffect(() => {
if (!sidebarTeamUrl) {
return;
}
const body = new FormData();
body.append('teamUrl', sidebarTeamUrl);
void fetch('/api/preferred-team', { method: 'POST', body });
}, [sidebarTeamUrl]);
const groups = getSettingsNavGroups({
organisation: organisation
? {
url: organisation.url,
currentOrganisationRole: organisation.currentOrganisationRole,
organisationClaim: organisation.organisationClaim,
}
: null,
team: teamForSidebar ? { url: teamForSidebar.url, currentTeamRole: teamForSidebar.currentTeamRole } : null,
hasManageableBillingOrgs: organisations.some((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
),
});
const canManageOrg =
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
// Must be derived from the team in the URL context, NOT from `teamForSidebar` — the
// latter falls back to any manageable team in the org, which would let a team manager
// through the organisation-scope guard (and `useOptionalCurrentTeam()` resolves for any
// member regardless of role, which would let a plain member through the team guard).
const canManageCurrentTeam = team !== null && canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole);
// Account pages are available to every user. The organisation and team scopes each
// require the manage permission for THAT scope — they are not interchangeable.
const isAuthorised = match(activeScope)
.with('account', () => true)
.with('organisation', () => canManageOrg)
.with('team', () => canManageCurrentTeam)
.exhaustive();
if (!isAuthorised) {
return (
<GenericErrorLayout
errorCode={401}
errorCodeMap={{
401: {
heading: msg`Unauthorized`,
subHeading: msg`401 Unauthorized`,
message: msg`You are not authorized to access this page.`,
},
}}
primaryButton={
<Button asChild>
<Link to="/settings">
<Trans>Go to your settings</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
const scopeName = match(activeScope)
.with('account', () => _(msg`Account`))
.with('organisation', () => organisation?.name ?? '')
.with('team', () => team?.name ?? organisation?.name ?? '')
.exhaustive();
const activeGroup =
activeScope === 'account' ? groups.account : activeScope === 'organisation' ? groups.organisation : groups.team;
const crumbs = findActiveCrumbs(activeGroup, pathname).map((label) => _(label));
return (
<div className="flex flex-col md:min-h-0 md:flex-1 md:flex-row">
<aside className="hover-scrollbar w-full shrink-0 border-b bg-background md:w-80 md:overflow-y-auto md:border-r md:border-b-0">
<div className="flex h-full flex-col">
<div className="hidden md:block">
<UnifiedSettingsSidebar
groups={groups}
currentOrgUrl={organisation?.url ?? null}
currentTeamUrl={teamForSidebar?.url ?? null}
/>
</div>
<div className="md:hidden">
<UnifiedSettingsSidebarMobile
groups={groups}
activeScope={activeScope}
currentOrgUrl={organisation?.url ?? null}
currentTeamUrl={teamForSidebar?.url ?? null}
/>
</div>
</div>
</aside>
<main ref={contentPaneRef} className="relative flex-1 px-4 md:overflow-y-auto md:px-12 lg:px-16">
<div className="mx-auto w-full max-w-3xl py-6 md:py-8" data-testid="unified-settings-content">
<SettingsScopeBreadcrumb scope={activeScope} scopeName={scopeName} crumbs={crumbs} />
<Outlet />
</div>
</main>
</div>
);
};
@@ -0,0 +1,165 @@
import type { SettingsNavGroups, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
import { cn } from '@documenso/ui/lib/utils';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { SettingsOrgSwitcher } from './settings-org-switcher';
import { SettingsTeamSwitcher } from './settings-team-switcher';
type MobileScope = SettingsNavScope | 'account';
export type UnifiedSettingsSidebarMobileProps = {
groups: SettingsNavGroups;
activeScope: MobileScope;
currentOrgUrl: string | null;
currentTeamUrl: string | null;
};
export const UnifiedSettingsSidebarMobile = ({
groups,
activeScope,
currentOrgUrl,
currentTeamUrl,
}: UnifiedSettingsSidebarMobileProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
const navigate = useNavigate();
const visibleScopes = useMemo<MobileScope[]>(() => {
const scopes: MobileScope[] = [];
if (groups.organisation) {
scopes.push('organisation');
}
if (groups.team) {
scopes.push('team');
}
scopes.push('account');
return scopes;
}, [groups]);
// Falls back to the Account group rather than rendering nothing — an empty scope group
// would leave the mobile viewport with no settings navigation at all.
const activeGroup =
(activeScope === 'organisation' ? groups.organisation : activeScope === 'team' ? groups.team : null) ??
groups.account;
const selectableItems = useMemo(() => activeGroup.items.filter((item) => !item.isSubNavParent), [activeGroup.items]);
// The select matches on exact value, but plenty of settings pages are sub-routes of a
// nav item (`/settings/security/passkeys`, `/t/x/settings/webhooks/:id`, …). Resolving
// to the longest matching item path keeps the trigger populated on those pages instead
// of rendering an empty box — mirrors the desktop sidebar's prefix highlighting.
const selectedPath = useMemo(() => {
let bestMatch: string | undefined;
for (const item of selectableItems) {
if (pathname !== item.path && !pathname.startsWith(`${item.path}/`)) {
continue;
}
if (!bestMatch || item.path.length > bestMatch.length) {
bestMatch = item.path;
}
}
return bestMatch;
}, [selectableItems, pathname]);
const handleScopeChange = (scope: MobileScope) => {
if (scope === activeScope) {
return;
}
if (scope === 'organisation' && groups.organisation) {
void navigate(groups.organisation.items[0].path);
} else if (scope === 'team' && groups.team) {
void navigate(groups.team.items[0].path);
} else if (scope === 'account') {
void navigate(groups.account.items[0].path);
}
};
// The tab row stays full-bleed (its border-b acts as a full-width divider); the
// sections under it get `px-4` to line up with the content pane's own `px-4`
// inset, and `pb-4` keeps the last control off the aside's bottom border.
return (
<div className="flex flex-col gap-3 pb-4" data-testid="unified-settings-sidebar-mobile">
{visibleScopes.length > 1 ? (
<div className="flex border-border border-b" role="tablist">
{visibleScopes.map((scope) => {
const isActive = scope === activeScope;
const accent =
scope === 'organisation'
? 'border-emerald-500 text-emerald-700 dark:text-emerald-300'
: scope === 'team'
? 'border-blue-500 text-blue-700 dark:text-blue-300'
: 'border-foreground text-foreground';
return (
<button
key={scope}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => handleScopeChange(scope)}
className={cn(
'flex-1 border-b-2 py-2 text-center font-bold text-xs uppercase tracking-wide',
isActive ? accent : 'border-transparent text-muted-foreground',
)}
data-testid={`unified-settings-mobile-tab-${scope}`}
>
{scope === 'organisation' && <Trans>Organisation</Trans>}
{scope === 'team' && <Trans>Team</Trans>}
{scope === 'account' && <Trans>Account</Trans>}
</button>
);
})}
</div>
) : (
<div className="px-4 py-2 text-center font-bold text-muted-foreground text-xs uppercase tracking-wide">
{activeScope === 'organisation' && <Trans>Organisation</Trans>}
{activeScope === 'team' && <Trans>Team</Trans>}
{activeScope === 'account' && <Trans>Account</Trans>}
</div>
)}
{/* The organisation switcher is always present in a scoped view — at team scope it's
the only way for a user who can't manage the organisation to move between them,
since the Organisation tab is absent when they have no organisation pages. */}
{activeScope !== 'account' && currentOrgUrl && (
<div className="flex flex-col gap-2 px-4">
<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />
{activeScope === 'team' && (
<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />
)}
</div>
)}
<div className="px-4">
<div className="mb-1 font-bold text-[10px] text-muted-foreground uppercase tracking-wide">
<Trans>Jump to</Trans>
</div>
<Select
value={selectedPath}
onValueChange={(value) => {
void navigate(value);
}}
>
<SelectTrigger data-testid="unified-settings-mobile-section-trigger">
<SelectValue placeholder={_(msg`Select a section`)} />
</SelectTrigger>
<SelectContent>
{selectableItems.map((item) => (
<SelectItem key={item.path} value={item.path}>
{item.isSubNav ? `${_(msg`Preferences`)} ${_(item.label)}` : _(item.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
};
@@ -0,0 +1,275 @@
import type { SettingsNavGroups, SettingsNavItem, SettingsNavScope } from '@documenso/lib/utils/settings-nav';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@documenso/ui/primitives/collapsible';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronRightIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { NavLink, useLocation } from 'react-router';
import { SettingsOrgSwitcher } from './settings-org-switcher';
import { SettingsTeamSwitcher } from './settings-team-switcher';
export type UnifiedSettingsSidebarProps = {
groups: SettingsNavGroups;
currentOrgUrl: string | null;
currentTeamUrl: string | null;
};
export const UnifiedSettingsSidebar = ({ groups, currentOrgUrl, currentTeamUrl }: UnifiedSettingsSidebarProps) => {
return (
<aside className="flex w-full flex-col" data-testid="unified-settings-sidebar">
{currentOrgUrl && (
<div className="p-4">
<SidebarGroup
heading={<Trans>Organisation Settings</Trans>}
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
switcher={<SettingsOrgSwitcher currentOrgUrl={currentOrgUrl} />}
items={groups.organisation?.items ?? []}
scope="organisation"
emptyState={
<p
className="rounded-md border border-dashed px-3 py-2 text-muted-foreground text-xs"
data-testid="unified-settings-organisation-empty-state"
>
<Trans>
You don't have permission to manage this organisation. Switch to another one above, or continue in
your team settings below.
</Trans>
</p>
}
/>
</div>
)}
{groups.team && currentOrgUrl && (
<div className="border-t p-4">
<SidebarGroup
heading={<Trans>Team Settings</Trans>}
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
switcher={<SettingsTeamSwitcher currentOrgUrl={currentOrgUrl} currentTeamUrl={currentTeamUrl} />}
items={groups.team.items}
scope={groups.team.scope}
/>
</div>
)}
<div className={cn('p-4', currentOrgUrl && 'border-t')}>
<SidebarGroup
heading={<Trans>Account Settings</Trans>}
activeBgClassName="bg-[#F1FBEA] text-gray-900 hover:bg-[#F1FBEA] hover:text-gray-900 dark:bg-[#1C2515] dark:text-[#F1FBEA] dark:hover:bg-[#1C2515] dark:hover:text-[#F1FBEA]"
items={groups.account.items}
scope={groups.account.scope}
/>
</div>
</aside>
);
};
type SidebarGroupProps = {
className?: string;
headingClassName?: string;
heading: ReactNode;
activeBgClassName: string;
switcher?: ReactNode;
items: SettingsNavItem[];
scope: SettingsNavScope;
emptyState?: ReactNode;
};
type GroupedNavEntry =
| { kind: 'flat'; item: SettingsNavItem }
| { kind: 'collapsible'; parent: SettingsNavItem; children: SettingsNavItem[] };
/**
* Walk a flat item list and group consecutive `isSubNav` items under their preceding
* `isSubNavParent`. Anything else renders as a flat entry.
*/
const groupNavEntries = (items: SettingsNavItem[]): GroupedNavEntry[] => {
const entries: GroupedNavEntry[] = [];
let currentCollapsible: { parent: SettingsNavItem; children: SettingsNavItem[] } | null = null;
for (const item of items) {
if (item.isSubNavParent) {
currentCollapsible = { parent: item, children: [] };
entries.push({ kind: 'collapsible', ...currentCollapsible });
continue;
}
if (item.isSubNav && currentCollapsible) {
currentCollapsible.children.push(item);
continue;
}
currentCollapsible = null;
entries.push({ kind: 'flat', item });
}
return entries;
};
const SidebarGroup = ({
className,
headingClassName,
heading,
activeBgClassName,
switcher,
items,
scope,
emptyState,
}: SidebarGroupProps) => {
const grouped = groupNavEntries(items);
return (
<div className={className} data-testid="unified-settings-sidebar-group">
<div
className={cn('mb-2 font-semibold text-muted-foreground text-xs uppercase tracking-widest', headingClassName)}
>
{heading}
</div>
{switcher && <div className="mb-2">{switcher}</div>}
{items.length === 0 && emptyState}
<nav className="flex flex-col gap-1">
{grouped.map((entry) => {
if (entry.kind === 'flat') {
return (
<FlatNavItem
key={`${entry.item.key}-${entry.item.path}`}
item={entry.item}
activeBgClassName={activeBgClassName}
scope={scope}
/>
);
}
return (
<CollapsibleNavSection
key={`${entry.parent.key}-${entry.parent.path}`}
parent={entry.parent}
childItems={entry.children}
activeBgClassName={activeBgClassName}
scope={scope}
/>
);
})}
</nav>
</div>
);
};
type FlatNavItemProps = {
item: SettingsNavItem;
activeBgClassName: string;
scope: SettingsNavScope;
};
const FlatNavItem = ({ item, activeBgClassName, scope }: FlatNavItemProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
// The General items link to the settings root, which prefixes every other
// item's path those require an exact match. Everything else highlights on
// its sub-routes too (e.g. Security on /settings/security/passkeys).
const isActive =
item.key === 'general' ? pathname === item.path : pathname === item.path || pathname.startsWith(`${item.path}/`);
return (
<NavLink to={item.path} className="group block" data-testid={`unified-settings-nav-${scope}-${item.key}`} end>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-full justify-start font-normal text-muted-foreground',
isActive && cn(activeBgClassName, 'font-medium'),
)}
>
{item.icon && <item.icon className="mr-2 h-4 w-4" />}
{_(item.label)}
</Button>
</NavLink>
);
};
type CollapsibleNavSectionProps = {
parent: SettingsNavItem;
childItems: SettingsNavItem[];
activeBgClassName: string;
scope: SettingsNavScope;
};
const CollapsibleNavSection = ({ parent, childItems, activeBgClassName, scope }: CollapsibleNavSectionProps) => {
const { _ } = useLingui();
const { pathname } = useLocation();
const hasActiveChild = childItems.some((child) => pathname === child.path || pathname.startsWith(`${child.path}/`));
const [isOpen, setIsOpen] = useState(hasActiveChild);
// Auto-open when navigating to a sub-route. Closing while on a sub-route is
// respected (state stays closed) until the user navigates away and back.
useEffect(() => {
if (hasActiveChild) {
setIsOpen(true);
}
}, [hasActiveChild]);
const ParentIcon = parent.icon ? parent.icon : null;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-8 w-full justify-start font-normal text-muted-foreground',
// Emphasise the parent when one of its children is the active page.
hasActiveChild && 'font-medium text-foreground',
)}
data-testid={`unified-settings-nav-${scope}-${parent.key}`}
aria-expanded={isOpen}
>
{ParentIcon && <ParentIcon className="mr-2 h-4 w-4" />}
{_(parent.label)}
<ChevronRightIcon
className={cn('ml-auto h-4 w-4 transition-transform duration-200', isOpen && 'rotate-90')}
/>
</Button>
</CollapsibleTrigger>
{/* data-[state=closed]:hidden — the `flex` display class would otherwise
defeat the `hidden` attribute Radix sets when closed, leaving the
empty content box (and its mt-1) inflating the gap below the trigger. */}
<CollapsibleContent className="mt-1 flex flex-col gap-1 data-[state=closed]:hidden">
{childItems.map((child) => {
const isActive = pathname === child.path || pathname.startsWith(`${child.path}/`);
return (
<NavLink
key={`${child.key}-${child.path}`}
to={child.path}
className="group block pl-6"
data-testid={`unified-settings-nav-${scope}-${child.key}`}
>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-full justify-start font-normal text-muted-foreground',
isActive && cn(activeBgClassName, 'font-medium'),
)}
>
{_(child.label)}
</Button>
</NavLink>
);
})}
</CollapsibleContent>
</Collapsible>
);
};
@@ -44,31 +44,13 @@ export const UserOrganisationsTable = () => {
header: _(msg`Organisation`),
accessorKey: 'name',
cell: ({ row }) => (
<Link
to={isPersonalLayoutMode ? `/settings/organisations` : `/o/${row.original.url}`}
preventScrollReset={true}
>
<Link to={`/o/${row.original.url}`} preventScrollReset={true}>
<AvatarWithText
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
avatarClass="h-12 w-12"
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={
<span className="font-semibold text-foreground/80">
{isPersonalLayoutMode
? _(
msg({
message: `Personal`,
context: `Personal organisation (adjective)`,
}),
)
: row.original.name}
</span>
}
secondaryText={
isPersonalLayoutMode
? _(msg`Your personal organisation`)
: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`
}
primaryText={<span className="font-semibold text-foreground/80">{row.original.name}</span>}
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/o/${row.original.url}`}
/>
</Link>
),
@@ -1,4 +1,5 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
@@ -46,6 +47,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
const { user, organisations } = useSession();
const { layoutMode } = useChildRouteFlags();
const teamUrl = params.teamUrl;
const orgUrl = params.orgUrl;
@@ -108,6 +111,7 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
return (
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={currentTeam || null}>
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
<OrganisationBillingBanner />
<OrganisationQuotaBanner />
@@ -116,15 +120,17 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
{banner && !hideHeader && <AppBanner banner={banner} />}
{!hideHeader && <Header />}
{!hideHeader && <Header fullWidth={layoutMode === 'settings'} />}
<main
className={cn({
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader,
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader && layoutMode !== 'settings',
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
})}
>
<Outlet />
</main>
</div>
</TeamProvider>
</OrganisationProvider>
);
@@ -53,7 +53,7 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
return (
<div>
<SettingsHeader title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`} hideDivider>
<SettingsHeader hideDivider title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`}>
<ClaimCreateDialog licenseFlags={licenseFlags} />
</SettingsHeader>
@@ -40,7 +40,7 @@ export default function AdminEmailTransportsPage() {
return (
<div>
<SettingsHeader title={t`Email Transports`} subtitle={t`Manage all email transports`} hideDivider>
<SettingsHeader hideDivider title={t`Email Transports`} subtitle={t`Manage all email transports`}>
<EmailTransportCreateDialog />
</SettingsHeader>
@@ -258,7 +258,11 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
return (
<div>
<SettingsHeader title={t`Manage organisation`} subtitle={t`Manage the ${organisation.name} organisation`}>
<SettingsHeader
hideDivider
title={t`Manage organisation`}
subtitle={t`Manage the ${organisation.name} organisation`}
>
<Button variant="outline" asChild>
<Link to={`/admin/organisation-insights/${organisationId}`}>
<Trans>View insights</Trans>
@@ -269,10 +273,10 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} />
<SettingsHeader
hideDivider
title={t`Organisation usage`}
subtitle={t`Current usage against organisation limits.`}
className="mt-6"
hideDivider
/>
<OrganisationUsagePanel
@@ -308,6 +312,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
</div>
<SettingsHeader
hideDivider
title={t`Manage subscription`}
subtitle={t`Manage the ${organisation.name} organisation subscription`}
className="mt-16"
@@ -425,6 +430,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
</div>
<SettingsHeader
hideDivider
title={t`Danger Zone`}
subtitle={t`Irreversible actions for this organisation`}
className="mt-16"
@@ -1,13 +1,23 @@
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { cn } from '@documenso/ui/lib/utils';
import { Outlet } from 'react-router';
export default function Layout() {
const currentOrganisation = useCurrentOrganisation();
const { layoutMode } = useChildRouteFlags();
// Note: We use a key to force a re-render if the team context changes.
// This is required otherwise you would see the wrong page content.
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8" key={currentOrganisation.url}>
<div
className={cn({
'mx-auto w-full max-w-screen-xl px-4 md:px-8': layoutMode !== 'settings',
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
})}
key={currentOrganisation.url}
>
<Outlet />
</div>
);
@@ -1,173 +1,27 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import {
Building2Icon,
CreditCardIcon,
GroupIcon,
MailboxIcon,
Settings2Icon,
ShieldCheckIcon,
Users2Icon,
} from 'lucide-react';
import { FaUsers } from 'react-icons/fa6';
import { Link, NavLink, Outlet } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/o.$orgUrl.settings._layout';
export function meta() {
return appMetaTags(msg`Organisation Settings`);
}
export default function SettingsLayout() {
const { t } = useLingui();
export const handle: RouteHandle = {
layoutMode: 'settings',
};
const isBillingEnabled = IS_BILLING_ENABLED();
const organisation = useCurrentOrganisation();
const organisationSettingRoutes = [
{
path: `/o/${organisation.url}/settings/general`,
label: t`General`,
icon: Building2Icon,
},
{
path: `/o/${organisation.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
hideHighlight: true,
},
{
path: `/o/${organisation.url}/settings/document`,
label: t`Document`,
isSubNav: true,
},
{
path: `/o/${organisation.url}/settings/branding`,
label: t`Branding`,
isSubNav: true,
},
{
path: `/o/${organisation.url}/settings/email`,
label: t`Email`,
isSubNav: true,
},
{
path: `/o/${organisation.url}/settings/email-domains`,
label: t`Email Domains`,
icon: MailboxIcon,
},
{
path: `/o/${organisation.url}/settings/teams`,
label: t`Teams`,
icon: FaUsers,
},
{
path: `/o/${organisation.url}/settings/members`,
label: t`Members`,
icon: Users2Icon,
},
{
path: `/o/${organisation.url}/settings/groups`,
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/o/${organisation.url}/settings/sso`,
label: t`SSO`,
icon: ShieldCheckIcon,
},
{
path: `/o/${organisation.url}/settings/billing`,
label: t`Billing`,
icon: CreditCardIcon,
},
].filter((route) => {
if (!isBillingEnabled && route.path.includes('/billing')) {
return false;
}
if (
(!isBillingEnabled || !organisation.organisationClaim.flags.emailDomains) &&
route.path.includes('/email-domains')
) {
return false;
}
if (
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
route.path.includes('/sso')
) {
return false;
}
return true;
});
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
return (
<GenericErrorLayout
errorCode={401}
errorCodeMap={{
401: {
heading: msg`Unauthorized`,
subHeading: msg`401 Unauthorized`,
message: msg`You are not authorized to access this page.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/o/${organisation.url}`}>
<Trans>Go Back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
return (
<div>
<h1 className="font-semibold text-4xl">
<Trans>Organisation Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
{/* Navigation */}
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{organisationSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.hideHighlight,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
export function loader({ request }: Route.LoaderArgs) {
return {
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
};
}
export default function OrganisationSettingsLayout({ loaderData }: Route.ComponentProps) {
return <UnifiedSettingsLayout activeScope="organisation" preferredTeamUrl={loaderData.preferredTeamUrl} />;
}
@@ -129,7 +129,7 @@ export default function OrganisationSettingsBrandingPage() {
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
@@ -0,0 +1,80 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import {
CertificatePreferencesForm,
type TCertificatePreferencesFormSchema,
} from '~/components/forms/certificate-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
export default function OrganisationSettingsCertificatesPage() {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { toast } = useToast();
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const onCertificatePreferencesFormSubmit = async (data: TCertificatePreferencesFormSchema) => {
try {
const { includeSigningCertificate, includeAuditLog } = data;
if (includeSigningCertificate === null || includeAuditLog === null) {
throw new Error('Should not be possible.');
}
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
includeSigningCertificate,
includeAuditLog,
},
});
toast({
title: t`Certificate preferences updated`,
description: t`Your certificate preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your certificate preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
if (isLoadingOrganisation || !organisationWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<SettingsHeader
title={t`Certificates`}
subtitle={t`Here you can set certificate and audit log preferences for your organisation. Teams will inherit these settings by default.`}
/>
<section>
<CertificatePreferencesForm
canInherit={false}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onCertificatePreferencesFormSubmit}
/>
</section>
</div>
);
}
@@ -51,24 +51,16 @@ export default function OrganisationSettingsDocumentPage() {
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
reminderSettings,
} = data;
if (
documentVisibility === null ||
documentLanguage === null ||
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null ||
includeAuditLog === null ||
aiFeaturesEnabled === null
) {
throw new Error('Should not be possible.');
@@ -81,17 +73,12 @@ export default function OrganisationSettingsDocumentPage() {
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
defaultRecipients,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
},
});
@@ -124,7 +111,7 @@ export default function OrganisationSettingsDocumentPage() {
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
<section>
@@ -132,7 +132,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
return (
<div>
<SettingsHeader title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
<SettingsHeader hideDivider title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
</SettingsHeader>
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -19,12 +18,9 @@ export function meta() {
export default function OrganisationSettingsEmailDomains() {
const { t } = useLingui();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isEmailDomainsEnabled = organisation.organisationClaim.flags.emailDomains;
if (!IS_BILLING_ENABLED()) {
@@ -33,7 +29,11 @@ export default function OrganisationSettingsEmailDomains() {
return (
<div>
<SettingsHeader title={t`Email Domains`} subtitle={t`Here you can add email domains to your organisation.`}>
<SettingsHeader
hideDivider
title={t`Email Domains`}
subtitle={t`Here you can add email domains to your organisation.`}
>
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
</SettingsHeader>
@@ -55,7 +55,7 @@ export default function OrganisationSettingsEmailDomains() {
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline">
<Link to={isPersonalLayoutMode ? '/settings/billing' : `/o/${organisation.url}/settings/billing`}>
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Update Billing</Trans>
</Link>
</Button>
@@ -27,7 +27,7 @@ export default function OrganisationSettingsGeneral() {
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
try {
const { emailId, emailReplyTo, emailDocumentSettings } = data;
const { emailId, emailReplyTo, emailDocumentSettings, includeSenderDetails } = data;
await updateOrganisationSettings({
organisationId: organisation.id,
@@ -36,6 +36,7 @@ export default function OrganisationSettingsGeneral() {
emailReplyTo: emailReplyTo || null,
// emailReplyToName,
emailDocumentSettings,
includeSenderDetails: includeSenderDetails ?? undefined,
},
});
@@ -59,7 +60,7 @@ export default function OrganisationSettingsGeneral() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
<section>
@@ -21,7 +21,7 @@ export default function OrganisationSettingsGeneral() {
const organisation = useCurrentOrganisation();
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={_(msg`General`)} subtitle={_(msg`Here you can edit your organisation details.`)} />
<div className="space-y-8">
@@ -30,9 +30,6 @@ export default function OrganisationSettingsGeneral() {
</div>
{canExecuteOrganisationAction('DELETE_ORGANISATION', organisation.currentOrganisationRole) && (
<>
<hr className="my-4" />
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
@@ -46,7 +43,6 @@ export default function OrganisationSettingsGeneral() {
<OrganisationDeleteDialog />
</Alert>
</>
)}
</div>
);
@@ -94,7 +94,11 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
return (
<div>
<SettingsHeader title={t`Organisation Group Settings`} subtitle={t`Manage your organisation group settings.`}>
<SettingsHeader
hideDivider
title={t`Organisation Group Settings`}
subtitle={t`Manage your organisation group settings.`}
>
<OrganisationGroupDeleteDialog
organisationGroupId={groupId}
organisationGroupName={group.name || ''}
@@ -10,6 +10,7 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader
hideDivider
title={t`Custom Organisation Groups`}
subtitle={t`Manage the custom groups of members for your organisation.`}
>
@@ -46,7 +46,11 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader title={_(msg`Organisation Members`)} subtitle={_(msg`Manage the members or invite new members.`)}>
<SettingsHeader
hideDivider
title={_(msg`Organisation Members`)}
subtitle={_(msg`Manage the members or invite new members.`)}
>
<OrganisationMemberInviteDialog />
</SettingsHeader>
@@ -0,0 +1,76 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import {
ReminderPreferencesForm,
type TReminderPreferencesFormSchema,
} from '~/components/forms/reminder-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
export default function OrganisationSettingsRemindersPage() {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { toast } = useToast();
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const onReminderPreferencesFormSubmit = async (data: TReminderPreferencesFormSchema) => {
try {
const { envelopeExpirationPeriod, reminderSettings } = data;
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
},
});
toast({
title: t`Reminder preferences updated`,
description: t`Your reminder preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your reminder preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
if (isLoadingOrganisation || !organisationWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<SettingsHeader
title={t`Reminders`}
subtitle={t`Here you can set expiration and signing reminder preferences for your organisation. Teams will inherit these settings by default.`}
/>
<section>
<ReminderPreferencesForm
canInherit={false}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onReminderPreferencesFormSubmit}
/>
</section>
</div>
);
}
@@ -73,8 +73,9 @@ export default function OrganisationSettingSSOLoginPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
hideDivider
title={t`Organisation SSO Portal`}
subtitle={t`Manage a custom SSO login portal for your organisation.`}
/>
@@ -35,7 +35,7 @@ export default function OrganisationSettingsTeamsPage() {
return (
<div>
<SettingsHeader title={t`Teams`} subtitle={t`Manage the teams in this organisation.`}>
<SettingsHeader hideDivider title={t`Teams`} subtitle={t`Manage the teams in this organisation.`}>
<TeamCreateDialog />
</SettingsHeader>
@@ -1,54 +0,0 @@
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { TrpcProvider } from '@documenso/trpc/react';
import { useEffect } from 'react';
import { Outlet, useNavigate } from 'react-router';
import { TeamProvider } from '~/providers/team';
/**
* These routes should only render if the user has:
*
* - 1 Personal organisation
* - Nothing else
*
* This removes the UX complexity for users who only have a single personal organisation, instead of showing them multiple settings pages:
*
* - Organisation settings
* - Teams settings
*/
export default function Layout() {
const { organisations } = useSession();
const navigate = useNavigate();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const currentOrganisation = organisations[0];
const team = currentOrganisation?.teams[0] || null;
useEffect(() => {
if (!isPersonalLayoutMode || !team) {
void navigate('/settings/profile');
}
}, []);
if (!isPersonalLayoutMode || !team) {
return null;
}
const trpcHeaders = {
'x-team-Id': team.id.toString(),
};
return (
<TrpcProvider headers={trpcHeaders}>
<OrganisationProvider organisation={currentOrganisation}>
<TeamProvider team={team}>
<Outlet />
</TeamProvider>
</OrganisationProvider>
</TrpcProvider>
);
}
@@ -1,5 +0,0 @@
import BillingPage, { meta } from '../../o.$orgUrl.settings.billing';
export { meta };
export default BillingPage;
@@ -1,5 +0,0 @@
import BrandingPage, { meta } from '../../o.$orgUrl.settings.branding';
export { meta };
export default BrandingPage;
@@ -1,5 +0,0 @@
import DocumentPage, { loader, meta } from '../../o.$orgUrl.settings.document';
export { loader, meta };
export default DocumentPage;
@@ -1,5 +0,0 @@
import EmailPage, { meta } from '../../o.$orgUrl.settings.email';
export { meta };
export default EmailPage;
@@ -1,5 +0,0 @@
import PublicProfilePage, { loader, meta } from '../../t.$teamUrl+/settings.public-profile';
export { loader, meta };
export default PublicProfilePage;
@@ -1,5 +0,0 @@
import TokensPage, { meta } from '../../t.$teamUrl+/settings.tokens';
export { meta };
export default TokensPage;
@@ -1,5 +0,0 @@
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id._index';
export { meta };
export default WebhookPage;
@@ -1,5 +0,0 @@
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks._index';
export { meta };
export default WebhookPage;
@@ -1,30 +1,27 @@
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Outlet } from 'react-router';
import { SettingsDesktopNav } from '~/components/general/settings-nav-desktop';
import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Settings`);
}
export default function SettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="font-semibold text-4xl">
<Trans>Settings</Trans>
</h1>
export const handle: RouteHandle = {
layoutMode: 'settings',
};
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<SettingsDesktopNav className="hidden md:col-span-3 md:flex" />
<SettingsMobileNav className="col-span-12 mb-8 md:hidden" />
export function loader({ request }: Route.LoaderArgs) {
return {
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
};
}
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
export default function SettingsLayout({ loaderData }: Route.ComponentProps) {
return <UnifiedSettingsLayout activeScope="account" preferredTeamUrl={loaderData.preferredTeamUrl} />;
}
@@ -17,6 +17,7 @@ export default function SettingsBilling() {
<SettingsHeader
title={t`Billing`}
subtitle={t`Manage billing and subscriptions for organisations where you have billing management permissions.`}
hideDivider
/>
<UserBillingOrganisationsTable />
@@ -14,6 +14,7 @@ export default function TeamsSettingsPage() {
<SettingsHeader
title={_(msg`Organisations`)}
subtitle={_(msg`Manage all organisations you are currently associated with.`)}
hideDivider
>
<OrganisationCreateDialog />
</SettingsHeader>
@@ -32,8 +32,6 @@ export default function SettingsProfile() {
<AvatarImageForm className="mb-8 max-w-xl" />
<ProfileForm className="mb-8 max-w-xl" />
<hr className="my-4 max-w-xl" />
<div className="max-w-xl space-y-8">
<AnimatePresence>
{(!isPersonalLayoutMode || user.email !== teamEmail?.email) && teamEmail && (
@@ -66,13 +66,7 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
{hasEmailPasswordAccount && (
<>
<PasswordForm user={user} />
<hr className="mt-6 border-border/50" />
</>
)}
{hasEmailPasswordAccount && <PasswordForm user={user} />}
<Alert className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
@@ -15,9 +15,9 @@ export default function SettingsSecurityActivity() {
return (
<div>
<SettingsHeader
hideDivider
title={_(msg`Security activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
/>
<div className="mt-4">
@@ -15,7 +15,7 @@ export default function SettingsPasskeys() {
return (
<div>
<SettingsHeader title={_(msg`Passkeys`)} subtitle={_(msg`Manage your passkeys.`)} hideDivider={true}>
<SettingsHeader hideDivider title={_(msg`Passkeys`)} subtitle={_(msg`Manage your passkeys.`)}>
<PasskeyCreateDialog />
</SettingsHeader>
@@ -1,8 +1,10 @@
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
@@ -17,6 +19,8 @@ export default function Layout() {
const team = useOptionalCurrentTeam();
const organisation = useOptionalCurrentOrganisation();
const { layoutMode } = useChildRouteFlags();
const limits = useMemo(() => {
if (!organisation) {
return undefined;
@@ -78,7 +82,7 @@ export default function Layout() {
// Note: We use a key to force a re-render if the team context changes.
// This is required otherwise you would see the wrong page content.
return (
<div key={team.url}>
<div key={team.url} className={cn({ 'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings' })}>
<TrpcProvider headers={trpcHeaders}>
<LimitsProvider initialValue={limits} teamId={team.id}>
<Outlet />
@@ -72,7 +72,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
updatedAt: envelope.updatedAt,
documentMeta: envelope.documentMeta,
},
recipients: envelope.recipients,
recipients: envelope.recipients.map((recipient) => ({
id: recipient.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
})),
documentRootPath,
userId: user.id,
};
@@ -118,7 +123,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
},
];
const formatRecipientText = (recipient: Recipient) => {
const formatRecipientText = (recipient: Pick<Recipient, 'email' | 'name' | 'role'>) => {
let text = recipient.email;
if (recipient.name) {
@@ -1,162 +1,11 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Trans } from '@lingui/react/macro';
import { CheckCircle2, Clock } from 'lucide-react';
import { match, P } from 'ts-pattern';
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { TeamUpdateForm } from '~/components/forms/team-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
import { useCurrentTeam } from '~/providers/team';
import { redirect } from 'react-router';
import type { Route } from './+types/settings._index';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamWithEmail({
userId: user.id,
teamUrl: params.teamUrl,
});
return {
team,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { team } = loaderData;
const currentTeam = useCurrentTeam();
return (
<div className="max-w-2xl">
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
<AvatarImageForm team={currentTeam} className="mb-8" />
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">
{(team.teamEmail || team.emailVerification) && (
<Alert className="p-6" variant="neutral">
<AlertTitle>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
You can view documents associated with this email and use this identity when sending documents.
</Trans>
</AlertDescription>
<hr className="mt-2 border-border/50" />
<div className="flex flex-row items-center justify-between pt-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
primaryText={
<span className="font-semibold text-foreground/80 text-sm">
{team.teamEmail?.name || team.emailVerification?.name}
</span>
export function loader({ params }: Route.LoaderArgs) {
if (params.teamUrl) {
throw redirect(`/t/${params.teamUrl}/settings/general`);
}
secondaryText={
<span className="text-sm">{team.teamEmail?.email || team.emailVerification?.email}</span>
}
/>
<div className="flex flex-row items-center pr-2">
<div className="mr-4 flex flex-row items-center text-muted-foreground text-sm xl:mr-8">
{match({
teamEmail: team.teamEmail,
emailVerification: team.emailVerification,
})
.with({ teamEmail: P.not(null) }, () => (
<>
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
<Trans>Active</Trans>
</>
))
.with(
{
emailVerification: P.when(
(emailVerification) => emailVerification && emailVerification?.expiresAt < new Date(),
),
},
() => (
<>
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
<Trans>Expired</Trans>
</>
),
)
.with({ emailVerification: P.not(null) }, () => (
<>
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
<Trans>Awaiting email confirmation</Trans>
</>
))
.otherwise(() => null)}
</div>
<TeamEmailDropdown team={team} />
</div>
</div>
</Alert>
)}
{!team.teamEmail && !team.emailVerification && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<ul className="mt-0.5 list-inside list-disc text-muted-foreground text-sm">
{/* Feature not available yet. */}
{/* <li>Display this name and email when sending documents</li> */}
{/* <li>View documents associated with this email</li> */}
<span>
<Trans>View documents associated with this email</Trans>
</span>
</ul>
</AlertDescription>
</div>
<TeamEmailAddDialog teamId={team.id} />
</Alert>
)}
{canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Delete team</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
This team, and any associated data excluding billing invoices will be permanently deleted.
</Trans>
</AlertDescription>
</div>
<TeamDeleteDialog teamId={team.id} teamName={team.name} redirectTo="/dashboard" />
</Alert>
)}
</section>
</div>
);
throw redirect('/');
}
@@ -1,15 +1,11 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { BracesIcon, Globe2Icon, GroupIcon, Settings2Icon, SettingsIcon, Users2Icon, WebhookIcon } from 'lucide-react';
import { Link, NavLink, Outlet, redirect } from 'react-router';
import { redirect } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentTeam } from '~/providers/team';
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/settings._layout';
@@ -18,6 +14,10 @@ export function meta() {
return appMetaTags(msg`Team Settings`);
}
export const handle: RouteHandle = {
layoutMode: 'settings',
};
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
@@ -35,123 +35,6 @@ export async function clientLoader() {
// Do nothing, we only want the loader to run on SSR.
}
export default function TeamsSettingsLayout() {
const { t } = useLingui();
const team = useCurrentTeam();
const teamSettingRoutes = [
{
path: `/t/${team.url}/settings`,
label: t`General`,
icon: SettingsIcon,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
isSubNavParent: true,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Document`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/branding`,
label: t`Branding`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/email`,
label: t`Email`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/public-profile`,
label: t`Public Profile`,
icon: Globe2Icon,
},
{
path: `/t/${team.url}/settings/members`,
label: t`Members`,
icon: Users2Icon,
},
{
path: `/t/${team.url}/settings/groups`,
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/t/${team.url}/settings/tokens`,
label: t`API Tokens`,
icon: BracesIcon,
},
{
path: `/t/${team.url}/settings/webhooks`,
label: t`Webhooks`,
icon: WebhookIcon,
},
];
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
return (
<GenericErrorLayout
errorCode={401}
errorCodeMap={{
401: {
heading: msg`Unauthorized`,
subHeading: msg`401 Unauthorized`,
message: msg`You are not authorized to access this page.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}`}>
<Trans>Go Back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="font-semibold text-4xl">
<Trans>Team Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{teamSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.isSubNavParent,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
export default function TeamSettingsLayout() {
return <UnifiedSettingsLayout activeScope="team" />;
}
@@ -115,7 +115,7 @@ export default function TeamsSettingsPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
title={t`Branding Preferences`}
subtitle={t`Here you can set preferences and defaults for branding.`}
@@ -0,0 +1,76 @@
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import {
CertificatePreferencesForm,
type TCertificatePreferencesFormSchema,
} from '~/components/forms/certificate-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsCertificatesPage() {
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
teamReference: team.id,
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const onCertificatePreferencesFormSubmit = async (data: TCertificatePreferencesFormSchema) => {
try {
const { includeSigningCertificate, includeAuditLog } = data;
await updateTeamSettings({
teamId: team.id,
data: {
includeSigningCertificate,
includeAuditLog,
},
});
toast({
title: t`Certificate preferences updated`,
description: t`Your certificate preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your certificate preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
if (isLoadingTeam || !teamWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<SettingsHeader
title={t`Certificates`}
subtitle={t`Here you can set certificate and audit log preferences for your team.`}
/>
<section>
<CertificatePreferencesForm
canInherit={true}
settings={teamWithSettings.teamSettings}
onFormSubmit={onCertificatePreferencesFormSubmit}
/>
</section>
</div>
);
}
@@ -2,7 +2,6 @@ import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
@@ -13,11 +12,6 @@ import {
} from '~/components/forms/document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Document Preferences`);
}
export const loader = () => {
return {
@@ -46,15 +40,10 @@ export default function TeamsSettingsPage() {
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
reminderSettings,
} = data;
await updateTeamSettings({
@@ -64,13 +53,8 @@ export default function TeamsSettingsPage() {
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
defaultRecipients,
aiFeaturesEnabled,
envelopeExpirationPeriod,
reminderSettings,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,
@@ -110,7 +94,7 @@ export default function TeamsSettingsPage() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
title={t`Document Preferences`}
subtitle={t`Here you can set preferences and defaults for your team.`}
@@ -1,17 +1,11 @@
import { trpc } from '@documenso/trpc/react';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { EmailPreferencesForm, type TEmailPreferencesFormSchema } from '~/components/forms/email-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags(msg`Settings`);
}
export default function TeamEmailSettingsGeneral() {
const { t } = useLingui();
@@ -27,7 +21,7 @@ export default function TeamEmailSettingsGeneral() {
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
try {
const { emailId, emailReplyTo, emailDocumentSettings } = data;
const { emailId, emailReplyTo, emailDocumentSettings, includeSenderDetails } = data;
await updateTeamSettings({
teamId: team.id,
@@ -36,6 +30,7 @@ export default function TeamEmailSettingsGeneral() {
emailReplyTo,
// emailReplyToName,
emailDocumentSettings,
includeSenderDetails,
},
});
@@ -59,7 +54,7 @@ export default function TeamEmailSettingsGeneral() {
}
return (
<div className="max-w-2xl">
<div>
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
<section>
@@ -0,0 +1,281 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { buildTeamWhereQuery, canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { CheckCircle2, Clock, EditIcon, LoaderIcon, MailIcon, MoreHorizontalIcon, XIcon } from 'lucide-react';
import { redirect } from 'react-router';
import { match, P } from 'ts-pattern';
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog';
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { TeamUpdateForm } from '~/components/forms/team-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/settings.general';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
if (!user || !params.teamUrl) {
throw redirect('/');
}
const team = await prisma.team.findUnique({
where: {
...buildTeamWhereQuery({
teamId: undefined,
userId: user.id,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
url: params.teamUrl,
},
include: {
teamEmail: {
select: {
email: true,
name: true,
},
},
emailVerification: {
select: {
email: true,
name: true,
expiresAt: true,
},
},
},
});
if (!team) {
throw redirect('/');
}
return {
teamEmail: team.teamEmail
? {
email: team.teamEmail?.email,
name: team.teamEmail.name,
}
: null,
emailVerification: team.emailVerification
? {
email: team.emailVerification?.email,
name: team.emailVerification.name,
expiresAt: team.emailVerification.expiresAt,
}
: null,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { t } = useLingui();
const { toast } = useToast();
const { teamEmail, emailVerification } = loaderData;
const team = useCurrentTeam();
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
trpc.team.email.verification.resend.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Email verification has been resent`,
duration: 5000,
});
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`Unable to resend verification at this time. Please try again.`,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<div>
<SettingsHeader title={t`General settings`} subtitle={t`Here you can edit your team's details.`} />
<AvatarImageForm team={team} className="mb-8" />
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">
{(teamEmail || emailVerification) && (
<Alert className="p-6" variant="neutral">
<AlertTitle>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
You can view documents associated with this email and use this identity when sending documents.
</Trans>
</AlertDescription>
<hr className="mt-2 border-border/50" />
<div className="flex flex-row items-center justify-between pt-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials((teamEmail?.name || emailVerification?.name) ?? '')}
primaryText={
<span className="font-semibold text-foreground/80 text-sm">
{teamEmail?.name || emailVerification?.name}
</span>
}
secondaryText={<span className="text-sm">{teamEmail?.email || emailVerification?.email}</span>}
/>
<div className="flex flex-row items-center pr-2">
<div className="mr-4 flex flex-row items-center text-muted-foreground text-sm xl:mr-8">
{match({
teamEmail,
emailVerification: emailVerification,
})
.with({ teamEmail: P.not(null) }, () => (
<>
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
<Trans>Active</Trans>
</>
))
.with(
{
emailVerification: P.when(
(emailVerification) => emailVerification && emailVerification?.expiresAt < new Date(),
),
},
() => (
<>
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
<Trans>Expired</Trans>
</>
),
)
.with({ emailVerification: P.not(null) }, () => (
<>
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
<Trans>Awaiting email confirmation</Trans>
</>
))
.otherwise(() => null)}
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
{!teamEmail && emailVerification && (
<DropdownMenuItem
disabled={isResendingEmailVerification}
onClick={(e) => {
e.preventDefault();
void resendEmailVerification({ teamId: team.id });
}}
>
{isResendingEmailVerification ? (
<LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
) : (
<MailIcon className="mr-2 h-4 w-4" />
)}
<Trans>Resend verification</Trans>
</DropdownMenuItem>
)}
{teamEmail && (
<TeamEmailUpdateDialog
teamId={team.id}
teamEmail={teamEmail}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
}
/>
)}
<TeamEmailDeleteDialog
team={team}
teamEmail={teamEmail}
emailVerification={emailVerification}
teamName={team.name}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<XIcon className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Alert>
)}
{!teamEmail && !emailVerification && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<ul className="mt-0.5 list-inside list-disc text-muted-foreground text-sm">
{/* Feature not available yet. */}
{/* <li>Display this name and email when sending documents</li> */}
{/* <li>View documents associated with this email</li> */}
<span>
<Trans>View documents associated with this email</Trans>
</span>
</ul>
</AlertDescription>
</div>
<TeamEmailAddDialog teamId={team.id} />
</Alert>
)}
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Delete team</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
This team, and any associated data excluding billing invoices will be permanently deleted.
</Trans>
</AlertDescription>
</div>
<TeamDeleteDialog teamId={team.id} teamName={team.name} redirectTo="/dashboard" />
</Alert>
)}
</section>
</div>
);
}
@@ -56,7 +56,7 @@ export default function TeamsSettingsGroupsPage() {
return (
<div>
<SettingsHeader title={t`Team Groups`} subtitle={t`Manage the groups assigned to this team.`}>
<SettingsHeader hideDivider title={t`Team Groups`} subtitle={t`Manage the groups assigned to this team.`}>
<TeamGroupCreateDialog />
</SettingsHeader>
@@ -40,7 +40,7 @@ export default function TeamsSettingsMembersPage() {
return (
<div>
<SettingsHeader title={t`Team Members`} subtitle={t`Manage the members of your team.`}>
<SettingsHeader hideDivider title={t`Team Members`} subtitle={t`Manage the members of your team.`}>
<TeamMemberCreateDialog />
</SettingsHeader>
@@ -128,8 +128,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
}, [profile.enabled]);
return (
<div className="max-w-2xl">
<div>
<SettingsHeader
hideDivider
title={t`Public Profile`}
subtitle={t`You can choose to enable or disable the profile for public view.`}
>
@@ -189,9 +190,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
<div className="mt-4">
<SettingsHeader
hideDivider
title={t`Templates`}
subtitle={t`Show templates in your public profile for your audience to sign and get started quickly`}
hideDivider={true}
className="mt-8 [&>*>h3]:text-base"
>
<ManagePublicTemplateDialog
@@ -0,0 +1,76 @@
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import {
ReminderPreferencesForm,
type TReminderPreferencesFormSchema,
} from '~/components/forms/reminder-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsRemindersPage() {
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
teamReference: team.id,
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const onReminderPreferencesFormSubmit = async (data: TReminderPreferencesFormSchema) => {
try {
const { envelopeExpirationPeriod, reminderSettings } = data;
await updateTeamSettings({
teamId: team.id,
data: {
envelopeExpirationPeriod,
reminderSettings,
},
});
toast({
title: t`Reminder preferences updated`,
description: t`Your reminder preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your reminder preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
if (isLoadingTeam || !teamWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div>
<SettingsHeader
title={t`Reminders`}
subtitle={t`Here you can set expiration and signing reminder preferences for your team.`}
/>
<section>
<ReminderPreferencesForm
canInherit={true}
settings={teamWithSettings.teamSettings}
onFormSubmit={onReminderPreferencesFormSubmit}
/>
</section>
</div>
);
}
@@ -84,6 +84,7 @@ export default function ApiTokensPage() {
return (
<div>
<SettingsHeader
hideDivider
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
@@ -216,6 +216,7 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
return (
<div>
<SettingsHeader
hideDivider
title={
<div className="flex items-center gap-2">
<p>
@@ -89,6 +89,7 @@ export default function WebhookPage() {
return (
<div>
<SettingsHeader
hideDivider
title={t`Webhooks`}
subtitle={t`On this page, you can create new Webhooks and manage the existing ones.`}
>
+2 -1
View File
@@ -1,5 +1,6 @@
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ZTeamUrlSchema } from '@documenso/trpc/server/team-router/schema';
@@ -11,7 +12,7 @@ export async function loader({ request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
if (session.isAuthenticated) {
const teamUrlCookie = extractCookieFromHeaders('preferred-team-url', request.headers);
const teamUrlCookie = extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers);
// const referrer = request.headers.get('referer');
// let isReferrerFromTeamUrl = false;
+17 -2
View File
@@ -1,7 +1,9 @@
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getPublicProfileByUrl } from '@documenso/lib/server-only/profile/get-public-profile-by-url';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@@ -53,6 +55,19 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const canManageProfileSettings = (sessionData?.organisations ?? []).some((organisation) => {
const team = organisation.teams.find((currentTeam) => currentTeam.id === profile.teamId);
if (!team) {
return false;
}
return (
canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole) ||
canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)
);
});
return (
<div className="flex flex-col items-center justify-center py-4 sm:py-32">
<div className="flex flex-col items-center">
@@ -118,11 +133,11 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
</Trans>
</span>
)}
{'userId' in profile && user?.id === profile.userId && (
{canManageProfileSettings && (
<span className="mt-2 inline-block">
<Trans>
Go to your{' '}
<Link to="/settings/public-profile" className="underline">
<Link to={`/t/${publicProfile.url}/settings/public-profile`} className="underline">
public profile settings
</Link>{' '}
to add documents.
@@ -64,7 +64,7 @@ export async function loader({ params }: Route.LoaderArgs) {
},
});
if (!verificationToken || verificationToken.expires < new Date()) {
if (!verificationToken || verificationToken.completed || verificationToken.expires < new Date()) {
throw data({
type: 'invalid-token',
});
@@ -1,7 +1,11 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { Link } from 'react-router';
import type { Route } from './+types/team.verify.email.$token';
@@ -19,8 +23,15 @@ export async function loader({ params }: Route.LoaderArgs) {
where: {
token,
},
include: {
team: true,
select: {
email: true,
completed: true,
expiresAt: true,
team: {
select: {
name: true,
},
},
},
});
@@ -37,52 +48,11 @@ export async function loader({ params }: Route.LoaderArgs) {
} as const;
}
const { team } = teamEmailVerification;
let isTeamEmailVerificationError = false;
try {
await prisma.$transaction([
prisma.teamEmailVerification.updateMany({
where: {
teamId: team.id,
email: teamEmailVerification.email,
},
data: {
completed: true,
},
}),
prisma.teamEmailVerification.deleteMany({
where: {
teamId: team.id,
expiresAt: {
lt: new Date(),
},
},
}),
prisma.teamEmail.create({
data: {
teamId: team.id,
email: teamEmailVerification.email,
name: teamEmailVerification.name,
},
}),
]);
} catch (e) {
console.error(e);
isTeamEmailVerificationError = true;
}
if (isTeamEmailVerificationError) {
return {
state: 'VerificationError',
teamName: team.name,
} as const;
}
return {
state: 'Success',
teamName: team.name,
state: 'Pending',
token,
email: teamEmailVerification.email,
teamName: teamEmailVerification.team.name,
} as const;
}
@@ -113,7 +83,8 @@ export default function VerifyTeamEmailPage({ loaderData }: Route.ComponentProps
if (data.state === 'AlreadyCompleted') {
return (
<div>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Team email already verified!</Trans>
</h1>
@@ -130,35 +101,61 @@ export default function VerifyTeamEmailPage({ loaderData }: Route.ComponentProps
</Link>
</Button>
</div>
);
}
if (data.state === 'VerificationError') {
return (
<div>
<h1 className="font-semibold text-4xl">
<Trans>Team email verification</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Something went wrong while attempting to verify your email address for <strong>{data.teamName}</strong>.
Please try again later.
</Trans>
</p>
</div>
);
}
return <PendingTeamEmailVerification token={data.token} email={data.email} teamName={data.teamName} />;
}
type PendingTeamEmailVerificationProps = {
token: string;
email: string;
teamName: string;
};
const PendingTeamEmailVerification = ({ token, email, teamName }: PendingTeamEmailVerificationProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [isVerified, setIsVerified] = useState(false);
const { mutateAsync: completeTeamEmailVerification, isPending } = trpc.team.email.verification.complete.useMutation({
onSuccess: () => setIsVerified(true),
onError: (err) => {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: t`Email already in use`,
description: t`This email is already being used as a team email. Please contact your team for assistance.`,
variant: 'destructive',
duration: 10000,
});
return;
}
toast({
title: t`Something went wrong`,
description: t`We were unable to verify this email at this time. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
if (isVerified) {
return (
<div>
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Team email verified!</Trans>
</h1>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>
You have verified your email address for <strong>{data.teamName}</strong>.
You have verified your email address for <strong>{teamName}</strong>.
</Trans>
</p>
@@ -168,5 +165,35 @@ export default function VerifyTeamEmailPage({ loaderData }: Route.ComponentProps
</Link>
</Button>
</div>
</div>
);
}
}
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="font-semibold text-4xl">
<Trans>Verify team email</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
<strong>{teamName}</strong> would like to use <strong>{email}</strong> as their team email.
</Trans>
</p>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>They will be able to view documents associated with this email.</Trans>
</p>
<p className="mt-2 mb-4 text-muted-foreground text-sm">
<Trans>Do not proceed if you are unsure about this request.</Trans>
</p>
<Button loading={isPending} onClick={async () => completeTeamEmailVerification({ token })}>
<Trans>Verify email</Trans>
</Button>
</div>
</div>
);
};
@@ -0,0 +1,21 @@
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { ZTeamUrlSchema } from '@documenso/trpc/server/team-router/schema';
import type { ActionFunctionArgs } from 'react-router';
/**
* Sets the preferred team cookie.
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const teamUrl = ZTeamUrlSchema.safeParse(formData.get('teamUrl'));
if (!teamUrl.success) {
throw new Response('Invalid team url', { status: 400 });
}
return new Response('OK', {
status: 200,
// Attributes must stay in step with the middleware: session cookie, root path, Lax.
headers: { 'Set-Cookie': `${PREFERRED_TEAM_URL_COOKIE}=${teamUrl.data}; Path=/; SameSite=Lax` },
});
};
+2 -1
View File
@@ -1,3 +1,4 @@
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
import { AppDebugger } from '@documenso/lib/utils/debugger';
import type { Context, Next } from 'hono';
import { setCookie } from 'hono/cookie';
@@ -49,7 +50,7 @@ export const appMiddleware = async (c: Context, next: Next) => {
if (pathname.startsWith('/t/')) {
debug.log('Setting preferred team url cookie');
setCookie(c, 'preferred-team-url', pathname.split('/')[2], {
setCookie(c, PREFERRED_TEAM_URL_COOKIE, pathname.split('/')[2], {
sameSite: 'lax',
});
@@ -338,7 +338,7 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
});
// Verify they can access organisation settings (owner permission)
await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
});
@@ -524,7 +524,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
});
// Should be able to access organisation settings
await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
@@ -539,5 +539,5 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
});
// Should still be able to access settings (as they should now be an admin)
await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
});
@@ -15,11 +15,11 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/document`,
redirectPath: `/o/${organisation.url}/settings/reminders`,
});
// Wait for the form to load.
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
await expect(page.getByTestId('envelope-expiration-mode')).toBeVisible();
// Change the amount to 2.
const amountInput = page.getByTestId('envelope-expiration-amount');
@@ -36,7 +36,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
await page.getByRole('option', { name: 'Weeks' }).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 reminder preferences have been updated').first()).toBeVisible();
// Verify via database.
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
@@ -54,18 +54,18 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/document`,
redirectPath: `/o/${organisation.url}/settings/reminders`,
});
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// Find the mode select (shows "Custom duration") and change to "Never expires".
const modeTrigger = page.getByTestId('envelope-expiration-mode');
await expect(modeTrigger).toBeVisible();
await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).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 reminder preferences have been updated').first()).toBeVisible();
// Verify via database.
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
@@ -106,11 +106,9 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/document`,
redirectPath: `/t/${team.url}/settings/reminders`,
});
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// The expiration picker mode select should show "Inherit from organisation" by default.
const modeTrigger = page.getByTestId('envelope-expiration-mode');
await expect(modeTrigger).toBeVisible();
@@ -129,7 +127,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await page.getByRole('option', { name: 'Days' }).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 reminder preferences have been updated').first()).toBeVisible();
// Verify team setting is overridden.
const teamSettings = await getTeamSettings({ teamId: team.id });
@@ -1,9 +1,11 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { FieldType } from '@documenso/prisma/client';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
@@ -128,3 +130,82 @@ test('[ENVELOPE_EXPIRATION]: expired recipient cannot complete signing', async (
}).toPass({ timeout: 10_000 });
}
});
const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record<string, unknown>) => {
return await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/${procedure}`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ json: input }),
});
};
/**
* The signing page loader only redirects expired recipients, which a direct API call
* bypasses. The tests above exercise the V1 signing path; this covers the V2 route
* (`envelope.field.sign`), which must reject on the server regardless of the UI.
*/
test('[ENVELOPE_EXPIRATION]: expired recipient cannot sign a field via the V2 API', async ({ request }) => {
const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
title: '[TEST] Expired recipient V2 signing',
recipients: [
{
email: `expired-v2-${Date.now()}@test.documenso.com`,
name: 'Expired Signer',
role: 'SIGNER',
signingOrder: 1,
},
],
fieldsPerRecipient: [
[
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
],
],
});
const recipient = distributeResult.recipients[0];
const seededEnvelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { fields: true },
});
const textField = seededEnvelope.fields.find((field) => field.type === FieldType.TEXT);
if (!textField) {
throw new Error('TEXT field not found on the seeded envelope');
}
// Sanity check: the recipient can sign while the signing window is open.
const beforeExpiry = await trpcMutation(request, 'envelope.field.sign', {
token: recipient.token,
fieldId: textField.id,
fieldValue: { type: FieldType.TEXT, value: 'before' },
});
expect(beforeExpiry.ok()).toBeTruthy();
await prisma.field.update({
where: { id: textField.id },
data: { inserted: false, customText: '' },
});
await prisma.recipient.update({
where: { id: recipient.id },
data: { expiresAt: new Date(Date.now() - 60_000) },
});
const afterExpiry = await trpcMutation(request, 'envelope.field.sign', {
token: recipient.token,
fieldId: textField.id,
fieldValue: { type: FieldType.TEXT, value: 'after' },
});
expect(afterExpiry.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: textField.id },
});
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe('');
});
@@ -313,20 +313,14 @@ test.describe('Signing Certificate Tests', () => {
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/document`,
redirectPath: `/t/${team.url}/settings/certificates`,
});
await page
.getByRole('group')
.locator('div')
.filter({ hasText: 'Include the Signing' })
.getByRole('combobox')
.click();
await page.getByTestId('include-signing-certificate-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.waitForTimeout(1000);
await expect(page.getByText('Your certificate preferences have been updated').first()).toBeVisible();
// Verify the setting was saved
const updatedTeam = await prisma.team.findFirstOrThrow({
@@ -337,23 +331,21 @@ test.describe('Signing Certificate Tests', () => {
expect(updatedTeam.teamGlobalSettings?.includeSigningCertificate).toBe(false);
// Toggle the setting back to true
await page
.getByRole('group')
.locator('div')
.filter({ hasText: 'Include the Signing' })
.getByRole('combobox')
.click();
await page.getByTestId('include-signing-certificate-trigger').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.waitForTimeout(1000);
// Verify the setting was saved
const updatedTeam2 = await prisma.team.findFirstOrThrow({
// The toast from the first save may still be visible, so poll the database
// for the saved value instead of waiting on UI signals.
await expect
.poll(async () => {
const updatedTeam = await prisma.team.findFirstOrThrow({
where: { id: team.id },
include: { teamGlobalSettings: true },
});
expect(updatedTeam2.teamGlobalSettings?.includeSigningCertificate).toBe(true);
return updatedTeam.teamGlobalSettings?.includeSigningCertificate;
})
.toBe(true);
});
});
@@ -35,12 +35,24 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByTestId('signature-types-trigger').click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Upload' }).click();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Sender details moved to the email preferences page.
await page.goto(`/o/${organisation.url}/settings/email`);
await page.getByTestId('include-sender-details-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
// The signing certificate toggle moved to the certificates page.
await page.goto(`/o/${organisation.url}/settings/certificates`);
await page.getByTestId('include-signing-certificate-trigger').click();
await page.getByRole('option', { name: 'No' }).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 certificate preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
@@ -236,8 +248,14 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Reply to email' }).click();
await page.getByRole('textbox', { name: 'Reply to email' }).fill('team@example.com');
// Change email document settings inheritance to controlled
await page.getByRole('combobox').filter({ hasText: 'Inherit from organisation' }).click();
// Change email document settings inheritance to controlled. Scope to the
// email-document-settings field — the sender-details select on this page also
// renders an "Inherit from organisation" value.
await page
.getByTestId('inheritable-email-document-settings')
.getByRole('combobox')
.filter({ hasText: 'Inherit from organisation' })
.click();
await page.getByRole('option', { name: 'Override organisation settings' }).click();
// Update some email settings
@@ -93,3 +93,34 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
await expect(page.getByRole('heading')).toContainText('Document Signed');
});
test('[PUBLIC_PROFILE]: empty-profile settings hint only shows to team managers', async ({ page }) => {
const { user, team } = await seedUser();
// Enable the team's public profile with no linked templates so the empty
// state (and its "manage your profile" hint) renders.
await prisma.teamProfile.upsert({
where: { teamId: team.id },
update: { enabled: true },
create: { teamId: team.id, enabled: true },
});
// The team owner manages the team → sees the hint linking straight to the
// team's public-profile settings.
await apiSignin({ page, email: user.email });
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${team.url}`);
const settingsLink = page.getByRole('link', { name: 'public profile settings' });
await expect(settingsLink).toBeVisible();
await expect(settingsLink).toHaveAttribute('href', `/t/${team.url}/settings/public-profile`);
// A different signed-in user who doesn't manage this team sees the empty state
// but no settings hint.
const { user: stranger } = await seedUser();
await apiSignin({ page, email: stranger.email });
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${team.url}`);
await expect(page.getByText("hasn't added any documents")).toBeVisible();
await expect(page.getByRole('link', { name: 'public profile settings' })).toHaveCount(0);
});
@@ -0,0 +1,82 @@
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeam } from '@documenso/prisma/seed/teams';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
const readPreferredTeamUrl = async (page: Page) => {
const cookies = await page.context().cookies();
return cookies.find((cookie) => cookie.name === 'preferred-team-url')?.value ?? null;
};
/**
* Two organisations the signed-in user administers, each with its own team.
*/
const seedTwoOrganisations = async () => {
const { owner, team: teamA, organisation: orgA } = await seedTeam();
const { organisation: orgB, team: teamB } = await seedTeam();
await seedOrganisationMembers({
members: [{ email: owner.email, organisationRole: OrganisationMemberRole.ADMIN }],
organisationId: orgB.id,
});
return { owner, orgA, teamA, orgB, teamB };
};
const switchOrganisationInSettings = async (page: Page, organisationUrl: string) => {
const sidebar = page.getByTestId('unified-settings-sidebar');
await sidebar.getByTestId('settings-org-switcher-trigger').click();
await page.getByTestId(`settings-org-switcher-item-${organisationUrl}`).click();
await page.waitForURL(`/o/${organisationUrl}/settings/general`);
};
test.describe('Preferred team cookie', () => {
test('switching organisation in settings records a team from that organisation', async ({ page }) => {
const { owner, teamA, orgB, teamB } = await seedTwoOrganisations();
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${teamA.url}/settings/general`);
expect(await readPreferredTeamUrl(page)).toBe(teamA.url);
await switchOrganisationInSettings(page, orgB.url);
// Recorded by the settings layout, which posts asynchronously rather than blocking the
// navigation, so the swap lands shortly after the URL changes.
await expect.poll(() => readPreferredTeamUrl(page)).toBe(teamB.url);
});
test('app root redirects into the organisation last selected in settings', async ({ page }) => {
const { owner, teamA, orgB, teamB } = await seedTwoOrganisations();
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${teamA.url}/settings/general`);
await switchOrganisationInSettings(page, orgB.url);
await expect.poll(() => readPreferredTeamUrl(page)).toBe(teamB.url);
await page.goto('/');
await expect(page).toHaveURL(`/t/${teamB.url}/documents`);
});
test('switching team in settings records the newly selected team', async ({ page }) => {
const { owner, teamA, orgB, teamB } = await seedTwoOrganisations();
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${teamB.url}/settings/general`);
expect(await readPreferredTeamUrl(page)).toBe(teamB.url);
await page.goto(`/t/${teamA.url}/settings/general`);
expect(await readPreferredTeamUrl(page)).toBe(teamA.url);
await page.goto('/');
await expect(page).toHaveURL(`/t/${teamA.url}/documents`);
});
});
@@ -0,0 +1,554 @@
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
/**
* Every seeded user is given their own organisation. Removing it leaves the user with only
* the access that was explicitly granted, which is how we reach the "team access only" and
* "no organisations at all" states.
*/
const deleteOwnedOrganisations = async (userId: number) => {
await prisma.organisation.deleteMany({
where: {
ownerUserId: userId,
},
});
};
test.describe('Unified Settings', () => {
test('shows both groups for the team owner at team scope', async ({ page }) => {
const { owner, team, organisation } = await seedTeam();
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${team.url}/settings`);
const sidebar = page.getByTestId('unified-settings-sidebar');
await expect(sidebar).toBeVisible();
const groups = sidebar.getByTestId('unified-settings-sidebar-group');
// Organisation + Team groups, plus the always-visible Account group.
await expect(groups).toHaveCount(3);
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toContainText(organisation.name);
await expect(sidebar.getByTestId('settings-team-switcher-trigger')).toContainText(team.name);
// Nav item labels are lingui `msg` descriptors resolved to strings at render —
// assert the visible text so a broken translation (blank / [object Object])
// would fail here. Test ids are scope-qualified because item keys repeat across groups.
await expect(sidebar.getByTestId('unified-settings-nav-team-members')).toContainText('Members');
await expect(sidebar.getByTestId('unified-settings-nav-team-preferences')).toContainText('Preferences');
await expect(sidebar.getByTestId('unified-settings-nav-organisation-members')).toContainText('Members');
await expect(sidebar.getByTestId('unified-settings-nav-account-profile')).toContainText('Profile');
});
test('shows both groups for the team owner at org scope (team-fallback)', async ({ page }) => {
const { owner, team, organisation } = await seedTeam();
await apiSignin({ page, email: owner.email });
// At org scope `useOptionalCurrentTeam()` is null, but the layout falls
// back to the user's first manageable team in the current org so both
// groups still render.
await page.goto(`/o/${organisation.url}/settings`);
const sidebar = page.getByTestId('unified-settings-sidebar');
await expect(sidebar).toBeVisible();
const groups = sidebar.getByTestId('unified-settings-sidebar-group');
// Organisation + Team groups, plus the always-visible Account group.
await expect(groups).toHaveCount(3);
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toContainText(organisation.name);
// Team switcher shows the fallback team (the user's first manageable team in this org).
await expect(sidebar.getByTestId('settings-team-switcher-trigger')).toContainText(team.name);
// The empty state is only for users who can't manage the organisation.
await expect(sidebar.getByTestId('unified-settings-organisation-empty-state')).toHaveCount(0);
});
test('sidebar is flush with the left viewport edge', async ({ page }) => {
const { owner, organisation } = await seedTeam();
// Wide viewport — a centered max-w-screen-xl container would offset the
// sidebar by (1600 - 1280) / 2 = 160px+, while flush-left is ~16px (the
// aside's own internal padding).
await page.setViewportSize({ width: 1600, height: 900 });
await apiSignin({ page, email: owner.email });
await page.goto(`/o/${organisation.url}/settings`);
const sidebar = page.getByTestId('unified-settings-sidebar');
await expect(sidebar).toBeVisible();
const box = await sidebar.boundingBox();
expect(box?.x ?? Number.MAX_SAFE_INTEGER).toBeLessThan(100);
// The app header stretches to the full viewport width on settings pages.
const headerContainer = page.getByTestId('app-header-container');
const headerBox = await headerContainer.boundingBox();
expect(headerBox?.x ?? Number.MAX_SAFE_INTEGER).toBeLessThan(50);
expect((headerBox?.x ?? 0) + (headerBox?.width ?? 0)).toBeGreaterThan(1550);
// Outside of settings the header keeps its centered max-w-screen-xl container.
await page.goto(`/o/${organisation.url}`);
await expect(headerContainer).toBeVisible();
const centeredHeaderBox = await headerContainer.boundingBox();
expect(centeredHeaderBox?.x ?? 0).toBeGreaterThan(100);
});
test('content is centered within the pane beside the sidebar', async ({ page }) => {
const { owner, organisation } = await seedTeam();
await page.setViewportSize({ width: 1600, height: 900 });
await apiSignin({ page, email: owner.email });
await page.goto(`/o/${organisation.url}/settings`);
const content = page.getByTestId('unified-settings-content');
await expect(content).toBeVisible();
const contentBox = await content.boundingBox();
// The pane spans from the sidebar's right edge (fixed 320px aside) to the
// viewport edge. The content container should be centered within it.
const paneCenter = (320 + 1600) / 2;
const contentCenter = (contentBox?.x ?? 0) + (contentBox?.width ?? 0) / 2;
expect(Math.abs(contentCenter - paneCenter)).toBeLessThan(24);
});
test('keeps current section when switching teams', async ({ page }) => {
// Seed one team, then add a second team to the same organisation.
const { owner, team: team1, organisation } = await seedTeam();
const team2Url = `team-two-${nanoid()}`;
await createTeam({
userId: owner.id,
teamName: 'Team Two',
teamUrl: team2Url,
organisationId: organisation.id,
inheritMembers: true,
});
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${team1.url}/settings/members`);
// Scope to the desktop sidebar — the mobile sidebar also renders the
// same testid.
const sidebar = page.getByTestId('unified-settings-sidebar');
await sidebar.getByTestId('settings-team-switcher-trigger').click();
// The popover content matches the trigger width.
const triggerBox = await sidebar.getByTestId('settings-team-switcher-trigger').boundingBox();
const contentBox = await page.getByTestId('settings-team-switcher-content').boundingBox();
expect(Math.abs((contentBox?.width ?? 0) - (triggerBox?.width ?? -1))).toBeLessThan(2);
await page.getByTestId(`settings-team-switcher-item-${team2Url}`).click();
await page.waitForURL(`/t/${team2Url}/settings/members`);
await expect(page).toHaveURL(`/t/${team2Url}/settings/members`);
});
test('account settings keep the organisation the user was working in', async ({ page }) => {
// The user administers their own organisation, but only manages a team in the seeded
// one — so the two differ in whether organisation settings are reachable.
const { team: teamInOtherOrg, organisation: otherOrganisation } = await seedTeam();
const user = await seedTeamMember({ teamId: teamInOtherOrg.id, role: TeamMemberRole.MANAGER });
const ownedOrganisation = await prisma.organisation.findFirstOrThrow({
where: { ownerUserId: user.id },
include: { teams: true },
});
// Both are seeded as "Personal Organisation", so rename them to tell the switcher apart.
await prisma.organisation.update({ where: { id: ownedOrganisation.id }, data: { name: 'Org I Administer' } });
await prisma.organisation.update({
where: { id: otherOrganisation.id },
data: { name: 'Org I Only Have A Team In' },
});
await apiSignin({ page, email: user.email });
const sidebar = page.getByTestId('unified-settings-sidebar');
const orgTrigger = sidebar.getByTestId('settings-org-switcher-trigger');
// `organisations` comes back unordered, so which one account scope falls back to isn't
// fixed. Read it cold, then work in the *other* one — otherwise the test can pass just
// because the fallback already happened to be the right organisation.
await page.goto('/settings/profile');
const fallbackIsOwned = ((await orgTrigger.textContent()) ?? '').includes('Org I Administer');
const target = fallbackIsOwned
? { name: 'Org I Only Have A Team In', teamUrl: teamInOtherOrg.url }
: { name: 'Org I Administer', teamUrl: ownedOrganisation.teams[0].url };
await page.goto(`/t/${target.teamUrl}/settings/general`);
await expect(orgTrigger).toContainText(target.name);
// Account scope has no organisation in the URL either, so it must not silently jump
// back to whichever organisation happens to be first.
await sidebar.getByTestId('unified-settings-nav-account-profile').click();
await page.waitForURL('/settings/profile');
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Account Settings');
await expect(orgTrigger).toContainText(target.name);
});
test('team switcher keeps the selected team when moving to organisation scope', async ({ page }) => {
const { owner, team: team1, organisation } = await seedTeam();
// Lowercased to match what `ZTeamUrlSchema` stores — `createTeam` is called directly
// here, bypassing the tRPC input schema that would normalise it in the real flow.
const team2Url = `team-two-${nanoid()}`.toLowerCase();
await createTeam({
userId: owner.id,
teamName: 'Team Two',
teamUrl: team2Url,
organisationId: organisation.id,
inheritMembers: true,
});
await apiSignin({ page, email: owner.email });
const sidebar = page.getByTestId('unified-settings-sidebar');
const trigger = sidebar.getByTestId('settings-team-switcher-trigger');
// `organisation.teams` comes back unordered, so which team the sidebar falls back to
// isn't fixed. Read it first, then deliberately select the *other* one — otherwise the
// test can pass simply because the fallback already happened to be the right team.
await page.goto(`/o/${organisation.url}/settings/general`);
const fallbackIsTeam2 = ((await trigger.textContent()) ?? '').includes('Team Two');
const selected = fallbackIsTeam2 ? { url: team1.url, name: team1.name } : { url: team2Url, name: 'Team Two' };
await page.goto(`/t/${team2Url}/settings/general`);
await trigger.click();
await page.getByTestId(`settings-team-switcher-item-${selected.url}`).click();
await page.waitForURL(`/t/${selected.url}/settings/general`);
await expect(trigger).toContainText(selected.name);
// Organisation scope has no team in the URL, so the sidebar has to remember which team
// the user picked rather than falling back to whichever one happens to be first.
await sidebar.getByTestId('unified-settings-nav-organisation-general').click();
await page.waitForURL(`/o/${organisation.url}/settings/general`);
// Wait for the organisation page to actually render — asserting straight after
// `waitForURL` can read the previous scope's still-mounted sidebar and pass falsely.
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation Settings');
await expect(trigger).toContainText(selected.name);
// The selection must also survive in the cookie — otherwise the app root would send the
// user back to the wrong team.
await expect
.poll(async () => {
const cookies = await page.context().cookies();
return cookies.find((cookie) => cookie.name === 'preferred-team-url')?.value ?? null;
})
.toBe(selected.url);
});
test('inheritable field toggles between INHERITED and OVERRIDDEN', async ({ page }) => {
const { owner, team } = await seedTeam();
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${team.url}/settings/document`);
const langStatus = page.getByTestId('document-language-status');
await expect(langStatus).toHaveText(/inherited/i);
// Open the language select and pick a non-default value.
await page.getByTestId('document-language-trigger').click();
await page
.getByRole('option', { name: /english/i })
.first()
.click();
await expect(langStatus).toHaveText(/override/i);
// Selecting the inherit option stages the field back to inherited.
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: /inherit from organisation/i }).click();
await expect(langStatus).toHaveText(/inherited/i);
});
test('branding fields toggle between INHERITED and OVERRIDDEN', async ({ page }) => {
const { owner, team } = await seedTeam();
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${team.url}/settings/branding`);
const enabledStatus = page.getByTestId('branding-enabled-status');
const urlStatus = page.getByTestId('branding-url-status');
await expect(enabledStatus).toHaveText(/inherited/i);
await expect(urlStatus).toHaveText(/inherited/i);
// Enable branding — unlocks the other fields and overrides the tri-state select.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: /yes/i }).click();
await expect(enabledStatus).toHaveText(/override/i);
// Override the brand website (inherit sentinel is the empty string).
await page.getByPlaceholder('https://example.com').fill('https://example.org');
await expect(urlStatus).toHaveText(/override/i);
// Clearing the field stages it back to its inherit sentinel (empty string).
await page.getByPlaceholder('https://example.com').fill('');
await expect(urlStatus).toHaveText(/inherited/i);
});
test('reminders page renders extracted fields with inheritance badges', async ({ page }) => {
const { owner, team } = await seedTeam();
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${team.url}/settings/reminders`);
await expect(page.getByTestId('envelope-expiration-period-status')).toHaveText(/inherited/i);
await expect(page.getByTestId('reminder-settings-status')).toHaveText(/inherited/i);
// The fields were extracted out of the document preferences page.
await page.goto(`/t/${team.url}/settings/document`);
await expect(page.getByTestId('document-language-status')).toBeVisible();
await expect(page.getByTestId('envelope-expiration-period-status')).not.toBeVisible();
});
test('certificates page renders extracted fields with inheritance badges', async ({ page }) => {
const { owner, team } = await seedTeam();
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${team.url}/settings/certificates`);
await expect(page.getByTestId('include-signing-certificate-status')).toHaveText(/inherited/i);
await expect(page.getByTestId('include-audit-log-status')).toHaveText(/inherited/i);
});
test('send on behalf of team lives on the email preferences page', async ({ page }) => {
const { owner, team } = await seedTeam();
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${team.url}/settings/email`);
await expect(page.getByTestId('include-sender-details-status')).toHaveText(/inherited/i);
// Moved out of the document preferences page.
await page.goto(`/t/${team.url}/settings/document`);
await expect(page.getByTestId('document-language-status')).toBeVisible();
await expect(page.getByTestId('include-sender-details-status')).not.toBeVisible();
});
test('account settings render inside the unified layout', async ({ page }) => {
const { owner } = await seedTeam();
await apiSignin({ page, email: owner.email });
await page.goto('/settings/profile');
const sidebar = page.getByTestId('unified-settings-sidebar');
await expect(sidebar).toBeVisible();
// Org + team groups render via the manageable-organisation fallback, and the
// Account group is always present.
await expect(sidebar.getByTestId('unified-settings-sidebar-group')).toHaveCount(3);
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Account Settings');
});
test('personal team can save email preferences', async ({ page }) => {
const { user, team } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({ page, email: user.email });
await page.goto(`/t/${team.url}/settings/email`);
// The sender-details field is hidden for personal orgs and its unchanged
// inherit sentinel is echoed back on submit — the server must drop it as a
// no-op rather than rejecting the whole update.
await page.getByPlaceholder('noreply@example.com').fill('replies@example.com');
await page.getByRole('button', { name: /save changes/i }).click();
await expect(page.getByText('Email preferences updated').first()).toBeVisible({ timeout: 15_000 });
});
test('content pane scrolls back to top when navigating between sections', async ({ page }) => {
const { owner, team } = await seedTeam();
// Short (but still md+) viewport so the document preferences page overflows
// the internally-scrolling content pane.
await page.setViewportSize({ width: 1280, height: 720 });
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${team.url}/settings/document`);
// Wait for the preferences form itself — the pane only overflows once the
// form has loaded (the query-loading spinner is shorter than the pane).
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
// The content pane is the <main> wrapping the content container.
const contentPane = page.getByTestId('unified-settings-content').locator('..');
// Scroll the pane down (the document preferences page overflows it).
await contentPane.evaluate((el) => el.scrollTo(0, el.scrollHeight));
const scrolledOffset = await contentPane.evaluate((el) => el.scrollTop);
expect(scrolledOffset).toBeGreaterThan(0);
// Navigate to another section via the sidebar (the members testid exists in
// both scope groups, so target the team group's link by href).
await page.getByTestId('unified-settings-sidebar').locator(`a[href="/t/${team.url}/settings/members"]`).click();
await expect(page).toHaveURL(`/t/${team.url}/settings/members`);
await expect.poll(async () => await contentPane.evaluate((el) => el.scrollTop)).toBe(0);
});
test('deleted personal-layout URL returns 404', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email });
const response = await page.goto('/settings/document');
expect(response?.status()).toBe(404);
});
test('team-only access shows the org switcher but no organisation pages', async ({ page }) => {
const { team, organisation } = await seedTeam();
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
await deleteOwnedOrganisations(manager.id);
await apiSignin({ page, email: manager.email });
await page.goto(`/t/${team.url}/settings/general`);
const sidebar = page.getByTestId('unified-settings-sidebar');
await expect(sidebar).toBeVisible();
// The Organisation group still renders so it can host the switcher — that's the only
// way this user can move between organisations — but it exposes no pages.
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toContainText(organisation.name);
await expect(sidebar.getByTestId('unified-settings-nav-organisation-general')).toHaveCount(0);
await expect(sidebar.getByTestId('unified-settings-nav-organisation-members')).toHaveCount(0);
await expect(sidebar.getByTestId('unified-settings-nav-organisation-billing')).toHaveCount(0);
// An empty group would just look broken, so it explains itself directly under the switcher.
const emptyState = sidebar.getByTestId('unified-settings-organisation-empty-state');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText(/permission to manage this organisation/i);
// Team and account pages remain navigable.
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
await expect(sidebar.getByTestId('unified-settings-nav-team-members')).toBeVisible();
await expect(sidebar.getByTestId('unified-settings-nav-account-profile')).toBeVisible();
});
test('team-only access is rejected from organisation settings', async ({ page }) => {
const { team, organisation } = await seedTeam();
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
await deleteOwnedOrganisations(manager.id);
await apiSignin({ page, email: manager.email });
// Managing a team must not grant access to the organisation scope.
await page.goto(`/o/${organisation.url}/settings/general`);
await expect(page.getByRole('heading', { name: 'Unauthorized' })).toBeVisible();
await expect(page.getByRole('link', { name: /go to your settings/i })).toBeVisible();
await expect(page.getByTestId('unified-settings-sidebar')).toHaveCount(0);
});
test('team member without manage permission is rejected from team settings', async ({ page }) => {
const { team } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await apiSignin({ page, email: member.email });
await page.goto(`/t/${team.url}/settings/general`);
// The team settings loader redirects out of the settings tree on a full page load.
await expect(page).not.toHaveURL(/\/settings\//);
await expect(page.getByTestId('unified-settings-sidebar')).toHaveCount(0);
});
test('user with no organisations only sees account settings', async ({ page }) => {
const { user } = await seedUser();
await deleteOwnedOrganisations(user.id);
await apiSignin({ page, email: user.email });
await page.goto('/settings/profile');
const sidebar = page.getByTestId('unified-settings-sidebar');
await expect(sidebar).toBeVisible();
await expect(sidebar.getByTestId('unified-settings-sidebar-group')).toHaveCount(1);
await expect(sidebar.getByTestId('unified-settings-nav-account-profile')).toBeVisible();
await expect(sidebar.getByTestId('unified-settings-nav-account-security')).toBeVisible();
// No organisation in context means no switcher and no scoped groups.
await expect(sidebar.getByTestId('settings-org-switcher-trigger')).toHaveCount(0);
await expect(sidebar.getByTestId('settings-team-switcher-trigger')).toHaveCount(0);
});
test('switching to an organisation the user cannot manage lands in team scope', async ({ page }) => {
const { team: otherTeam, organisation: otherOrganisation } = await seedTeam();
// `seedTeamMember` seeds the user with their own organisation (which they own) and
// then grants them a team role in the seeded organisation — exactly the mixed-access
// shape the switcher has to handle.
const manager = await seedTeamMember({ teamId: otherTeam.id, role: TeamMemberRole.MANAGER });
const ownedOrganisation = await prisma.organisation.findFirstOrThrow({
where: { ownerUserId: manager.id },
});
await apiSignin({ page, email: manager.email });
await page.goto(`/o/${ownedOrganisation.url}/settings/members`);
const sidebar = page.getByTestId('unified-settings-sidebar');
await sidebar.getByTestId('settings-org-switcher-trigger').click();
await page.getByTestId(`settings-org-switcher-item-${otherOrganisation.url}`).click();
// `members` exists under both scopes so the section carries over, but the scope drops
// to team because the user can't manage the destination organisation.
await page.waitForURL(`/t/${otherTeam.url}/settings/members`);
});
test('switching scope falls back to General when the section does not exist there', async ({ page }) => {
const { team: otherTeam, organisation: otherOrganisation } = await seedTeam();
const manager = await seedTeamMember({ teamId: otherTeam.id, role: TeamMemberRole.MANAGER });
const ownedOrganisation = await prisma.organisation.findFirstOrThrow({
where: { ownerUserId: manager.id },
});
await apiSignin({ page, email: manager.email });
// `teams` only exists under organisation scope.
await page.goto(`/o/${ownedOrganisation.url}/settings/teams`);
const sidebar = page.getByTestId('unified-settings-sidebar');
await sidebar.getByTestId('settings-org-switcher-trigger').click();
await page.getByTestId(`settings-org-switcher-item-${otherOrganisation.url}`).click();
await page.waitForURL(`/t/${otherTeam.url}/settings/general`);
});
});
@@ -69,5 +69,6 @@ test('[TEAMS]: update team', async ({ page }) => {
await page.getByRole('button', { name: 'Save changes' }).click();
// 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`);
// The team settings index redirects to the explicit General route.
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings/general`);
});
@@ -1,4 +1,5 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
@@ -29,7 +30,33 @@ test('[TEAMS]: send team email request', async ({ page }) => {
});
test('[TEAMS]: accept team email request', async ({ page }) => {
const { user, team } = await seedUser();
const { team } = await seedUser();
const teamEmailVerification = await seedTeamEmailVerification({
email: `team-email-verification--${team.url}@test.documenso.com`,
teamId: team.id,
});
const getTeamEmail = async () => prisma.teamEmail.findUnique({ where: { teamId: team.id } });
expect(await getTeamEmail()).toBeNull();
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
// Visiting the page (GET) must not verify the team email. An automated email link
// scanner or prefetcher must not be able to complete the verification.
await expect(page.getByRole('heading', { name: 'Verify team email' })).toBeVisible();
expect(await getTeamEmail()).toBeNull();
await page.getByRole('button', { name: 'Verify email' }).click();
await expect(page.getByRole('heading', { name: 'Team email verified!' })).toBeVisible();
expect(await getTeamEmail()).not.toBeNull();
});
test('[TEAMS]: team email verification link is invalid once completed', async ({ page }) => {
const { team } = await seedUser();
const teamEmailVerification = await seedTeamEmailVerification({
email: `team-email-verification--${team.url}@test.documenso.com`,
@@ -37,7 +64,11 @@ test('[TEAMS]: accept team email request', async ({ page }) => {
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
await expect(page.getByRole('heading')).toContainText('Team email verified!');
await page.getByRole('button', { name: 'Verify email' }).click();
await expect(page.getByRole('heading', { name: 'Team email verified!' })).toBeVisible();
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
await expect(page.getByRole('heading', { name: 'Team email already verified!' })).toBeVisible();
});
test('[TEAMS]: delete team email', async ({ page }) => {
@@ -51,6 +51,10 @@ test('[ORGANISATIONS]: settings save bar floats when the form footer is off-scre
isPersonalOrganisation: false,
});
// Short (but still md+) viewport so the document preferences form overflows
// the internally-scrolling settings content pane.
await page.setViewportSize({ width: 1280, height: 720 });
await apiSignin({
page,
email: user.email,
@@ -71,8 +75,10 @@ test('[ORGANISATIONS]: settings save bar floats when the form footer is off-scre
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));
// notice disappears. The settings layout scrolls its content pane internally,
// so scroll that pane rather than the window.
const contentPane = page.getByTestId('unified-settings-content').locator('..');
await contentPane.evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
@@ -0,0 +1,74 @@
import { prisma } from '@documenso/prisma';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { TeamMemberRole, WebhookTriggerEvents } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
/**
* Calls the procedure the way an attacker would — directly, from the authenticated browser
* context, bypassing the UI entirely. The settings page is gated on MANAGE_TEAM, so going
* through the UI would only prove the page is hidden, not that the data is protected.
*/
const callGetTeamWebhooks = async (page: Page, teamId: number) =>
await page.evaluate(async (id) => {
const response = await fetch('/api/trpc/webhook.getTeamWebhooks', {
method: 'GET',
headers: { 'content-type': 'application/json', 'x-team-id': String(id) },
});
return { status: response.status, body: await response.text() };
}, teamId);
test.describe('Webhook secret access', () => {
test('team managers can read webhook secrets', async ({ page }) => {
const { owner, team } = await seedTeam();
await prisma.webhook.create({
data: {
webhookUrl: 'https://example.com/hook',
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
secret: 'super-secret-signing-key',
enabled: true,
userId: owner.id,
teamId: team.id,
},
});
await apiSignin({ page, email: owner.email });
await page.goto(`/t/${team.url}/settings/webhooks`);
const { status, body } = await callGetTeamWebhooks(page, team.id);
expect(status).toBe(200);
// The edit dialog reads the secret straight off these rows, so managers must get it.
expect(body).toContain('super-secret-signing-key');
});
test('team members without manage permission cannot read webhook secrets', async ({ page }) => {
const { owner, team } = await seedTeam();
await prisma.webhook.create({
data: {
webhookUrl: 'https://example.com/hook',
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
secret: 'super-secret-signing-key',
enabled: true,
userId: owner.id,
teamId: team.id,
},
});
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await apiSignin({ page, email: member.email });
await page.goto(`/t/${team.url}/documents`);
const { status, body } = await callGetTeamWebhooks(page, team.id);
// Whatever the failure mode, the signing key must never appear in the response.
expect(body).not.toContain('super-secret-signing-key');
expect(status).not.toBe(200);
});
});
@@ -0,0 +1,55 @@
import { useMatches } from 'react-router';
/**
* The layout treatment a route wants from its parent layout(s).
*
* - `'settings'` — the full-height unified settings layout: no centered page
* container, a full-width app header, and a viewport-height flex column so the
* settings shell can fill the available space and scroll internally.
* - `null` — the default layout (centered `<PageContainer />`, normal flow).
*/
export type LayoutMode = 'settings' | null;
/**
* Typed route `handle` export. Controls layout rendering.
*
* - `hideAppHeader` — tells the parent layout to skip rendering `<AppHeader />`.
* - `layoutMode` — selects the layout treatment the parent layout(s) apply. See
* {@link LayoutMode}.
*/
export type RouteHandle = {
hideAppHeader?: boolean;
layoutMode?: LayoutMode;
};
/**
* Returns layout flags from the deepest matching route that sets any.
* Layouts call this to decide whether to render certain elements.
*/
export function useChildRouteFlags(): { hideAppHeader: boolean; layoutMode: LayoutMode } {
const matches = useMatches();
let hideAppHeader = false;
let layoutMode: LayoutMode = null;
// Walk from deepest match backward so the leaf route wins per flag.
for (let i = matches.length - 1; i >= 0; i--) {
const handle = matches[i].handle;
if (handle == null || typeof handle !== 'object') {
continue;
}
const h = handle as RouteHandle;
if (layoutMode === null && h.layoutMode) {
layoutMode = h.layoutMode;
}
if (!hideAppHeader && h.hideAppHeader) {
hideAppHeader = true;
}
}
return { hideAppHeader, layoutMode };
}
+1
View File
@@ -0,0 +1 @@
export const PREFERRED_TEAM_URL_COOKIE = 'preferred-team-url';
@@ -1,37 +0,0 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetTeamEmailByEmailOptions = {
email: string;
};
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
return await prisma.teamEmail.findFirst({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};
export const getTeamWithEmail = async ({ userId, teamUrl }: { userId: number; teamUrl: string }) => {
return await prisma.team.findFirstOrThrow({
where: {
...buildTeamWhereQuery({ teamId: undefined, userId }),
url: teamUrl,
},
include: {
teamEmail: true,
emailVerification: true,
},
});
};

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