mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 14:52:23 +10:00
feat: unify settings (#3128)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||
@@ -46,6 +47,8 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
||||
|
||||
const { user, organisations } = useSession();
|
||||
|
||||
const { layoutMode } = useChildRouteFlags();
|
||||
|
||||
const teamUrl = params.teamUrl;
|
||||
const orgUrl = params.orgUrl;
|
||||
|
||||
@@ -108,23 +111,26 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
|
||||
return (
|
||||
<OrganisationProvider organisation={currentOrganisation}>
|
||||
<TeamProvider team={currentTeam || null}>
|
||||
<OrganisationBillingBanner />
|
||||
<div className={cn({ 'md:flex md:h-dvh md:flex-col md:overflow-hidden': layoutMode === 'settings' })}>
|
||||
<OrganisationBillingBanner />
|
||||
|
||||
<OrganisationQuotaBanner />
|
||||
<OrganisationQuotaBanner />
|
||||
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
|
||||
{banner && !hideHeader && <AppBanner banner={banner} />}
|
||||
{banner && !hideHeader && <AppBanner banner={banner} />}
|
||||
|
||||
{!hideHeader && <Header />}
|
||||
{!hideHeader && <Header fullWidth={layoutMode === 'settings'} />}
|
||||
|
||||
<main
|
||||
className={cn({
|
||||
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader,
|
||||
})}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
<main
|
||||
className={cn({
|
||||
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader && layoutMode !== 'settings',
|
||||
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
|
||||
})}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</TeamProvider>
|
||||
</OrganisationProvider>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`} hideDivider>
|
||||
<SettingsHeader hideDivider title={t`Subscription Claims`} subtitle={t`Manage all subscription claims`}>
|
||||
<ClaimCreateDialog licenseFlags={licenseFlags} />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function AdminEmailTransportsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Email Transports`} subtitle={t`Manage all email transports`} hideDivider>
|
||||
<SettingsHeader hideDivider title={t`Email Transports`} subtitle={t`Manage all email transports`}>
|
||||
<EmailTransportCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -258,7 +258,11 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Manage organisation`} subtitle={t`Manage the ${organisation.name} organisation`}>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Manage organisation`}
|
||||
subtitle={t`Manage the ${organisation.name} organisation`}
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/organisation-insights/${organisationId}`}>
|
||||
<Trans>View insights</Trans>
|
||||
@@ -269,10 +273,10 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
<GenericOrganisationAdminForm organisation={organisation} />
|
||||
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation usage`}
|
||||
subtitle={t`Current usage against organisation limits.`}
|
||||
className="mt-6"
|
||||
hideDivider
|
||||
/>
|
||||
|
||||
<OrganisationUsagePanel
|
||||
@@ -308,6 +312,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
</div>
|
||||
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Manage subscription`}
|
||||
subtitle={t`Manage the ${organisation.name} organisation subscription`}
|
||||
className="mt-16"
|
||||
@@ -425,6 +430,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
</div>
|
||||
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Danger Zone`}
|
||||
subtitle={t`Irreversible actions for this organisation`}
|
||||
className="mt-16"
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function Layout() {
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
|
||||
const { layoutMode } = useChildRouteFlags();
|
||||
|
||||
// Note: We use a key to force a re-render if the team context changes.
|
||||
// This is required otherwise you would see the wrong page content.
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8" key={currentOrganisation.url}>
|
||||
<div
|
||||
className={cn({
|
||||
'mx-auto w-full max-w-screen-xl px-4 md:px-8': layoutMode !== 'settings',
|
||||
'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings',
|
||||
})}
|
||||
key={currentOrganisation.url}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,173 +1,27 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
|
||||
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
Building2Icon,
|
||||
CreditCardIcon,
|
||||
GroupIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
ShieldCheckIcon,
|
||||
Users2Icon,
|
||||
} from 'lucide-react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
import { Link, NavLink, Outlet } from 'react-router';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.settings._layout';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Organisation Settings`);
|
||||
}
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const { t } = useLingui();
|
||||
export const handle: RouteHandle = {
|
||||
layoutMode: 'settings',
|
||||
};
|
||||
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const organisationSettingRoutes = [
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/general`,
|
||||
label: t`General`,
|
||||
icon: Building2Icon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: t`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
hideHighlight: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: t`Document`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/branding`,
|
||||
label: t`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/email`,
|
||||
label: t`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/email-domains`,
|
||||
label: t`Email Domains`,
|
||||
icon: MailboxIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/teams`,
|
||||
label: t`Teams`,
|
||||
icon: FaUsers,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/members`,
|
||||
label: t`Members`,
|
||||
icon: Users2Icon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/groups`,
|
||||
label: t`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/sso`,
|
||||
label: t`SSO`,
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/billing`,
|
||||
label: t`Billing`,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
].filter((route) => {
|
||||
if (!isBillingEnabled && route.path.includes('/billing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!isBillingEnabled || !organisation.organisationClaim.flags.emailDomains) &&
|
||||
route.path.includes('/email-domains')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!isBillingEnabled || !organisation.organisationClaim.flags.authenticationPortal) &&
|
||||
route.path.includes('/sso')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={401}
|
||||
errorCodeMap={{
|
||||
401: {
|
||||
heading: msg`Unauthorized`,
|
||||
subHeading: msg`401 Unauthorized`,
|
||||
message: msg`You are not authorized to access this page.`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to={`/o/${organisation.url}`}>
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Organisation Settings</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
{/* Navigation */}
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
|
||||
)}
|
||||
>
|
||||
{organisationSettingRoutes.map((route) => (
|
||||
<NavLink
|
||||
to={route.path}
|
||||
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
|
||||
key={route.path}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', {
|
||||
'group-aria-[current]:bg-secondary': !route.hideHighlight,
|
||||
})}
|
||||
>
|
||||
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
|
||||
<Trans>{route.label}</Trans>
|
||||
</Button>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 md:col-span-9">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
return {
|
||||
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsLayout({ loaderData }: Route.ComponentProps) {
|
||||
return <UnifiedSettingsLayout activeScope="organisation" preferredTeamUrl={loaderData.preferredTeamUrl} />;
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||
|
||||
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import {
|
||||
CertificatePreferencesForm,
|
||||
type TCertificatePreferencesFormSchema,
|
||||
} from '~/components/forms/certificate-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
export default function OrganisationSettingsCertificatesPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const onCertificatePreferencesFormSubmit = async (data: TCertificatePreferencesFormSchema) => {
|
||||
try {
|
||||
const { includeSigningCertificate, includeAuditLog } = data;
|
||||
|
||||
if (includeSigningCertificate === null || includeAuditLog === null) {
|
||||
throw new Error('Should not be possible.');
|
||||
}
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Certificate preferences updated`,
|
||||
description: t`Your certificate preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your certificate preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Certificates`}
|
||||
subtitle={t`Here you can set certificate and audit log preferences for your organisation. Teams will inherit these settings by default.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<CertificatePreferencesForm
|
||||
canInherit={false}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onCertificatePreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,24 +51,16 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
documentVisibility === null ||
|
||||
documentLanguage === null ||
|
||||
documentDateFormat === null ||
|
||||
includeSenderDetails === null ||
|
||||
includeSigningCertificate === null ||
|
||||
includeAuditLog === null ||
|
||||
aiFeaturesEnabled === null
|
||||
) {
|
||||
throw new Error('Should not be possible.');
|
||||
@@ -81,17 +73,12 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
reminderSettings: reminderSettings ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -124,7 +111,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||
|
||||
<section>
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
|
||||
<SettingsHeader hideDivider title={t`Email Domain Settings`} subtitle={t`Manage your email domain settings.`}>
|
||||
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -19,12 +18,9 @@ export function meta() {
|
||||
|
||||
export default function OrganisationSettingsEmailDomains() {
|
||||
const { t } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const isEmailDomainsEnabled = organisation.organisationClaim.flags.emailDomains;
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
@@ -33,7 +29,11 @@ export default function OrganisationSettingsEmailDomains() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Email Domains`} subtitle={t`Here you can add email domains to your organisation.`}>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Email Domains`}
|
||||
subtitle={t`Here you can add email domains to your organisation.`}
|
||||
>
|
||||
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
|
||||
</SettingsHeader>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function OrganisationSettingsEmailDomains() {
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||
<Button asChild variant="outline">
|
||||
<Link to={isPersonalLayoutMode ? '/settings/billing' : `/o/${organisation.url}/settings/billing`}>
|
||||
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||
<Trans>Update Billing</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function OrganisationSettingsGeneral() {
|
||||
|
||||
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
|
||||
try {
|
||||
const { emailId, emailReplyTo, emailDocumentSettings } = data;
|
||||
const { emailId, emailReplyTo, emailDocumentSettings, includeSenderDetails } = data;
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
@@ -36,6 +36,7 @@ export default function OrganisationSettingsGeneral() {
|
||||
emailReplyTo: emailReplyTo || null,
|
||||
// emailReplyToName,
|
||||
emailDocumentSettings,
|
||||
includeSenderDetails: includeSenderDetails ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,7 +60,7 @@ export default function OrganisationSettingsGeneral() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
|
||||
|
||||
<section>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function OrganisationSettingsGeneral() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<SettingsHeader title={_(msg`General`)} subtitle={_(msg`Here you can edit your organisation details.`)} />
|
||||
|
||||
<div className="space-y-8">
|
||||
@@ -30,23 +30,19 @@ export default function OrganisationSettingsGeneral() {
|
||||
</div>
|
||||
|
||||
{canExecuteOrganisationAction('DELETE_ORGANISATION', organisation.currentOrganisationRole) && (
|
||||
<>
|
||||
<hr className="my-4" />
|
||||
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Delete organisation</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Delete organisation</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>This organisation, and any associated data will be permanently deleted.</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>This organisation, and any associated data will be permanently deleted.</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<OrganisationDeleteDialog />
|
||||
</Alert>
|
||||
</>
|
||||
<OrganisationDeleteDialog />
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -94,7 +94,11 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Organisation Group Settings`} subtitle={t`Manage your organisation group settings.`}>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation Group Settings`}
|
||||
subtitle={t`Manage your organisation group settings.`}
|
||||
>
|
||||
<OrganisationGroupDeleteDialog
|
||||
organisationGroupId={groupId}
|
||||
organisationGroupName={group.name || ''}
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function TeamsSettingsMembersPage() {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Custom Organisation Groups`}
|
||||
subtitle={t`Manage the custom groups of members for your organisation.`}
|
||||
>
|
||||
|
||||
@@ -46,7 +46,11 @@ export default function TeamsSettingsMembersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={_(msg`Organisation Members`)} subtitle={_(msg`Manage the members or invite new members.`)}>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={_(msg`Organisation Members`)}
|
||||
subtitle={_(msg`Manage the members or invite new members.`)}
|
||||
>
|
||||
<OrganisationMemberInviteDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import {
|
||||
ReminderPreferencesForm,
|
||||
type TReminderPreferencesFormSchema,
|
||||
} from '~/components/forms/reminder-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
export default function OrganisationSettingsRemindersPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const onReminderPreferencesFormSubmit = async (data: TReminderPreferencesFormSchema) => {
|
||||
try {
|
||||
const { envelopeExpirationPeriod, reminderSettings } = data;
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
reminderSettings: reminderSettings ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Reminder preferences updated`,
|
||||
description: t`Your reminder preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your reminder preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Reminders`}
|
||||
subtitle={t`Here you can set expiration and signing reminder preferences for your organisation. Teams will inherit these settings by default.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<ReminderPreferencesForm
|
||||
canInherit={false}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onReminderPreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,8 +73,9 @@ export default function OrganisationSettingSSOLoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation SSO Portal`}
|
||||
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
||||
/>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function OrganisationSettingsTeamsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Teams`} subtitle={t`Manage the teams in this organisation.`}>
|
||||
<SettingsHeader hideDivider title={t`Teams`} subtitle={t`Manage the teams in this organisation.`}>
|
||||
<TeamCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
|
||||
import { TeamProvider } from '~/providers/team';
|
||||
|
||||
/**
|
||||
* These routes should only render if the user has:
|
||||
*
|
||||
* - 1 Personal organisation
|
||||
* - Nothing else
|
||||
*
|
||||
* This removes the UX complexity for users who only have a single personal organisation, instead of showing them multiple settings pages:
|
||||
*
|
||||
* - Organisation settings
|
||||
* - Teams settings
|
||||
*/
|
||||
export default function Layout() {
|
||||
const { organisations } = useSession();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const currentOrganisation = organisations[0];
|
||||
const team = currentOrganisation?.teams[0] || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPersonalLayoutMode || !team) {
|
||||
void navigate('/settings/profile');
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isPersonalLayoutMode || !team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trpcHeaders = {
|
||||
'x-team-Id': team.id.toString(),
|
||||
};
|
||||
|
||||
return (
|
||||
<TrpcProvider headers={trpcHeaders}>
|
||||
<OrganisationProvider organisation={currentOrganisation}>
|
||||
<TeamProvider team={team}>
|
||||
<Outlet />
|
||||
</TeamProvider>
|
||||
</OrganisationProvider>
|
||||
</TrpcProvider>
|
||||
);
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
import BillingPage, { meta } from '../../o.$orgUrl.settings.billing';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default BillingPage;
|
||||
@@ -1,5 +0,0 @@
|
||||
import BrandingPage, { meta } from '../../o.$orgUrl.settings.branding';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default BrandingPage;
|
||||
@@ -1,5 +0,0 @@
|
||||
import DocumentPage, { loader, meta } from '../../o.$orgUrl.settings.document';
|
||||
|
||||
export { loader, meta };
|
||||
|
||||
export default DocumentPage;
|
||||
@@ -1,5 +0,0 @@
|
||||
import EmailPage, { meta } from '../../o.$orgUrl.settings.email';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default EmailPage;
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
import PublicProfilePage, { loader, meta } from '../../t.$teamUrl+/settings.public-profile';
|
||||
|
||||
export { loader, meta };
|
||||
|
||||
export default PublicProfilePage;
|
||||
@@ -1,5 +0,0 @@
|
||||
import TokensPage, { meta } from '../../t.$teamUrl+/settings.tokens';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default TokensPage;
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default WebhookPage;
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default WebhookPage;
|
||||
@@ -1,30 +1,27 @@
|
||||
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
|
||||
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
import { SettingsDesktopNav } from '~/components/general/settings-nav-desktop';
|
||||
import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
|
||||
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Settings`);
|
||||
}
|
||||
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Settings</Trans>
|
||||
</h1>
|
||||
export const handle: RouteHandle = {
|
||||
layoutMode: 'settings',
|
||||
};
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<SettingsDesktopNav className="hidden md:col-span-3 md:flex" />
|
||||
<SettingsMobileNav className="col-span-12 mb-8 md:hidden" />
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
return {
|
||||
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
|
||||
};
|
||||
}
|
||||
|
||||
<div className="col-span-12 md:col-span-9">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function SettingsLayout({ loaderData }: Route.ComponentProps) {
|
||||
return <UnifiedSettingsLayout activeScope="account" preferredTeamUrl={loaderData.preferredTeamUrl} />;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function SettingsBilling() {
|
||||
<SettingsHeader
|
||||
title={t`Billing`}
|
||||
subtitle={t`Manage billing and subscriptions for organisations where you have billing management permissions.`}
|
||||
hideDivider
|
||||
/>
|
||||
|
||||
<UserBillingOrganisationsTable />
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function TeamsSettingsPage() {
|
||||
<SettingsHeader
|
||||
title={_(msg`Organisations`)}
|
||||
subtitle={_(msg`Manage all organisations you are currently associated with.`)}
|
||||
hideDivider
|
||||
>
|
||||
<OrganisationCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
@@ -32,8 +32,6 @@ export default function SettingsProfile() {
|
||||
<AvatarImageForm className="mb-8 max-w-xl" />
|
||||
<ProfileForm className="mb-8 max-w-xl" />
|
||||
|
||||
<hr className="my-4 max-w-xl" />
|
||||
|
||||
<div className="max-w-xl space-y-8">
|
||||
<AnimatePresence>
|
||||
{(!isPersonalLayoutMode || user.email !== teamEmail?.email) && teamEmail && (
|
||||
|
||||
@@ -66,13 +66,7 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
|
||||
title={_(msg`Security`)}
|
||||
subtitle={_(msg`Here you can manage your password and security settings.`)}
|
||||
/>
|
||||
{hasEmailPasswordAccount && (
|
||||
<>
|
||||
<PasswordForm user={user} />
|
||||
|
||||
<hr className="mt-6 border-border/50" />
|
||||
</>
|
||||
)}
|
||||
{hasEmailPasswordAccount && <PasswordForm user={user} />}
|
||||
|
||||
<Alert className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
|
||||
@@ -15,9 +15,9 @@ export default function SettingsSecurityActivity() {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={_(msg`Security activity`)}
|
||||
subtitle={_(msg`View all security activity related to your account.`)}
|
||||
hideDivider={true}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function SettingsPasskeys() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={_(msg`Passkeys`)} subtitle={_(msg`Manage your passkeys.`)} hideDivider={true}>
|
||||
<SettingsHeader hideDivider title={_(msg`Passkeys`)} subtitle={_(msg`Manage your passkeys.`)}>
|
||||
<PasskeyCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useChildRouteFlags } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@@ -17,6 +19,8 @@ export default function Layout() {
|
||||
const team = useOptionalCurrentTeam();
|
||||
const organisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const { layoutMode } = useChildRouteFlags();
|
||||
|
||||
const limits = useMemo(() => {
|
||||
if (!organisation) {
|
||||
return undefined;
|
||||
@@ -78,7 +82,7 @@ export default function Layout() {
|
||||
// Note: We use a key to force a re-render if the team context changes.
|
||||
// This is required otherwise you would see the wrong page content.
|
||||
return (
|
||||
<div key={team.url}>
|
||||
<div key={team.url} className={cn({ 'md:flex md:min-h-0 md:flex-1 md:flex-col': layoutMode === 'settings' })}>
|
||||
<TrpcProvider headers={trpcHeaders}>
|
||||
<LimitsProvider initialValue={limits} teamId={team.id}>
|
||||
<Outlet />
|
||||
|
||||
@@ -72,7 +72,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
updatedAt: envelope.updatedAt,
|
||||
documentMeta: envelope.documentMeta,
|
||||
},
|
||||
recipients: envelope.recipients,
|
||||
recipients: envelope.recipients.map((recipient) => ({
|
||||
id: recipient.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
})),
|
||||
documentRootPath,
|
||||
userId: user.id,
|
||||
};
|
||||
@@ -118,7 +123,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
||||
},
|
||||
];
|
||||
|
||||
const formatRecipientText = (recipient: Recipient) => {
|
||||
const formatRecipientText = (recipient: Pick<Recipient, 'email' | 'name' | 'role'>) => {
|
||||
let text = recipient.email;
|
||||
|
||||
if (recipient.name) {
|
||||
|
||||
@@ -1,162 +1,11 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getTeamWithEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckCircle2, Clock } from 'lucide-react';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
|
||||
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
|
||||
import { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||
import { TeamUpdateForm } from '~/components/forms/team-update-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/settings._index';
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
export function loader({ params }: Route.LoaderArgs) {
|
||||
if (params.teamUrl) {
|
||||
throw redirect(`/t/${params.teamUrl}/settings/general`);
|
||||
}
|
||||
|
||||
const team = await getTeamWithEmail({
|
||||
userId: user.id,
|
||||
teamUrl: params.teamUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
team,
|
||||
};
|
||||
}
|
||||
|
||||
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { team } = loaderData;
|
||||
|
||||
const currentTeam = useCurrentTeam();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
|
||||
|
||||
<AvatarImageForm team={currentTeam} className="mb-8" />
|
||||
|
||||
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
{(team.teamEmail || team.emailVerification) && (
|
||||
<Alert className="p-6" variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>Team email</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
You can view documents associated with this email and use this identity when sending documents.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
|
||||
<hr className="mt-2 border-border/50" />
|
||||
|
||||
<div className="flex flex-row items-center justify-between pt-4">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
|
||||
primaryText={
|
||||
<span className="font-semibold text-foreground/80 text-sm">
|
||||
{team.teamEmail?.name || team.emailVerification?.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={
|
||||
<span className="text-sm">{team.teamEmail?.email || team.emailVerification?.email}</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center pr-2">
|
||||
<div className="mr-4 flex flex-row items-center text-muted-foreground text-sm xl:mr-8">
|
||||
{match({
|
||||
teamEmail: team.teamEmail,
|
||||
emailVerification: team.emailVerification,
|
||||
})
|
||||
.with({ teamEmail: P.not(null) }, () => (
|
||||
<>
|
||||
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
|
||||
<Trans>Active</Trans>
|
||||
</>
|
||||
))
|
||||
.with(
|
||||
{
|
||||
emailVerification: P.when(
|
||||
(emailVerification) => emailVerification && emailVerification?.expiresAt < new Date(),
|
||||
),
|
||||
},
|
||||
() => (
|
||||
<>
|
||||
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
|
||||
<Trans>Expired</Trans>
|
||||
</>
|
||||
),
|
||||
)
|
||||
.with({ emailVerification: P.not(null) }, () => (
|
||||
<>
|
||||
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
|
||||
<Trans>Awaiting email confirmation</Trans>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<TeamEmailDropdown team={team} />
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!team.teamEmail && !team.emailVerification && (
|
||||
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Team email</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<ul className="mt-0.5 list-inside list-disc text-muted-foreground text-sm">
|
||||
{/* Feature not available yet. */}
|
||||
{/* <li>Display this name and email when sending documents</li> */}
|
||||
{/* <li>View documents associated with this email</li> */}
|
||||
|
||||
<span>
|
||||
<Trans>View documents associated with this email</Trans>
|
||||
</span>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<TeamEmailAddDialog teamId={team.id} />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
||||
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Delete team</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
This team, and any associated data excluding billing invoices will be permanently deleted.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<TeamDeleteDialog teamId={team.id} teamName={team.name} redirectTo="/dashboard" />
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import type { RouteHandle } from '@documenso/lib/client-only/hooks/use-child-route-flags';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { BracesIcon, Globe2Icon, GroupIcon, Settings2Icon, SettingsIcon, Users2Icon, WebhookIcon } from 'lucide-react';
|
||||
import { Link, NavLink, Outlet, redirect } from 'react-router';
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { UnifiedSettingsLayout } from '~/components/general/unified-settings-layout';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/settings._layout';
|
||||
@@ -18,6 +14,10 @@ export function meta() {
|
||||
return appMetaTags(msg`Team Settings`);
|
||||
}
|
||||
|
||||
export const handle: RouteHandle = {
|
||||
layoutMode: 'settings',
|
||||
};
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const session = await getSession(request);
|
||||
|
||||
@@ -35,123 +35,6 @@ export async function clientLoader() {
|
||||
// Do nothing, we only want the loader to run on SSR.
|
||||
}
|
||||
|
||||
export default function TeamsSettingsLayout() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const teamSettingRoutes = [
|
||||
{
|
||||
path: `/t/${team.url}/settings`,
|
||||
label: t`General`,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/document`,
|
||||
label: t`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
isSubNavParent: true,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/document`,
|
||||
label: t`Document`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/branding`,
|
||||
label: t`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/email`,
|
||||
label: t`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/public-profile`,
|
||||
label: t`Public Profile`,
|
||||
icon: Globe2Icon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/members`,
|
||||
label: t`Members`,
|
||||
icon: Users2Icon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/groups`,
|
||||
label: t`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/tokens`,
|
||||
label: t`API Tokens`,
|
||||
icon: BracesIcon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/webhooks`,
|
||||
label: t`Webhooks`,
|
||||
icon: WebhookIcon,
|
||||
},
|
||||
];
|
||||
|
||||
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={401}
|
||||
errorCodeMap={{
|
||||
401: {
|
||||
heading: msg`Unauthorized`,
|
||||
subHeading: msg`401 Unauthorized`,
|
||||
message: msg`You are not authorized to access this page.`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to={`/t/${team.url}`}>
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Team Settings</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
|
||||
)}
|
||||
>
|
||||
{teamSettingRoutes.map((route) => (
|
||||
<NavLink
|
||||
to={route.path}
|
||||
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
|
||||
key={route.path}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', {
|
||||
'group-aria-[current]:bg-secondary': !route.isSubNavParent,
|
||||
})}
|
||||
>
|
||||
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
|
||||
<Trans>{route.label}</Trans>
|
||||
</Button>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 md:col-span-9">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function TeamSettingsLayout() {
|
||||
return <UnifiedSettingsLayout activeScope="team" />;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function TeamsSettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Branding Preferences`}
|
||||
subtitle={t`Here you can set preferences and defaults for branding.`}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import {
|
||||
CertificatePreferencesForm,
|
||||
type TCertificatePreferencesFormSchema,
|
||||
} from '~/components/forms/certificate-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export default function TeamsSettingsCertificatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
|
||||
teamReference: team.id,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||
|
||||
const onCertificatePreferencesFormSubmit = async (data: TCertificatePreferencesFormSchema) => {
|
||||
try {
|
||||
const { includeSigningCertificate, includeAuditLog } = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: {
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Certificate preferences updated`,
|
||||
description: t`Your certificate preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your certificate preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingTeam || !teamWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Certificates`}
|
||||
subtitle={t`Here you can set certificate and audit log preferences for your team.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<CertificatePreferencesForm
|
||||
canInherit={true}
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onCertificatePreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
@@ -13,11 +12,6 @@ import {
|
||||
} from '~/components/forms/document-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Document Preferences`);
|
||||
}
|
||||
|
||||
export const loader = () => {
|
||||
return {
|
||||
@@ -46,15 +40,10 @@ export default function TeamsSettingsPage() {
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
@@ -64,13 +53,8 @@ export default function TeamsSettingsPage() {
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
...(signatureTypes.length === 0
|
||||
? {
|
||||
typedSignatureEnabled: null,
|
||||
@@ -110,7 +94,7 @@ export default function TeamsSettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Document Preferences`}
|
||||
subtitle={t`Here you can set preferences and defaults for your team.`}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { EmailPreferencesForm, type TEmailPreferencesFormSchema } from '~/components/forms/email-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Settings`);
|
||||
}
|
||||
|
||||
export default function TeamEmailSettingsGeneral() {
|
||||
const { t } = useLingui();
|
||||
@@ -27,7 +21,7 @@ export default function TeamEmailSettingsGeneral() {
|
||||
|
||||
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
|
||||
try {
|
||||
const { emailId, emailReplyTo, emailDocumentSettings } = data;
|
||||
const { emailId, emailReplyTo, emailDocumentSettings, includeSenderDetails } = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
@@ -36,6 +30,7 @@ export default function TeamEmailSettingsGeneral() {
|
||||
emailReplyTo,
|
||||
// emailReplyToName,
|
||||
emailDocumentSettings,
|
||||
includeSenderDetails,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,7 +54,7 @@ export default function TeamEmailSettingsGeneral() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<SettingsHeader title={t`Email Preferences`} subtitle={t`You can manage your email preferences here.`} />
|
||||
|
||||
<section>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { buildTeamWhereQuery, canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { CheckCircle2, Clock, EditIcon, LoaderIcon, MailIcon, MoreHorizontalIcon, XIcon } from 'lucide-react';
|
||||
import { redirect } from 'react-router';
|
||||
import { match, P } from 'ts-pattern';
|
||||
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
|
||||
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
|
||||
import { TeamEmailDeleteDialog } from '~/components/dialogs/team-email-delete-dialog';
|
||||
import { TeamEmailUpdateDialog } from '~/components/dialogs/team-email-update-dialog';
|
||||
import { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||
import { TeamUpdateForm } from '~/components/forms/team-update-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import type { Route } from './+types/settings.general';
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
if (!user || !params.teamUrl) {
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
...buildTeamWhereQuery({
|
||||
teamId: undefined,
|
||||
userId: user.id,
|
||||
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
}),
|
||||
url: params.teamUrl,
|
||||
},
|
||||
include: {
|
||||
teamEmail: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
return {
|
||||
teamEmail: team.teamEmail
|
||||
? {
|
||||
email: team.teamEmail?.email,
|
||||
name: team.teamEmail.name,
|
||||
}
|
||||
: null,
|
||||
emailVerification: team.emailVerification
|
||||
? {
|
||||
email: team.emailVerification?.email,
|
||||
name: team.emailVerification.name,
|
||||
expiresAt: team.emailVerification.expiresAt,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { teamEmail, emailVerification } = loaderData;
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: resendEmailVerification, isPending: isResendingEmailVerification } =
|
||||
trpc.team.email.verification.resend.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Email verification has been resent`,
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`Unable to resend verification at this time. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`General settings`} subtitle={t`Here you can edit your team's details.`} />
|
||||
|
||||
<AvatarImageForm team={team} className="mb-8" />
|
||||
|
||||
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
{(teamEmail || emailVerification) && (
|
||||
<Alert className="p-6" variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>Team email</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
You can view documents associated with this email and use this identity when sending documents.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
|
||||
<hr className="mt-2 border-border/50" />
|
||||
|
||||
<div className="flex flex-row items-center justify-between pt-4">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||
avatarFallback={extractInitials((teamEmail?.name || emailVerification?.name) ?? '')}
|
||||
primaryText={
|
||||
<span className="font-semibold text-foreground/80 text-sm">
|
||||
{teamEmail?.name || emailVerification?.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={<span className="text-sm">{teamEmail?.email || emailVerification?.email}</span>}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center pr-2">
|
||||
<div className="mr-4 flex flex-row items-center text-muted-foreground text-sm xl:mr-8">
|
||||
{match({
|
||||
teamEmail,
|
||||
emailVerification: emailVerification,
|
||||
})
|
||||
.with({ teamEmail: P.not(null) }, () => (
|
||||
<>
|
||||
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
|
||||
<Trans>Active</Trans>
|
||||
</>
|
||||
))
|
||||
.with(
|
||||
{
|
||||
emailVerification: P.when(
|
||||
(emailVerification) => emailVerification && emailVerification?.expiresAt < new Date(),
|
||||
),
|
||||
},
|
||||
() => (
|
||||
<>
|
||||
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
|
||||
<Trans>Expired</Trans>
|
||||
</>
|
||||
),
|
||||
)
|
||||
.with({ emailVerification: P.not(null) }, () => (
|
||||
<>
|
||||
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
|
||||
<Trans>Awaiting email confirmation</Trans>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
{!teamEmail && emailVerification && (
|
||||
<DropdownMenuItem
|
||||
disabled={isResendingEmailVerification}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void resendEmailVerification({ teamId: team.id });
|
||||
}}
|
||||
>
|
||||
{isResendingEmailVerification ? (
|
||||
<LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MailIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans>Resend verification</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{teamEmail && (
|
||||
<TeamEmailUpdateDialog
|
||||
teamId={team.id}
|
||||
teamEmail={teamEmail}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<EditIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TeamEmailDeleteDialog
|
||||
team={team}
|
||||
teamEmail={teamEmail}
|
||||
emailVerification={emailVerification}
|
||||
teamName={team.name}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<XIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!teamEmail && !emailVerification && (
|
||||
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Team email</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<ul className="mt-0.5 list-inside list-disc text-muted-foreground text-sm">
|
||||
{/* Feature not available yet. */}
|
||||
{/* <li>Display this name and email when sending documents</li> */}
|
||||
{/* <li>View documents associated with this email</li> */}
|
||||
|
||||
<span>
|
||||
<Trans>View documents associated with this email</Trans>
|
||||
</span>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<TeamEmailAddDialog teamId={team.id} />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Delete team</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
This team, and any associated data excluding billing invoices will be permanently deleted.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<TeamDeleteDialog teamId={team.id} teamName={team.name} redirectTo="/dashboard" />
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export default function TeamsSettingsGroupsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Team Groups`} subtitle={t`Manage the groups assigned to this team.`}>
|
||||
<SettingsHeader hideDivider title={t`Team Groups`} subtitle={t`Manage the groups assigned to this team.`}>
|
||||
<TeamGroupCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function TeamsSettingsMembersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title={t`Team Members`} subtitle={t`Manage the members of your team.`}>
|
||||
<SettingsHeader hideDivider title={t`Team Members`} subtitle={t`Manage the members of your team.`}>
|
||||
<TeamMemberCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
|
||||
@@ -128,8 +128,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
}, [profile.enabled]);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Public Profile`}
|
||||
subtitle={t`You can choose to enable or disable the profile for public view.`}
|
||||
>
|
||||
@@ -189,9 +190,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
|
||||
<div className="mt-4">
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Templates`}
|
||||
subtitle={t`Show templates in your public profile for your audience to sign and get started quickly`}
|
||||
hideDivider={true}
|
||||
className="mt-8 [&>*>h3]:text-base"
|
||||
>
|
||||
<ManagePublicTemplateDialog
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import {
|
||||
ReminderPreferencesForm,
|
||||
type TReminderPreferencesFormSchema,
|
||||
} from '~/components/forms/reminder-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export default function TeamsSettingsRemindersPage() {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
|
||||
teamReference: team.id,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||
|
||||
const onReminderPreferencesFormSubmit = async (data: TReminderPreferencesFormSchema) => {
|
||||
try {
|
||||
const { envelopeExpirationPeriod, reminderSettings } = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: {
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Reminder preferences updated`,
|
||||
description: t`Your reminder preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your reminder preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingTeam || !teamWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Reminders`}
|
||||
subtitle={t`Here you can set expiration and signing reminder preferences for your team.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<ReminderPreferencesForm
|
||||
canInherit={true}
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onReminderPreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export default function ApiTokensPage() {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={<Trans>API Tokens</Trans>}
|
||||
subtitle={
|
||||
<Trans>
|
||||
|
||||
@@ -216,6 +216,7 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<p>
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function WebhookPage() {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Webhooks`}
|
||||
subtitle={t`On this page, you can create new Webhooks and manage the existing ones.`}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { ZTeamUrlSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
@@ -11,7 +12,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await getOptionalSession(request);
|
||||
|
||||
if (session.isAuthenticated) {
|
||||
const teamUrlCookie = extractCookieFromHeaders('preferred-team-url', request.headers);
|
||||
const teamUrlCookie = extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers);
|
||||
|
||||
// const referrer = request.headers.get('referer');
|
||||
// let isReferrerFromTeamUrl = false;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getPublicProfileByUrl } from '@documenso/lib/server-only/profile/get-public-profile-by-url';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -53,6 +55,19 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
const canManageProfileSettings = (sessionData?.organisations ?? []).some((organisation) => {
|
||||
const team = organisation.teams.find((currentTeam) => currentTeam.id === profile.teamId);
|
||||
|
||||
if (!team) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole) ||
|
||||
canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-4 sm:py-32">
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -118,11 +133,11 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
{'userId' in profile && user?.id === profile.userId && (
|
||||
{canManageProfileSettings && (
|
||||
<span className="mt-2 inline-block">
|
||||
<Trans>
|
||||
Go to your{' '}
|
||||
<Link to="/settings/public-profile" className="underline">
|
||||
<Link to={`/t/${publicProfile.url}/settings/public-profile`} className="underline">
|
||||
public profile settings
|
||||
</Link>{' '}
|
||||
to add documents.
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken || verificationToken.expires < new Date()) {
|
||||
if (!verificationToken || verificationToken.completed || verificationToken.expires < new Date()) {
|
||||
throw data({
|
||||
type: 'invalid-token',
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/team.verify.email.$token';
|
||||
@@ -19,8 +23,15 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
select: {
|
||||
email: true,
|
||||
completed: true,
|
||||
expiresAt: true,
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,52 +48,11 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
const { team } = teamEmailVerification;
|
||||
|
||||
let isTeamEmailVerificationError = false;
|
||||
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.teamEmailVerification.updateMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
email: teamEmailVerification.email,
|
||||
},
|
||||
data: {
|
||||
completed: true,
|
||||
},
|
||||
}),
|
||||
prisma.teamEmailVerification.deleteMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.teamEmail.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
email: teamEmailVerification.email,
|
||||
name: teamEmailVerification.name,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
isTeamEmailVerificationError = true;
|
||||
}
|
||||
|
||||
if (isTeamEmailVerificationError) {
|
||||
return {
|
||||
state: 'VerificationError',
|
||||
teamName: team.name,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'Success',
|
||||
teamName: team.name,
|
||||
state: 'Pending',
|
||||
token,
|
||||
email: teamEmailVerification.email,
|
||||
teamName: teamEmailVerification.team.name,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -113,60 +83,117 @@ export default function VerifyTeamEmailPage({ loaderData }: Route.ComponentProps
|
||||
|
||||
if (data.state === 'AlreadyCompleted') {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Team email already verified!</Trans>
|
||||
</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Team email already verified!</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have already verified your email address for <strong>{data.teamName}</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have already verified your email address for <strong>{data.teamName}</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.state === 'VerificationError') {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Team email verification</Trans>
|
||||
</h1>
|
||||
return <PendingTeamEmailVerification token={data.token} email={data.email} teamName={data.teamName} />;
|
||||
}
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Something went wrong while attempting to verify your email address for <strong>{data.teamName}</strong>.
|
||||
Please try again later.
|
||||
</Trans>
|
||||
</p>
|
||||
type PendingTeamEmailVerificationProps = {
|
||||
token: string;
|
||||
email: string;
|
||||
teamName: string;
|
||||
};
|
||||
|
||||
const PendingTeamEmailVerification = ({ token, email, teamName }: PendingTeamEmailVerificationProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
|
||||
const { mutateAsync: completeTeamEmailVerification, isPending } = trpc.team.email.verification.complete.useMutation({
|
||||
onSuccess: () => setIsVerified(true),
|
||||
onError: (err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||
toast({
|
||||
title: t`Email already in use`,
|
||||
description: t`This email is already being used as a team email. Please contact your team for assistance.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to verify this email at this time. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isVerified) {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Team email verified!</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have verified your email address for <strong>{teamName}</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Team email verified!</Trans>
|
||||
</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Verify team email</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have verified your email address for <strong>{data.teamName}</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
<strong>{teamName}</strong> would like to use <strong>{email}</strong> as their team email.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>They will be able to view documents associated with this email.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>Do not proceed if you are unsure about this request.</Trans>
|
||||
</p>
|
||||
|
||||
<Button loading={isPending} onClick={async () => completeTeamEmailVerification({ token })}>
|
||||
<Trans>Verify email</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PREFERRED_TEAM_URL_COOKIE } from '@documenso/lib/constants/cookies';
|
||||
import { ZTeamUrlSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import type { ActionFunctionArgs } from 'react-router';
|
||||
|
||||
/**
|
||||
* Sets the preferred team cookie.
|
||||
*/
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const teamUrl = ZTeamUrlSchema.safeParse(formData.get('teamUrl'));
|
||||
|
||||
if (!teamUrl.success) {
|
||||
throw new Response('Invalid team url', { status: 400 });
|
||||
}
|
||||
|
||||
return new Response('OK', {
|
||||
status: 200,
|
||||
// Attributes must stay in step with the middleware: session cookie, root path, Lax.
|
||||
headers: { 'Set-Cookie': `${PREFERRED_TEAM_URL_COOKIE}=${teamUrl.data}; Path=/; SameSite=Lax` },
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user