mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 23:02:22 +10:00
Merge branch 'main' into fix/replace-hardcoded-grays-with-theme-tokens
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"
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function DashboardPage() {
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_ORGANISATION', org.currentOrganisationRole) && (
|
||||
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Link to={`/o/${org.url}/settings`}>
|
||||
<Link to={`/o/${org.url}/settings/general`}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -212,7 +212,7 @@ export default function DashboardPage() {
|
||||
|
||||
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||
<div className="absolute top-4 right-4 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Link to={`/t/${team.url}/settings`}>
|
||||
<Link to={`/t/${team.url}/settings/general`}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function OrganisationSettingsTeamsPage() {
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/o/${organisation.url}/settings`}>
|
||||
<Link to={`/o/${organisation.url}/settings/general`}>
|
||||
<Trans>Manage Organisation</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -184,7 +184,7 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/t/${team.url}/settings`}>
|
||||
<Link to={`/t/${team.url}/settings/general`}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Settings</Trans>
|
||||
</Link>
|
||||
|
||||
@@ -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,11 +0,0 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export function loader({ params }: Route.LoaderArgs) {
|
||||
if (params.orgUrl) {
|
||||
throw redirect(`/o/${params.orgUrl}/settings/general`);
|
||||
}
|
||||
|
||||
throw redirect('/');
|
||||
}
|
||||
@@ -1,173 +1,45 @@
|
||||
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 { extractCookieFromDocument } from '@documenso/lib/client-only/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>
|
||||
);
|
||||
/**
|
||||
* Only runs on the initial document request (SSR) so the first paint has the
|
||||
* correct preferred team without a hydration mismatch.
|
||||
*/
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
return {
|
||||
preferredTeamUrl: extractCookieFromHeaders(PREFERRED_TEAM_URL_COOKIE, request.headers),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs instead of the server loader on client-side navigations, otherwise every
|
||||
* settings page switch would trigger a `.data` round-trip to the server just to
|
||||
* read this cookie.
|
||||
*
|
||||
* The cookie is not `HttpOnly` so it can be read straight from the document.
|
||||
*/
|
||||
export function clientLoader() {
|
||||
return {
|
||||
preferredTeamUrl: extractCookieFromDocument(PREFERRED_TEAM_URL_COOKIE),
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsLayout({ loaderData }: Route.ComponentProps) {
|
||||
return <UnifiedSettingsLayout activeScope="organisation" preferredTeamUrl={loaderData.preferredTeamUrl} />;
|
||||
}
|
||||
|
||||
@@ -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 { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
type TBrandingPreferencesFormSchema,
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -26,16 +26,12 @@ export function meta() {
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsBrandingPage() {
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
|
||||
|
||||
const {
|
||||
@@ -122,17 +118,22 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
const settingsHeaderText = t`Branding Preferences`;
|
||||
|
||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||
? t`Here you can set your general branding preferences.`
|
||||
: team
|
||||
? t`Here you can set branding preferences for your team.`
|
||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
const settingsHeaderSubtitle = team
|
||||
? t`Here you can set branding preferences for your team.`
|
||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
const brandingPreferencesFormEnabled =
|
||||
organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={settingsHeaderText}
|
||||
subtitle={settingsHeaderSubtitle}
|
||||
hideDivider={!brandingPreferencesFormEnabled}
|
||||
/>
|
||||
|
||||
{organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
|
||||
{brandingPreferencesFormEnabled ? (
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
context="Organisation"
|
||||
@@ -167,6 +168,8 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<BrandingUpsell />
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
@@ -181,7 +184,7 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
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';
|
||||
|
||||
import {
|
||||
DocumentPreferencesForm,
|
||||
@@ -21,23 +17,12 @@ export function meta() {
|
||||
return appMetaTags(msg`Document Preferences`);
|
||||
}
|
||||
|
||||
export const loader = () => {
|
||||
return {
|
||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function OrganisationSettingsDocumentPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
const { organisations } = useSession();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
@@ -51,24 +36,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 +58,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: delegateDocumentOwnership,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
reminderSettings: reminderSettings ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -119,18 +91,15 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
}
|
||||
|
||||
const settingsHeaderText = t`Document Preferences`;
|
||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||
? t`Here you can set your general document preferences.`
|
||||
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
const settingsHeaderSubtitle = 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>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={false}
|
||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
||||
@@ -27,8 +27,9 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ
|
||||
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||
import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id';
|
||||
|
||||
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { t } = useLingui();
|
||||
@@ -96,10 +97,23 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
|
||||
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
|
||||
}, [organisation]);
|
||||
|
||||
const pageHeader = t`Email Domain Settings`;
|
||||
const pageSubtitle = t`Manage your email domain settings.`;
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!organisation.organisationClaim.flags.emailDomains && IS_DOCUMENSO_CLOUD()) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader hideDivider title={pageHeader} subtitle={pageSubtitle} />
|
||||
|
||||
<EmailDomainsUpsell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingEmailDomain) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
@@ -132,7 +146,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={pageHeader} subtitle={pageSubtitle}>
|
||||
<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 { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
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';
|
||||
@@ -10,6 +9,7 @@ import { Link } from 'react-router';
|
||||
|
||||
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
|
||||
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -19,12 +19,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 +30,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>
|
||||
|
||||
@@ -41,6 +42,8 @@ export default function OrganisationSettingsEmailDomains() {
|
||||
<section>
|
||||
<OrganisationEmailDomainsDataTable />
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<EmailDomainsUpsell />
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
@@ -55,7 +58,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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { SsoPortalUpsell } from '~/components/general/settings-upsell/sso-portal-upsell';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema.shape.data
|
||||
@@ -63,18 +65,42 @@ export default function OrganisationSettingSSOLoginPage() {
|
||||
const { t } = useLingui();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isAuthenticationPortalEnabled = organisation.organisationClaim.flags.authenticationPortal === true;
|
||||
|
||||
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
|
||||
trpc.enterprise.organisation.authenticationPortal.get.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
trpc.enterprise.organisation.authenticationPortal.get.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
{
|
||||
// The endpoint rejects orgs without the claim flag, so don't fire
|
||||
// requests that are guaranteed to error.
|
||||
enabled: isAuthenticationPortalEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isAuthenticationPortalEnabled && IS_DOCUMENSO_CLOUD()) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Organisation SSO Portal`}
|
||||
subtitle={t`Manage a custom SSO login portal for your organisation.`}
|
||||
/>
|
||||
|
||||
<SsoPortalUpsell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingAuthenticationPortal || !authenticationPortal) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
|
||||
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,5 +0,0 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
export function loader() {
|
||||
throw redirect('/settings/profile');
|
||||
}
|
||||
@@ -1,30 +1,45 @@
|
||||
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
|
||||
import { extractCookieFromDocument } from '@documenso/lib/client-only/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" />
|
||||
/**
|
||||
* Only runs on the initial document request (SSR) so the first paint has the
|
||||
* correct preferred team without a hydration mismatch.
|
||||
*/
|
||||
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>
|
||||
);
|
||||
/**
|
||||
* Runs instead of the server loader on client-side navigations, otherwise every
|
||||
* settings page switch would trigger a `.data` round-trip to the server just to
|
||||
* read this cookie.
|
||||
*
|
||||
* The cookie is not `HttpOnly` so it can be read straight from the document.
|
||||
*/
|
||||
export function clientLoader() {
|
||||
return {
|
||||
preferredTeamUrl: extractCookieFromDocument(PREFERRED_TEAM_URL_COOKIE),
|
||||
};
|
||||
}
|
||||
|
||||
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,59 +1,57 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
||||
import { EnvelopeType, FolderType, type DocumentStatus as PrismaDocumentStatus } from '@prisma/client';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import {
|
||||
type EnvelopeBulkDownloadItem,
|
||||
EnvelopesBulkDownloadDialog,
|
||||
} from '~/components/dialogs/envelopes-bulk-download-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { PeriodSelector } from '~/components/general/period-selector';
|
||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||
import { DocumentsTablePeriodFilter } from '~/components/tables/documents-table-period-filter';
|
||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Documents`);
|
||||
}
|
||||
|
||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
status: true,
|
||||
period: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
query: true,
|
||||
}).extend({
|
||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||
});
|
||||
type EnvelopeMetaCache = Record<string, { title: string; status: PrismaDocumentStatus; isLegacy: boolean }>;
|
||||
|
||||
// Stable initial values: `useSessionStorage` keeps its setter identity stable
|
||||
// only while the initial value reference is stable, and the metadata cache
|
||||
// effect below depends on that setter.
|
||||
const EMPTY_ROW_SELECTION: RowSelectionState = {};
|
||||
const EMPTY_ENVELOPE_META_CACHE: EnvelopeMetaCache = {};
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
@@ -61,9 +59,18 @@ export default function DocumentsPage() {
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
|
||||
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
|
||||
// Scoped by team so selections made in one team never leak into another.
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
`documents-bulk-selection-${team.id}`,
|
||||
EMPTY_ROW_SELECTION,
|
||||
);
|
||||
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<EnvelopeMetaCache>(
|
||||
`documents-bulk-selection-meta-${team.id}`,
|
||||
EMPTY_ENVELOPE_META_CACHE,
|
||||
);
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
|
||||
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
|
||||
|
||||
const selectedEnvelopeIds = useMemo(() => {
|
||||
@@ -76,18 +83,23 @@ export default function DocumentsPage() {
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.CANCELLED]: 0,
|
||||
[ExtendedDocumentStatus.EXPIRED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
});
|
||||
|
||||
const findDocumentSearchParams = useMemo(
|
||||
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
);
|
||||
const [findDocumentSearchParams, setFindDocumentSearchParams] = useQueryStates(documentsSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
{
|
||||
...findDocumentSearchParams,
|
||||
status: findDocumentSearchParams.status ?? undefined,
|
||||
period: findDocumentSearchParams.period ?? undefined,
|
||||
senderIds: findDocumentSearchParams.senderIds ?? undefined,
|
||||
page: findDocumentSearchParams.page ?? undefined,
|
||||
perPage: findDocumentSearchParams.perPage ?? undefined,
|
||||
query: findDocumentSearchParams.query ?? undefined,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
@@ -95,34 +107,66 @@ export default function DocumentsPage() {
|
||||
},
|
||||
);
|
||||
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
useEffect(() => {
|
||||
setEnvelopeMetaCache((prev) => {
|
||||
const next: EnvelopeMetaCache = {};
|
||||
|
||||
params.set('status', value);
|
||||
for (const id of Object.keys(prev)) {
|
||||
if (rowSelection[id]) {
|
||||
next[id] = prev[id];
|
||||
}
|
||||
}
|
||||
|
||||
if (value === ExtendedDocumentStatus.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
for (const document of data?.data ?? []) {
|
||||
if (rowSelection[document.envelopeId]) {
|
||||
next[document.envelopeId] = {
|
||||
title: document.title,
|
||||
status: document.status,
|
||||
isLegacy: document.internalVersion === 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
||||
params.delete('status');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
const selectedEnvelopesForDownload = useMemo(() => {
|
||||
return selectedEnvelopeIds
|
||||
.map((id): EnvelopeBulkDownloadItem | null => {
|
||||
const meta = envelopeMetaCache[id];
|
||||
|
||||
let path = formatDocumentsPath(team.url);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title: meta.title,
|
||||
status: meta.status,
|
||||
// Stale cache entries predating this field are treated as legacy so
|
||||
// the Partial option is never offered without certainty.
|
||||
isLegacy: meta.isLegacy ?? true,
|
||||
};
|
||||
})
|
||||
.filter((item): item is EnvelopeBulkDownloadItem => item !== null);
|
||||
}, [selectedEnvelopeIds, envelopeMetaCache]);
|
||||
|
||||
if (params.toString()) {
|
||||
path += `?${params.toString()}`;
|
||||
}
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return Boolean(
|
||||
(findDocumentSearchParams.status && findDocumentSearchParams.status !== ExtendedDocumentStatus.ALL) ||
|
||||
findDocumentSearchParams.senderIds?.length ||
|
||||
findDocumentSearchParams.period,
|
||||
);
|
||||
}, [findDocumentSearchParams]);
|
||||
|
||||
return path;
|
||||
const onResetFilters = () => {
|
||||
void setFindDocumentSearchParams({
|
||||
status: null,
|
||||
senderIds: null,
|
||||
period: null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -136,67 +180,40 @@ export default function DocumentsPage() {
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="mt-8 flex flex-row items-center">
|
||||
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
<h2 className="font-semibold text-4xl">
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||
<div className="w-56">
|
||||
<DocumentSearch />
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.CANCELLED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
]
|
||||
.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
<DocumentsTableStatusFilter stats={stats} />
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((value) => (
|
||||
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<DocumentsTablePeriodFilter />
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||
</div>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
|
||||
<DocumentsTableEmptyState status={findDocumentSearchParams.status ?? ExtendedDocumentStatus.ALL} />
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
@@ -235,12 +252,28 @@ export default function DocumentsPage() {
|
||||
|
||||
<EnvelopesTableBulkActionBar
|
||||
selectedCount={selectedEnvelopeIds.length}
|
||||
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
|
||||
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
|
||||
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
|
||||
onClearSelection={() => setRowSelection({})}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkDownloadDialog
|
||||
envelopes={selectedEnvelopesForDownload}
|
||||
open={isBulkDownloadDialogOpen}
|
||||
onOpenChange={setIsBulkDownloadDialogOpen}
|
||||
onSuccess={(successfulEnvelopeIds) => {
|
||||
setRowSelection((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of successfulEnvelopeIds) {
|
||||
delete next[id];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<EnvelopesBulkMoveDialog
|
||||
envelopeIds={selectedEnvelopeIds}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
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 type { Route } from './+types/settings._index';
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
const team = await getTeamWithEmail({
|
||||
userId: user.id,
|
||||
teamUrl: params.teamUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
team,
|
||||
};
|
||||
}
|
||||
|
||||
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { team } = loaderData;
|
||||
|
||||
const currentTeam = useCurrentTeam();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
|
||||
|
||||
<AvatarImageForm team={currentTeam} className="mb-8" />
|
||||
|
||||
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
{(team.teamEmail || team.emailVerification) && (
|
||||
<Alert className="p-6" variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>Team email</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
You can view documents associated with this email and use this identity when sending documents.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
|
||||
<hr className="mt-2 border-border/50" />
|
||||
|
||||
<div className="flex flex-row items-center justify-between pt-4">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
|
||||
primaryText={
|
||||
<span className="font-semibold text-foreground/80 text-sm">
|
||||
{team.teamEmail?.name || team.emailVerification?.name}
|
||||
</span>
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type TBrandingPreferencesFormSchema,
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { BrandingUpsell } from '~/components/general/settings-upsell/branding-upsell';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
@@ -115,7 +116,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.`}
|
||||
@@ -155,6 +156,8 @@ export default function TeamsSettingsPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : IS_DOCUMENSO_CLOUD() ? (
|
||||
<BrandingUpsell />
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
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';
|
||||
|
||||
import {
|
||||
DocumentPreferencesForm,
|
||||
@@ -13,21 +10,8 @@ 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 {
|
||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
@@ -46,15 +30,10 @@ export default function TeamsSettingsPage() {
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
@@ -64,13 +43,8 @@ export default function TeamsSettingsPage() {
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
...(signatureTypes.length === 0
|
||||
? {
|
||||
typedSignatureEnabled: null,
|
||||
@@ -82,7 +56,7 @@ export default function TeamsSettingsPage() {
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
}),
|
||||
delegateDocumentOwnership: delegateDocumentOwnership,
|
||||
delegateDocumentOwnership,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,7 +84,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.`}
|
||||
@@ -119,7 +93,6 @@ export default function TeamsSettingsPage() {
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={true}
|
||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onDocumentPreferencesSubmit}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetApiTokensResponse } from '@documenso/trpc/server/api-token-router/get-api-tokens.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { TokenCreateDialog } from '~/components/dialogs/token-create-dialog';
|
||||
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
|
||||
import { ApiTokenForm } from '~/components/forms/token';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@@ -18,33 +22,89 @@ export function meta() {
|
||||
}
|
||||
|
||||
export default function ApiTokensPage() {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const { data: tokens } = trpc.apiToken.getMany.useQuery();
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const isUnauthorized = !!team && team.currentTeamRole !== TeamMemberRole.ADMIN;
|
||||
|
||||
const {
|
||||
data: tokens,
|
||||
isLoading,
|
||||
isError,
|
||||
} = trpc.apiToken.getMany.useQuery(undefined, {
|
||||
enabled: !isUnauthorized,
|
||||
});
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Name`,
|
||||
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
header: t`Created`,
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
{
|
||||
header: t`Expires`,
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.expires) {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Never</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.original.expires < new Date()) {
|
||||
return (
|
||||
<Badge variant="destructive" size="small">
|
||||
<Trans>Expired</Trans>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return i18n.date(row.original.expires);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t`Actions`,
|
||||
cell: ({ row }) => (
|
||||
<TokenDeleteDialog token={row.original}>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</TokenDeleteDialog>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<TGetApiTokensResponse[number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={<Trans>API Tokens</Trans>}
|
||||
subtitle={
|
||||
<Trans>
|
||||
On this page, you can create and manage API tokens. See our{' '}
|
||||
Create and manage API tokens. See our{' '}
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href={'https://docs.documenso.com/developers/public-api'}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Documentation
|
||||
documentation
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
>
|
||||
{!isUnauthorized && <TokenCreateDialog />}
|
||||
</SettingsHeader>
|
||||
|
||||
{team && team?.currentTeamRole !== TeamMemberRole.ADMIN ? (
|
||||
{isUnauthorized ? (
|
||||
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="warning">
|
||||
<div>
|
||||
<AlertTitle>
|
||||
@@ -56,58 +116,43 @@ export default function ApiTokensPage() {
|
||||
</div>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<ApiTokenForm className="max-w-xl" tokens={tokens} />
|
||||
|
||||
<hr className="mt-8 mb-4" />
|
||||
|
||||
<h4 className="font-medium text-xl">
|
||||
<Trans>Your existing tokens</Trans>
|
||||
</h4>
|
||||
|
||||
{tokens && tokens.length === 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="mt-2 text-muted-foreground text-sm italic">
|
||||
<Trans>Your tokens will be shown here once you create them.</Trans>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={tokens ?? []}
|
||||
perPage={0}
|
||||
currentPage={0}
|
||||
totalPages={0}
|
||||
error={{
|
||||
enable: isError,
|
||||
}}
|
||||
emptyState={
|
||||
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
<p>
|
||||
<Trans>You have no API tokens yet. Your tokens will be shown here once you create them.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokens && tokens.length > 0 && (
|
||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<div>
|
||||
<h5 className="text-base">{token.name}</h5>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-xs">
|
||||
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
|
||||
</p>
|
||||
{token.expires ? (
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
<Trans>Token doesn't have an expiration date</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TokenDeleteDialog token={token}>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</TokenDeleteDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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.`}
|
||||
>
|
||||
|
||||
@@ -26,12 +26,14 @@ import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
||||
|
||||
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Templates`);
|
||||
}
|
||||
|
||||
// Stable initial value: `useSessionStorage` keeps its setter identity stable
|
||||
// only while the initial value reference is stable.
|
||||
const EMPTY_ROW_SELECTION: RowSelectionState = {};
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
@@ -47,7 +49,11 @@ export default function TemplatesPage() {
|
||||
const isOrgView = view === 'organisation';
|
||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('templates-bulk-selection', {});
|
||||
// Scoped by team so selections made in one team never leak into another.
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
`templates-bulk-selection-${team.id}`,
|
||||
EMPTY_ROW_SELECTION,
|
||||
);
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
|
||||
@@ -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">
|
||||
@@ -120,11 +135,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.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/
|
||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Plural } from '@lingui/react/macro';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
@@ -14,6 +15,7 @@ import { redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
|
||||
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
||||
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
@@ -70,8 +72,18 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
};
|
||||
}
|
||||
|
||||
const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields);
|
||||
|
||||
if (recipientsWithMissingFields.length > 0) {
|
||||
return {
|
||||
isAccessAuthValid: true,
|
||||
isTemplateMissingSignatures: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
isAccessAuthValid: true,
|
||||
isTemplateMissingSignatures: false,
|
||||
template: {
|
||||
...template,
|
||||
folder: null,
|
||||
@@ -96,6 +108,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
.then((envelopeForSigning) => {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
isTemplateMissingSignatures: false,
|
||||
envelopeForSigning,
|
||||
} as const;
|
||||
})
|
||||
@@ -108,6 +121,13 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
isTemplateMissingSignatures: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
});
|
||||
};
|
||||
@@ -181,6 +201,10 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
return <DirectTemplateAuthPageView />;
|
||||
}
|
||||
|
||||
if (data.isTemplateMissingSignatures) {
|
||||
return <DirectTemplateInvalidPageView />;
|
||||
}
|
||||
|
||||
const { template, directTemplateRecipient } = data;
|
||||
|
||||
return (
|
||||
@@ -235,6 +259,10 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
||||
}
|
||||
|
||||
if (data.isTemplateMissingSignatures) {
|
||||
return <DirectTemplateInvalidPageView />;
|
||||
}
|
||||
|
||||
const { envelope, recipient } = data.envelopeForSigning;
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function WaitingForTurnToSignPage({ loaderData }: Route.Component
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h2 className="font-bold text-3xl tracking-tigh">
|
||||
<h2 className="font-bold text-3xl tracking-tight">
|
||||
<Trans>Waiting for Your Turn</Trans>
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -1,98 +1,15 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { Link } from 'react-router';
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/organisation.decline.$token';
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
export function loader({ params }: Route.LoaderArgs) {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
state: 'InvalidLink',
|
||||
} as const;
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationMemberInvite) {
|
||||
return {
|
||||
state: 'InvalidLink',
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (organisationMemberInvite.status !== OrganisationMemberInviteStatus.DECLINED) {
|
||||
await prisma.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.DECLINED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'Success',
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default function DeclineInvitationPage({ loaderData }: Route.ComponentProps) {
|
||||
const data = loaderData;
|
||||
|
||||
if (data.state === 'InvalidLink') {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invalid token</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>This token is invalid or has expired. No action is needed.</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation declined</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have declined the invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return to Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
// Declining now happens on the invite page via tRPC. Redirect there with the
|
||||
// `action=decline` flag so it renders the decline-only view (no accept).
|
||||
throw redirect(`/organisation/invite/${token}?action=decline`);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
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 { Link } from 'react-router';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { Route } from './+types/organisation.invite.$token';
|
||||
|
||||
@@ -37,6 +43,22 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
const organisationName = organisationMemberInvite.organisation.name;
|
||||
|
||||
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
|
||||
return {
|
||||
state: 'AlreadyAccepted',
|
||||
organisationName,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED) {
|
||||
return {
|
||||
state: 'AlreadyDeclined',
|
||||
organisationName,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
@@ -49,26 +71,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
// Directly convert the team member invite to a team member if they already have an account.
|
||||
if (user) {
|
||||
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
state: 'LoginRequired',
|
||||
email: organisationMemberInvite.email,
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
||||
|
||||
return {
|
||||
state: 'Success',
|
||||
state: 'Pending',
|
||||
token: organisationMemberInvite.token,
|
||||
email: organisationMemberInvite.email,
|
||||
organisationName: organisationMemberInvite.organisation.name,
|
||||
isSessionUserTheInvitedUser,
|
||||
organisationName,
|
||||
userExists: user !== null,
|
||||
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -97,57 +106,253 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
||||
);
|
||||
}
|
||||
|
||||
if (data.state === 'LoginRequired') {
|
||||
if (data.state === 'AlreadyAccepted') {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Organisation invitation</Trans>
|
||||
</h1>
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation already accepted</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have been invited by <strong>{data.organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You are already a member of <strong>{data.organisationName}</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>To accept this invitation you must create an account.</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
|
||||
<Trans>Create account</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.state === 'AlreadyDeclined') {
|
||||
return <InvitationDeclined organisationName={data.organisationName} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation accepted!</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have accepted an invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{data.isSessionUserTheInvitedUser ? (
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link to={`/signin#email=${encodeURIComponent(data.email)}`}>
|
||||
<Trans>Continue to login</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<PendingInvitation
|
||||
token={data.token}
|
||||
email={data.email}
|
||||
organisationName={data.organisationName}
|
||||
userExists={data.userExists}
|
||||
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PendingInvitationProps = {
|
||||
token: string;
|
||||
email: string;
|
||||
organisationName: string;
|
||||
userExists: boolean;
|
||||
isSessionUserTheInvitedUser: boolean;
|
||||
};
|
||||
|
||||
type InvitationResult = 'idle' | 'accepted' | 'declined';
|
||||
|
||||
type AcceptFailureReason = 'CapExceeded' | 'SubscriptionInactive' | 'Unknown';
|
||||
|
||||
const PendingInvitation = ({
|
||||
token,
|
||||
email,
|
||||
organisationName,
|
||||
userExists,
|
||||
isSessionUserTheInvitedUser,
|
||||
}: PendingInvitationProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useOptionalSession();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const actionIsDecline = searchParams.get('action') === 'decline';
|
||||
|
||||
const [result, setResult] = useState<InvitationResult>('idle');
|
||||
const [acceptFailureReason, setAcceptFailureReason] = useState<AcceptFailureReason | null>(null);
|
||||
|
||||
const acceptInvitation = trpc.organisation.member.invite.accept.useMutation({
|
||||
onSuccess: async () => {
|
||||
await refreshSession();
|
||||
|
||||
setResult('accepted');
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const failureReason = match(error.code)
|
||||
.with(AppErrorCode.LIMIT_EXCEEDED, () => 'CapExceeded' as const)
|
||||
.with('SUBSCRIPTION_INACTIVE', () => 'SubscriptionInactive' as const)
|
||||
.otherwise(() => 'Unknown' as const);
|
||||
|
||||
setAcceptFailureReason(failureReason);
|
||||
},
|
||||
});
|
||||
|
||||
const declineInvitation = trpc.organisation.member.invite.decline.useMutation({
|
||||
onSuccess: async () => {
|
||||
await refreshSession();
|
||||
|
||||
setResult('declined');
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`Unable to decline this invitation at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (result === 'accepted') {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation accepted!</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have accepted an invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{isSessionUserTheInvitedUser ? (
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Trans>Continue</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link to={`/signin#email=${encodeURIComponent(email)}`}>
|
||||
<Trans>Continue to login</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result === 'declined') {
|
||||
return <InvitationDeclined organisationName={organisationName} />;
|
||||
}
|
||||
|
||||
// Accepting requires an account (acceptance keys off the invited email).
|
||||
// Declining does not, so we only gate account creation on the accept flow.
|
||||
if (!actionIsDecline && !userExists) {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Organisation invitation</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have been invited by <strong>{organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>To accept this invitation you must create an account.</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/signup#email=${encodeURIComponent(email)}`}>
|
||||
<Trans>Create account</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = acceptInvitation.isPending || declineInvitation.isPending;
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Organisation invitation</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have been invited to join <strong>{organisationName}</strong> on Documenso.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{acceptFailureReason && (
|
||||
<p className="mt-2 mb-4 text-destructive text-sm">
|
||||
{match(acceptFailureReason)
|
||||
.with('CapExceeded', () => (
|
||||
<Trans>
|
||||
<strong>{organisationName}</strong> has reached its member limit. Please contact the organisation
|
||||
administrator to upgrade their plan before accepting this invitation.
|
||||
</Trans>
|
||||
))
|
||||
.with('SubscriptionInactive', () => (
|
||||
<Trans>
|
||||
<strong>{organisationName}</strong> does not have an active subscription. Please contact the
|
||||
organisation administrator to renew their plan before accepting this invitation.
|
||||
</Trans>
|
||||
))
|
||||
.with('Unknown', () => (
|
||||
<Trans>
|
||||
We were unable to add you to <strong>{organisationName}</strong> at this time. Please try again later,
|
||||
or contact the organisation administrator.
|
||||
</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => declineInvitation.mutateAsync({ token })}
|
||||
loading={declineInvitation.isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trans>Decline</Trans>
|
||||
</Button>
|
||||
|
||||
{!actionIsDecline && (
|
||||
<Button
|
||||
onClick={async () => acceptInvitation.mutateAsync({ token })}
|
||||
loading={acceptInvitation.isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trans>Accept</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InvitationDeclined = ({ organisationName }: { organisationName: string }) => {
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
<div className="w-full">
|
||||
<h1 className="font-semibold text-4xl">
|
||||
<Trans>Invitation declined</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
You have declined the invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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` },
|
||||
});
|
||||
};
|
||||
@@ -5,8 +5,9 @@
|
||||
*
|
||||
* No translations required.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
export const loader = () => {
|
||||
@@ -147,7 +148,7 @@ export default function EmbedPlaygroundPage() {
|
||||
return inputToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v2/embedding/create-presign-token', {
|
||||
const response = await fetch(formatPath('/api/v2/embedding/create-presign-token'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${inputToken}`,
|
||||
|
||||
Reference in New Issue
Block a user