mirror of
https://github.com/documenso/documenso.git
synced 2026-08-21 22:11:49 +10:00
feat: unify settings (#3128)
This commit is contained in:
@@ -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">
|
||||
<Trans>Default Signature Settings</Trans>
|
||||
<DocumentSignatureSettingsTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<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 />
|
||||
</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)}
|
||||
|
||||
+2
-6
@@ -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>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user