mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 19:51:32 +10:00
feat: add organisations (#1820)
This commit is contained in:
@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import BillingPage, { meta } from '../../o.$orgUrl.settings.billing';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default BillingPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import PreferencesPage, { meta } from '../../o.$orgUrl.settings.preferences';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default PreferencesPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import PublicProfilePage, { loader, meta } from '../../t.$teamUrl+/settings.public-profile';
|
||||
|
||||
export { meta, loader };
|
||||
|
||||
export default PublicProfilePage;
|
||||
@ -0,0 +1,5 @@
|
||||
import TokensPage, { meta } from '../../t.$teamUrl+/settings.tokens';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default TokensPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default WebhookPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks._index';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default WebhookPage;
|
||||
@ -1,157 +0,0 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { redirect } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
|
||||
import { BillingPlans } from '~/components/general/billing-plans';
|
||||
import { BillingPortalButton } from '~/components/general/billing-portal-button';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/billing';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Billing');
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
// Redirect if subscriptions are not enabled.
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw redirect('/settings/profile');
|
||||
}
|
||||
|
||||
if (!user.customerId) {
|
||||
await getStripeCustomerByUser(user).then((result) => result.user);
|
||||
}
|
||||
|
||||
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
||||
getSubscriptionsByUserId({ userId: user.id }),
|
||||
getPricesByInterval({ plans: [STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.PLATFORM] }),
|
||||
getPrimaryAccountPlanPrices(),
|
||||
]);
|
||||
|
||||
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
|
||||
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
primaryAccountPlanPriceIds.includes(priceId),
|
||||
);
|
||||
|
||||
const subscription =
|
||||
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
primaryAccountPlanSubscriptions[0];
|
||||
|
||||
if (subscription?.priceId) {
|
||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
const isMissingOrInactiveOrFreePlan =
|
||||
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
|
||||
|
||||
return superLoaderJson({
|
||||
prices,
|
||||
subscription,
|
||||
subscriptionProductName: subscriptionProduct?.name,
|
||||
isMissingOrInactiveOrFreePlan,
|
||||
});
|
||||
}
|
||||
|
||||
export default function TeamsSettingBillingPage() {
|
||||
const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
<Trans>Billing</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<p>
|
||||
<Trans>
|
||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Todo: Translation */}
|
||||
{!isMissingOrInactiveOrFreePlan &&
|
||||
match(subscription.status)
|
||||
.with('ACTIVE', () => (
|
||||
<p>
|
||||
{subscriptionProductName ? (
|
||||
<span>
|
||||
You are currently subscribed to{' '}
|
||||
<span className="font-semibold">{subscriptionProductName}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>You currently have an active plan</span>
|
||||
)}
|
||||
|
||||
{subscription.periodEnd && (
|
||||
<span>
|
||||
{' '}
|
||||
which is set to{' '}
|
||||
{subscription.cancelAtPeriodEnd ? (
|
||||
<span>
|
||||
end on{' '}
|
||||
<span className="font-semibold">
|
||||
{i18n.date(subscription.periodEnd)}.
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
automatically renew on{' '}
|
||||
<span className="font-semibold">
|
||||
{i18n.date(subscription.periodEnd)}.
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<p>
|
||||
<Trans>
|
||||
Your current plan is past due. Please update your payment information.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<BillingPortalButton>
|
||||
<Trans>Manage billing</Trans>
|
||||
</BillingPortalButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { OrganisationCreateDialog } from '~/components/dialogs/organisation-create-dialog';
|
||||
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { UserOrganisationsTable } from '~/components/tables/user-organisations-table';
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Organisations`)}
|
||||
subtitle={_(msg`Manage all organisations you are currently associated with.`)}
|
||||
>
|
||||
<OrganisationCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<UserOrganisationsTable />
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
<OrganisationInvitations />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,17 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
|
||||
import { AccountDeleteDialog } from '~/components/dialogs/account-delete-dialog';
|
||||
import { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||
import { ProfileForm } from '~/components/forms/profile';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { TeamEmailUsage } from '~/components/general/teams/team-email-usage';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
@ -13,6 +20,11 @@ export function meta() {
|
||||
|
||||
export default function SettingsProfile() {
|
||||
const { _ } = useLingui();
|
||||
const { organisations, user } = useSession();
|
||||
|
||||
const { data: teamEmail } = trpc.team.email.get.useQuery();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -26,7 +38,17 @@ export default function SettingsProfile() {
|
||||
|
||||
<hr className="my-4 max-w-xl" />
|
||||
|
||||
<AccountDeleteDialog className="max-w-xl" />
|
||||
<div className="max-w-xl space-y-8">
|
||||
<AnimatePresence>
|
||||
{(!isPersonalLayoutMode || user.email !== teamEmail?.email) && teamEmail && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<TeamEmailUsage teamEmail={teamEmail} />
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AccountDeleteDialog />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,237 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TemplateDirectLink } from '@prisma/client';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ManagePublicTemplateDialog } from '~/components/dialogs/public-profile-template-manage-dialog';
|
||||
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
|
||||
import { PublicProfileForm } from '~/components/forms/public-profile-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { SettingsPublicProfileTemplatesTable } from '~/components/tables/settings-public-profile-templates-table';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import type { Route } from './+types/public-profile';
|
||||
|
||||
type DirectTemplate = FindTemplateRow & {
|
||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
||||
};
|
||||
|
||||
const userProfileText = {
|
||||
settingsTitle: msg`Public Profile`,
|
||||
settingsSubtitle: msg`You can choose to enable or disable your profile for public view.`,
|
||||
templatesTitle: msg`My templates`,
|
||||
templatesSubtitle: msg`Show templates in your public profile for your audience to sign and get started quickly`,
|
||||
};
|
||||
|
||||
const teamProfileText = {
|
||||
settingsTitle: msg`Team Public Profile`,
|
||||
settingsSubtitle: msg`You can choose to enable or disable your team profile for public view.`,
|
||||
templatesTitle: msg`Team templates`,
|
||||
templatesSubtitle: msg`Show templates in your team public profile for your audience to sign and get started quickly`,
|
||||
};
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
const { profile } = await getUserPublicProfile({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return { profile };
|
||||
}
|
||||
|
||||
export default function PublicProfilePage({ loaderData }: Route.ComponentProps) {
|
||||
const { profile } = loaderData;
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { user, refreshSession } = useSession();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
|
||||
const { data } = trpc.template.findTemplates.useQuery({
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateUserProfile, isPending: isUpdatingUserProfile } =
|
||||
trpc.profile.updatePublicProfile.useMutation();
|
||||
|
||||
const { mutateAsync: updateTeamProfile, isPending: isUpdatingTeamProfile } =
|
||||
trpc.team.updateTeamPublicProfile.useMutation();
|
||||
|
||||
const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile;
|
||||
const profileText = team ? teamProfileText : userProfileText;
|
||||
|
||||
const enabledPrivateDirectTemplates = useMemo(
|
||||
() =>
|
||||
(data?.data ?? []).filter(
|
||||
(template): template is DirectTemplate =>
|
||||
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
const onProfileUpdate = async (data: TPublicProfileFormSchema) => {
|
||||
if (team) {
|
||||
await updateTeamProfile({
|
||||
teamId: team.id,
|
||||
...data,
|
||||
});
|
||||
} else {
|
||||
await updateUserProfile(data);
|
||||
|
||||
// Need to refresh session because we're editing the user's profile.
|
||||
await refreshSession();
|
||||
}
|
||||
|
||||
if (data.enabled === undefined && !isPublicProfileVisible) {
|
||||
setIsTooltipOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePublicProfileVisibility = async (isVisible: boolean) => {
|
||||
setIsTooltipOpen(false);
|
||||
|
||||
if (isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible && !user.url) {
|
||||
toast({
|
||||
title: _(msg`You must set a profile URL before enabling your public profile.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublicProfileVisible(isVisible);
|
||||
|
||||
try {
|
||||
await onProfileUpdate({
|
||||
enabled: isVisible,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`We were unable to set your public profile to public. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
setIsPublicProfileVisible(!isVisible);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsPublicProfileVisible(profile.enabled);
|
||||
}, [profile.enabled]);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={_(profileText.settingsTitle)}
|
||||
subtitle={_(profileText.settingsSubtitle)}
|
||||
>
|
||||
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
|
||||
{
|
||||
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
|
||||
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
<Trans>Hide</Trans>
|
||||
</span>
|
||||
<Switch
|
||||
disabled={isUpdating}
|
||||
checked={isPublicProfileVisible}
|
||||
onCheckedChange={togglePublicProfileVisibility}
|
||||
/>
|
||||
<span>
|
||||
<Trans>Show</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
|
||||
{isPublicProfileVisible ? (
|
||||
<>
|
||||
<p>
|
||||
<Trans>
|
||||
Profile is currently <strong>visible</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>Toggle the switch to hide your profile from the public.</Trans>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<Trans>
|
||||
Profile is currently <strong>hidden</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>Toggle the switch to show your profile to the public.</Trans>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SettingsHeader>
|
||||
|
||||
<PublicProfileForm
|
||||
profileUrl={team ? team.url : user.url}
|
||||
teamUrl={team?.url}
|
||||
profile={profile}
|
||||
onProfileUpdate={onProfileUpdate}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<SettingsHeader
|
||||
title={_(profileText.templatesTitle)}
|
||||
subtitle={_(profileText.templatesSubtitle)}
|
||||
hideDivider={true}
|
||||
className="mt-8 [&>*>h3]:text-base"
|
||||
>
|
||||
<ManagePublicTemplateDialog
|
||||
directTemplates={enabledPrivateDirectTemplates}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Trans>Link template</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="mt-6">
|
||||
<SettingsPublicProfileTemplatesTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
|
||||
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { TeamEmailUsage } from '~/components/general/teams/team-email-usage';
|
||||
import { TeamInvitations } from '~/components/general/teams/team-invitations';
|
||||
import { UserSettingsTeamsPageDataTable } from '~/components/tables/user-settings-teams-page-table';
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Teams`)}
|
||||
subtitle={_(msg`Manage all teams you are currently associated with.`)}
|
||||
>
|
||||
<TeamCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<UserSettingsTeamsPageDataTable />
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
<AnimatePresence>
|
||||
{teamEmail && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<TeamEmailUsage teamEmail={teamEmail} />
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<TeamInvitations />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
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';
|
||||
|
||||
export default function ApiTokensPage() {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={<Trans>API Tokens</Trans>}
|
||||
subtitle={
|
||||
<Trans>
|
||||
On this page, you can create and manage API tokens. See our{' '}
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href={'https://docs.documenso.com/developers/public-api'}
|
||||
target="_blank"
|
||||
>
|
||||
Documentation
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
|
||||
{team && team?.currentTeamMember.role !== TeamMemberRole.ADMIN ? (
|
||||
<Alert
|
||||
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
|
||||
variant="warning"
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>
|
||||
<Trans>Unauthorized</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>You need to be an admin to manage API tokens.</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<ApiTokenForm className="max-w-xl" tokens={tokens} />
|
||||
|
||||
<hr className="mb-4 mt-8" />
|
||||
|
||||
<h4 className="text-xl font-medium">
|
||||
<Trans>Your existing tokens</Trans>
|
||||
</h4>
|
||||
|
||||
{tokens && tokens.length === 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||
<Trans>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="border-border rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<div>
|
||||
<h5 className="text-base">{token.name}</h5>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
<Trans>
|
||||
Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}
|
||||
</Trans>
|
||||
</p>
|
||||
{token.expires ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
<Trans>
|
||||
Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-1 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,212 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useParams, useRevalidator } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
|
||||
|
||||
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
||||
|
||||
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
||||
|
||||
export default function WebhookPage() {
|
||||
const params = useParams();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const webhookId = params.id || '';
|
||||
|
||||
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
||||
{
|
||||
id: webhookId,
|
||||
},
|
||||
{ enabled: !!webhookId },
|
||||
);
|
||||
|
||||
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
|
||||
|
||||
const form = useForm<TEditWebhookFormSchema>({
|
||||
resolver: zodResolver(ZEditWebhookFormSchema),
|
||||
values: {
|
||||
webhookUrl: webhook?.webhookUrl ?? '',
|
||||
eventTriggers: webhook?.eventTriggers ?? [],
|
||||
secret: webhook?.secret ?? '',
|
||||
enabled: webhook?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TEditWebhookFormSchema) => {
|
||||
try {
|
||||
await updateWebhook({
|
||||
id: webhookId,
|
||||
...data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Webhook updated`),
|
||||
description: _(msg`The webhook has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Failed to update webhook`),
|
||||
description: _(
|
||||
msg`We encountered an error while updating the webhook. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Edit webhook`)}
|
||||
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>
|
||||
<Trans>Webhook URL</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The URL for Documenso to send webhook events to.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTriggers"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel required>
|
||||
<Trans>Triggers</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<WebhookMultiSelectCombobox
|
||||
listValues={value}
|
||||
onChange={(values: string[]) => {
|
||||
onChange(values);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans> The events that will trigger a webhook to be sent to your URL.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Secret</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
A secret that will be sent to your URL so you can verify that the request has
|
||||
been sent by Documenso.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update webhook</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
|
||||
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
export default function WebhookPage() {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Webhooks`)}
|
||||
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
|
||||
>
|
||||
<WebhookCreateDialog />
|
||||
</SettingsHeader>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
{webhooks && webhooks.length === 0 && (
|
||||
// TODO: Perhaps add some illustrations here to make the page more engaging
|
||||
<div className="mb-4">
|
||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||
<Trans>
|
||||
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{webhooks && webhooks.length > 0 && (
|
||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||
{webhooks?.map((webhook) => (
|
||||
<div
|
||||
key={webhook.id}
|
||||
className={cn(
|
||||
'border-border rounded-lg border p-4',
|
||||
!webhook.enabled && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="truncate font-mono text-xs">{webhook.id}</div>
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-4">
|
||||
<h5
|
||||
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
|
||||
title={webhook.webhookUrl}
|
||||
>
|
||||
{webhook.webhookUrl}
|
||||
</h5>
|
||||
|
||||
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
|
||||
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
<Trans>
|
||||
Listening to{' '}
|
||||
{webhook.eventTriggers
|
||||
.map((trigger) => toFriendlyWebhookEventName(trigger))
|
||||
.join(', ')}
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
<Trans>Created on {i18n.date(webhook.createdAt, DateTime.DATETIME_FULL)}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/settings/webhooks/${webhook.id}`}>
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<WebhookDeleteDialog webhook={webhook}>
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</WebhookDeleteDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user