feat: migrate nextjs to rr7

This commit is contained in:
David Nguyen
2025-01-02 15:33:37 +11:00
parent 9183f668d3
commit 383b5f78f0
898 changed files with 31175 additions and 24615 deletions

View File

@ -0,0 +1,5 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect('/settings/profile');
}

View File

@ -0,0 +1,29 @@
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 { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Settings');
}
export default function SettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">
<Trans>Settings</Trans>
</h1>
<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" />
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
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 { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Profile');
}
export default function SettingsProfile() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Profile`)}
subtitle={_(msg`Here you can edit your personal details.`)}
/>
<AvatarImageForm className="mb-8 max-w-xl" />
<ProfileForm className="mb-8 max-w-xl" />
<hr className="my-4 max-w-xl" />
<AccountDeleteDialog className="max-w-xl" />
</div>
);
}

View File

@ -0,0 +1,234 @@
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 { getLoaderSession } from 'server/utils/get-loader-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 { useOptionalCurrentTeam } from '~/providers/team';
import { SettingsPublicProfileTemplatesTable } from '../../../../components/tables/settings-public-profile-templates-table';
import type { Route } from './+types/index';
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() {
const { user } = getLoaderSession();
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 } = 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);
}
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>
);
}

View File

@ -0,0 +1,28 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsSecurityActivityTable } from '~/components/tables/settings-security-activity-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Security activity');
}
export default function SettingsSecurityActivity() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Security activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
/>
<div className="mt-4">
<SettingsSecurityActivityTable />
</div>
</div>
);
}

View File

@ -0,0 +1,164 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { prisma } from '@documenso/prisma';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
import { PasswordForm } from '~/components/forms/password';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types';
export function meta() {
return appMetaTags('Security');
}
export async function loader() {
const { user } = getLoaderSession();
const accounts = await prisma.account.findMany({
where: {
userId: user.id,
},
select: {
provider: true,
},
});
const providers = accounts.map((account) => account.provider);
return {
providers,
};
}
export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
const { providers } = loaderData;
const { _ } = useLingui();
const { user } = useSession();
const hasEmailPasswordAccount = providers.includes('DOCUMENSO');
return (
<div>
<SettingsHeader
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
{hasEmailPasswordAccount && (
<>
<PasswordForm user={user} />
<hr className="border-border/50 mt-6" />
</>
)}
<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">
<AlertTitle>
<Trans>Two factor authentication</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
{hasEmailPasswordAccount ? (
<Trans>
Add an authenticator to serve as a secondary authentication method when signing in,
or when signing documents.
</Trans>
) : (
<Trans>
Add an authenticator to serve as a secondary authentication method for signing
documents.
</Trans>
)}
</AlertDescription>
</div>
{user.twoFactorEnabled ? (
<DisableAuthenticatorAppDialog />
) : (
<EnableAuthenticatorAppDialog />
)}
</Alert>
{user.twoFactorEnabled && (
<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">
<AlertTitle>
<Trans>Recovery codes</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
<Trans>
Two factor authentication recovery codes are used to access your account in the
event that you lose access to your authenticator app.
</Trans>
</AlertDescription>
</div>
<ViewRecoveryCodesDialog />
</Alert>
)}
<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">
<AlertTitle>
<Trans>Passkeys</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
<Trans>
Allows authenticating using biometrics, password managers, hardware keys, etc.
</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/passkeys">
<Trans>Manage passkeys</Trans>
</Link>
</Button>
</Alert>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>
<Trans>Recent activity</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>View all recent security activity related to your account.</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/activity">
<Trans>View activity</Trans>
</Link>
</Button>
</Alert>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { PasskeyCreateDialog } from '~/components/dialogs/passkey-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsSecurityPasskeyTable } from '~/components/tables/settings-security-passkey-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Manage passkeys');
}
export default function SettingsPasskeys() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Passkeys`)}
subtitle={_(msg`Manage your passkeys.`)}
hideDivider={true}
>
<PasskeyCreateDialog />
</SettingsHeader>
<div className="mt-4">
<SettingsSecurityPasskeyTable />
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
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 { UserSettingsTeamsPageDataTable } from '~/components/tables/user-settings-teams-page-table';
import { TeamEmailUsage } from './team-email-usage';
import { TeamInvitations } from './team-invitations';
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>
);
}

View File

@ -0,0 +1,125 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamEmail } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamEmailUsageProps = {
teamEmail: TeamEmail & { team: { name: string; url: string } };
};
export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully revoked access.`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We encountered an unknown error while attempting to revoke access. Please try again or contact support.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
<div>
<AlertTitle className="mb-0">
<Trans>Team Email</Trans>
</AlertTitle>
<AlertDescription>
<p>
<Trans>
Your email is currently being used by team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
).
</Trans>
</p>
<p className="mt-1">
<Trans>They have permission on your behalf to:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>Display your name and email in documents</Trans>
</li>
<li>
<Trans>View all documents sent to your account</Trans>
</li>
</ul>
</AlertDescription>
</div>
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Revoke access</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to revoke access for team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url})
to use your email.
</Trans>
</DialogDescription>
</DialogHeader>
<fieldset disabled={isDeletingTeamEmail}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingTeamEmail}
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
>
<Trans>Revoke</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
</Alert>
);
};

View File

@ -0,0 +1,187 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { AnimatePresence } from 'framer-motion';
import { BellIcon } from 'lucide-react';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const TeamInvitations = () => {
const { data, isLoading } = trpc.team.getTeamInvitations.useQuery();
return (
<AnimatePresence>
{data && data.length > 0 && !isLoading && (
<AnimateGenericFadeInOut>
<Alert variant="secondary">
<div className="flex h-full flex-row items-center p-2">
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
<AlertDescription className="mr-2">
<Plural
value={data.length}
one={
<span>
You have <strong>1</strong> pending team invitation
</span>
}
other={
<span>
You have <strong>#</strong> pending team invitations
</span>
}
/>
</AlertDescription>
<Dialog>
<DialogTrigger asChild>
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
<Trans>View invites</Trans>
</button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Pending invitations</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Plural
value={data.length}
one={
<span>
You have <strong>1</strong> pending team invitation
</span>
}
other={
<span>
You have <strong>#</strong> pending team invitations
</span>
}
/>
</DialogDescription>
</DialogHeader>
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
{data.map((invitation) => (
<li key={invitation.teamId}>
<AvatarWithText
avatarSrc={formatAvatarUrl(invitation.team.avatarImageId)}
className="w-full max-w-none py-4"
avatarFallback={invitation.team.name.slice(0, 1)}
primaryText={
<span className="text-foreground/80 font-semibold">
{invitation.team.name}
</span>
}
secondaryText={formatTeamUrl(invitation.team.url)}
rightSideComponent={
<div className="ml-auto space-x-2">
<DeclineTeamInvitationButton teamId={invitation.team.id} />
<AcceptTeamInvitationButton teamId={invitation.team.id} />
</div>
}
/>
</li>
))}
</ul>
</DialogContent>
</Dialog>
</div>
</Alert>
</AnimateGenericFadeInOut>
)}
</AnimatePresence>
);
};
const AcceptTeamInvitationButton = ({ teamId }: { teamId: number }) => {
const { _ } = useLingui();
const { toast } = useToast();
const {
mutateAsync: acceptTeamInvitation,
isPending,
isSuccess,
} = trpc.team.acceptTeamInvitation.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Accepted team invitation`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to join this team at this time.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Button
onClick={async () => acceptTeamInvitation({ teamId })}
loading={isPending}
disabled={isPending || isSuccess}
>
<Trans>Accept</Trans>
</Button>
);
};
const DeclineTeamInvitationButton = ({ teamId }: { teamId: number }) => {
const { _ } = useLingui();
const { toast } = useToast();
const {
mutateAsync: declineTeamInvitation,
isPending,
isSuccess,
} = trpc.team.declineTeamInvitation.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Declined team invitation`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to decline this team invitation at this time.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Button
onClick={async () => declineTeamInvitation({ teamId })}
loading={isPending}
disabled={isPending || isSuccess}
variant="ghost"
>
<Trans>Decline</Trans>
</Button>
);
};

View File

@ -0,0 +1,91 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
return (
<div>
<h3 className="text-2xl font-semibold">
<Trans>API Tokens</Trans>
</h3>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
On this page, you can create new API tokens and manage the existing ones. <br />
Also see our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
>
Documentation
</a>
.
</Trans>
</p>
<hr className="my-4" />
<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>
);
}

View File

@ -0,0 +1,212 @@
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>
);
}

View File

@ -0,0 +1,106 @@
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>
);
}