feat: web i18n (#1286)

This commit is contained in:
David Nguyen
2024-08-27 20:34:39 +09:00
committed by GitHub
parent 0829311214
commit 75c8772a02
294 changed files with 14846 additions and 2229 deletions

View File

@@ -2,6 +2,9 @@
import { useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence, motion } from 'framer-motion';
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
@@ -21,11 +24,11 @@ const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
const FRIENDLY_INTERVALS: Record<Interval, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
year: 'Yearly',
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
day: msg`Daily`,
week: msg`Weekly`,
month: msg`Monthly`,
year: msg`Yearly`,
};
const MotionCard = motion(Card);
@@ -35,6 +38,7 @@ export type BillingPlansProps = {
};
export const BillingPlans = ({ prices }: BillingPlansProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const isMounted = useIsMounted();
@@ -55,8 +59,8 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
window.open(url);
} catch (_err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while trying to create a checkout session.',
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while trying to create a checkout session.`),
variant: 'destructive',
});
} finally {
@@ -72,7 +76,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
(interval) =>
prices[interval].length > 0 && (
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
{FRIENDLY_INTERVALS[interval]}
{_(FRIENDLY_INTERVALS[interval])}
</TabsTrigger>
),
)}
@@ -121,7 +125,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
loading={isFetchingCheckoutSession}
onClick={() => void onSubscribeClick(price.id)}
>
Subscribe
<Trans>Subscribe</Trans>
</Button>
</CardContent>
</MotionCard>

View File

@@ -2,6 +2,9 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -12,6 +15,7 @@ export type BillingPortalButtonProps = {
};
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
@@ -32,16 +36,18 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
window.open(sessionUrl, '_blank');
} catch (e) {
let description =
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
let description = _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
);
if (e.message === 'CUSTOMER_NOT_FOUND') {
description =
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
description = _(
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
);
}
toast({
title: 'Something went wrong',
title: _(msg`Something went wrong`),
description,
variant: 'destructive',
duration: 10000,
@@ -57,7 +63,7 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
onClick={async () => handleFetchPortalUrl()}
loading={isFetchingPortalUrl}
>
Manage Subscription
<Trans>Manage Subscription</Trans>
</Button>
);
};

View File

@@ -1,12 +1,14 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { match } from 'ts-pattern';
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 { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
@@ -24,6 +26,8 @@ export const metadata: Metadata = {
};
export default async function BillingSettingsPage() {
setupI18nSSR();
let { user } = await getRequiredServerComponentSession();
const isBillingEnabled = await getServerComponentFlag('app_billing');
@@ -66,15 +70,20 @@ export default async function BillingSettingsPage() {
return (
<div>
<h3 className="text-2xl font-semibold">Billing</h3>
<h3 className="text-2xl font-semibold">
<Trans>Billing</Trans>
</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (
<p>
You are currently on the <span className="font-semibold">Free Plan</span>.
<Trans>
You are currently on the <span className="font-semibold">Free Plan</span>.
</Trans>
</p>
)}
{/* Todo: Translation */}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
@@ -108,7 +117,11 @@ export default async function BillingSettingsPage() {
</p>
))
.with('PAST_DUE', () => (
<p>Your current plan is past due. Please update your payment information.</p>
<p>
<Trans>
Your current plan is past due. Please update your payment information.
</Trans>
</p>
))
.otherwise(() => null)}
</div>

View File

@@ -1,5 +1,9 @@
import React from 'react';
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { DesktopNav } from '~/components/(dashboard)/settings/layout/desktop-nav';
import { MobileNav } from '~/components/(dashboard)/settings/layout/mobile-nav';
@@ -8,9 +12,13 @@ export type DashboardSettingsLayoutProps = {
};
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
setupI18nSSR();
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">Settings</h1>
<h1 className="text-4xl font-semibold">
<Trans>Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<DesktopNav className="hidden md:col-span-3 md:flex" />

View File

@@ -2,6 +2,8 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { signOut } from 'next-auth/react';
import type { User } from '@documenso/prisma/client';
@@ -28,6 +30,7 @@ export type DeleteAccountDialogProps = {
};
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const hasTwoFactorAuthentication = user.twoFactorEnabled;
@@ -42,8 +45,8 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
await deleteAccount();
toast({
title: 'Account deleted',
description: 'Your account has been deleted successfully.',
title: _(msg`Account deleted`),
description: _(msg`Your account has been deleted successfully.`),
duration: 5000,
});
@@ -51,17 +54,19 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
title: _(msg`An error occurred`),
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your account. Please try again later.',
_(
msg`We encountered an unknown error while attempting to delete your account. Please try again later.`,
),
});
}
}
@@ -74,50 +79,63 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
variant="neutral"
>
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertTitle>
<Trans>Delete Account</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
Delete your account and all its contents, including completed documents. This action is
irreversible and will cancel your subscription, so proceed with caution.
<Trans>
Delete your account and all its contents, including completed documents. This action
is irreversible and will cancel your subscription, so proceed with caution.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog onOpenChange={() => setEnteredEmail('')}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
<Button variant="destructive">
<Trans>Delete Account</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
<DialogTitle>
<Trans>Delete Account</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
{hasTwoFactorAuthentication && (
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
Disable Two Factor Authentication before deleting your account.
<Trans>Disable Two Factor Authentication before deleting your account.</Trans>
</AlertDescription>
</Alert>
)}
<DialogDescription>
Documenso will delete <span className="font-semibold">all of your documents</span>
, along with all of your completed documents, signatures, and all other resources
belonging to your Account.
<Trans>
Documenso will delete{' '}
<span className="font-semibold">all of your documents</span>, along with all of
your completed documents, signatures, and all other resources belonging to your
Account.
</Trans>
</DialogDescription>
</DialogHeader>
{!hasTwoFactorAuthentication && (
<div className="mt-4">
<Label>
Please type{' '}
<span className="text-muted-foreground font-semibold">{user.email}</span> to
confirm.
<Trans>
Please type{' '}
<span className="text-muted-foreground font-semibold">{user.email}</span> to
confirm.
</Trans>
</Label>
<Input
@@ -136,7 +154,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
variant="destructive"
disabled={hasTwoFactorAuthentication || enteredEmail !== user.email}
>
{isDeletingAccount ? 'Deleting account...' : 'Confirm Deletion'}
{isDeletingAccount ? _(msg`Deleting account...`) : _(msg`Confirm Deletion`)}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,5 +1,9 @@
import type { Metadata } from 'next';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
@@ -13,11 +17,17 @@ export const metadata: Metadata = {
};
export default async function ProfileSettingsPage() {
setupI18nSSR();
const { _ } = useLingui();
const { user } = await getRequiredServerComponentSession();
return (
<div>
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<SettingsHeader
title={_(msg`Profile`)}
subtitle={_(msg`Here you can edit your personal details.`)}
/>
<AvatarImageForm className="mb-8 max-w-xl" user={user} />
<ProfileForm className="mb-8 max-w-xl" user={user} />

View File

@@ -1,9 +1,12 @@
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
import { PublicProfilePageView } from './public-profile-page-view';
export default async function Page() {
setupI18nSSR();
const { user } = await getRequiredServerComponentSession();
const { profile } = await getUserPublicProfile({

View File

@@ -2,6 +2,9 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
import type {
Team,
@@ -36,22 +39,21 @@ type DirectTemplate = FindTemplateRow & {
};
const userProfileText = {
settingsTitle: 'Public Profile',
settingsSubtitle: 'You can choose to enable or disable your profile for public view.',
templatesTitle: 'My templates',
templatesSubtitle:
'Show templates in your public profile for your audience to sign and get started quickly',
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: 'Team Public Profile',
settingsSubtitle: 'You can choose to enable or disable your team profile for public view.',
templatesTitle: 'Team templates',
templatesSubtitle:
'Show templates in your team public profile for your audience to sign and get started quickly',
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 const PublicProfilePageView = ({ user, team, profile }: PublicProfilePageViewOptions) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
@@ -104,7 +106,7 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
if (isVisible && !user.url) {
toast({
title: 'You must set a profile URL before enabling your public profile.',
title: _(msg`You must set a profile URL before enabling your public profile.`),
variant: 'destructive',
});
@@ -119,8 +121,8 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
});
} catch {
toast({
title: 'Something went wrong',
description: 'We were unable to set your public profile to public. Please try again.',
title: _(msg`Something went wrong`),
description: _(msg`We were unable to set your public profile to public. Please try again.`),
variant: 'destructive',
});
@@ -134,7 +136,10 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
return (
<div className="max-w-2xl">
<SettingsHeader title={profileText.settingsTitle} subtitle={profileText.settingsSubtitle}>
<SettingsHeader
title={_(profileText.settingsTitle)}
subtitle={_(profileText.settingsSubtitle)}
>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<div
@@ -146,13 +151,17 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
},
)}
>
<span>Hide</span>
<span>
<Trans>Hide</Trans>
</span>
<Switch
disabled={isUpdating}
checked={isPublicProfileVisible}
onCheckedChange={togglePublicProfileVisibility}
/>
<span>Show</span>
<span>
<Trans>Show</Trans>
</span>
</div>
</TooltipTrigger>
@@ -160,18 +169,26 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
{isPublicProfileVisible ? (
<>
<p>
Profile is currently <strong>visible</strong>.
<Trans>
Profile is currently <strong>visible</strong>.
</Trans>
</p>
<p>Toggle the switch to hide your profile from the public.</p>
<p>
<Trans>Toggle the switch to hide your profile from the public.</Trans>
</p>
</>
) : (
<>
<p>
Profile is currently <strong>hidden</strong>.
<Trans>
Profile is currently <strong>hidden</strong>.
</Trans>
</p>
<p>Toggle the switch to show your profile to the public.</p>
<p>
<Trans>Toggle the switch to show your profile to the public.</Trans>
</p>
</>
)}
</TooltipContent>
@@ -187,14 +204,18 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage
<div className="mt-4">
<SettingsHeader
title={profileText.templatesTitle}
subtitle={profileText.templatesSubtitle}
title={_(profileText.templatesTitle)}
subtitle={_(profileText.templatesSubtitle)}
hideDivider={true}
className="mt-8 [&>*>h3]:text-base"
>
<ManagePublicTemplateDialog
directTemplates={enabledPrivateDirectTemplates}
trigger={<Button variant="outline">Link template</Button>}
trigger={
<Button variant="outline">
<Trans>Link template</Trans>
</Button>
}
/>
</SettingsHeader>

View File

@@ -2,6 +2,8 @@
import { useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { EditIcon, FileIcon, LinkIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
@@ -30,6 +32,7 @@ type DirectTemplate = FindTemplateRow & {
export const PublicTemplatesDataTable = () => {
const team = useOptionalCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
@@ -71,8 +74,8 @@ export const PublicTemplatesDataTable = () => {
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The direct link has been copied to your clipboard',
title: _(msg`Copied to clipboard`),
description: _(msg`The direct link has been copied to your clipboard`),
});
});
@@ -105,26 +108,26 @@ export const PublicTemplatesDataTable = () => {
{isLoadingError && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
Unable to load your public profile templates at this time
<Trans>Unable to load your public profile templates at this time</Trans>
<button
onClick={(e) => {
e.preventDefault();
void refetch();
}}
>
Click here to retry
<Trans>Click here to retry</Trans>
</button>
</div>
)}
{!isInitialLoading && (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center text-sm">
No public profile templates found
<Trans>No public profile templates found</Trans>
<ManagePublicTemplateDialog
directTemplates={privateDirectTemplates}
trigger={
<button className="hover:text-muted-foreground/80 mt-1 text-xs">
Click here to get started
<Trans>Click here to get started</Trans>
</button>
}
/>
@@ -157,11 +160,13 @@ export const PublicTemplatesDataTable = () => {
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="center" side="left">
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuLabel>
<Trans>Action</Trans>
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => void onCopyClick(template.directLink.token)}>
<LinkIcon className="mr-2 h-4 w-4" />
Copy sharable link
<Trans>Copy sharable link</Trans>
</DropdownMenuItem>
<DropdownMenuItem
@@ -173,7 +178,7 @@ export const PublicTemplatesDataTable = () => {
}}
>
<EditIcon className="mr-2 h-4 w-4" />
Update
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem
@@ -185,7 +190,7 @@ export const PublicTemplatesDataTable = () => {
}
>
<Trash2Icon className="mr-2 h-4 w-4" />
Remove
<Trans>Remove</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,5 +1,10 @@
import type { Metadata } from 'next';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import ActivityPageBackButton from '../../../../../components/(dashboard)/settings/layout/activity-back';
@@ -10,11 +15,15 @@ export const metadata: Metadata = {
};
export default function SettingsSecurityActivityPage() {
setupI18nSSR();
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title="Security activity"
subtitle="View all recent security activity related to your account."
title={_(msg`Security activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
>
<ActivityPageBackButton />

View File

@@ -2,6 +2,8 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { UAParser } from 'ua-parser-js';
@@ -23,6 +25,8 @@ const dateFormat: DateTimeFormatOptions = {
};
export const UserSecurityActivityDataTable = () => {
const { _ } = useLingui();
const parser = new UAParser();
const pathname = usePathname();
@@ -63,12 +67,12 @@ export const UserSecurityActivityDataTable = () => {
<DataTable
columns={[
{
header: 'Date',
header: _(msg`Date`),
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'Device',
header: _(msg`Device`),
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
@@ -92,7 +96,7 @@ export const UserSecurityActivityDataTable = () => {
},
},
{
header: 'Browser',
header: _(msg`Browser`),
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
@@ -111,7 +115,7 @@ export const UserSecurityActivityDataTable = () => {
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
},
{
header: 'Action',
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
},

View File

@@ -1,6 +1,10 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@@ -17,6 +21,9 @@ export const metadata: Metadata = {
};
export default async function SecuritySettingsPage() {
setupI18nSSR();
const { _ } = useLingui();
const { user } = await getRequiredServerComponentSession();
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
@@ -24,8 +31,8 @@ export default async function SecuritySettingsPage() {
return (
<div>
<SettingsHeader
title="Security"
subtitle="Here you can manage your password and security settings."
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
{user.identityProvider === 'DOCUMENSO' && (
@@ -41,13 +48,22 @@ export default async function SecuritySettingsPage() {
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Two factor authentication</AlertTitle>
<AlertTitle>
<Trans>Two factor authentication</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
Add an authenticator to serve as a secondary authentication method{' '}
{user.identityProvider === 'DOCUMENSO'
? 'when signing in, or when signing documents.'
: 'for signing documents.'}
{user.identityProvider === 'DOCUMENSO' ? (
<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>
@@ -64,11 +80,15 @@ export default async function SecuritySettingsPage() {
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertTitle>
<Trans>Recovery codes</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the event
that you lose access to your authenticator app.
<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>
@@ -82,15 +102,21 @@ export default async function SecuritySettingsPage() {
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Passkeys</AlertTitle>
<AlertTitle>
<Trans>Passkeys</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
Allows authenticating using biometrics, password managers, hardware keys, etc.
<Trans>
Allows authenticating using biometrics, password managers, hardware keys, etc.
</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link href="/settings/security/passkeys">Manage passkeys</Link>
<Link href="/settings/security/passkeys">
<Trans>Manage passkeys</Trans>
</Link>
</Button>
</Alert>
)}
@@ -100,15 +126,19 @@ export default async function SecuritySettingsPage() {
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>Recent activity</AlertTitle>
<AlertTitle>
<Trans>Recent activity</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
View all recent security activity related to your account.
<Trans>View all recent security activity related to your account.</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link href="/settings/security/activity">View activity</Link>
<Link href="/settings/security/activity">
<Trans>View activity</Trans>
</Link>
</Button>
</Alert>
</div>

View File

@@ -3,6 +3,8 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
@@ -53,6 +55,7 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm<TCreatePasskeyFormSchema>({
@@ -81,7 +84,7 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
});
toast({
description: 'Successfully created passkey',
description: _(msg`Successfully created passkey`),
duration: 5000,
});
@@ -140,17 +143,22 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
{trigger ?? (
<Button variant="secondary" loading={isLoading}>
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
Add passkey
<Trans>Add passkey</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Add passkey</DialogTitle>
<DialogTitle>
<Trans>Add passkey</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.
<Trans>
Passkeys allow you to sign in and authenticate using biometrics, password managers,
etc.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -165,7 +173,9 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
name="passkeyName"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey name</FormLabel>
<FormLabel required>
<Trans>Passkey name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder="eg. Mac" {...field} />
</FormControl>
@@ -176,13 +186,17 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
<Alert variant="neutral">
<AlertDescription>
When you click continue, you will be prompted to add the first available
authenticator on your system.
<Trans>
When you click continue, you will be prompted to add the first available
authenticator on your system.
</Trans>
</AlertDescription>
<AlertDescription className="mt-2">
If you do not want to use the authenticator prompted, you can close it, which will
then display the next available authenticator.
<Trans>
If you do not want to use the authenticator prompted, you can close it, which
will then display the next available authenticator.
</Trans>
</AlertDescription>
</Alert>
@@ -190,30 +204,40 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
<Alert variant="destructive">
{match(formError)
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
<AlertDescription>This passkey has already been registered.</AlertDescription>
<AlertDescription>
<Trans>This passkey has already been registered.</Trans>
</AlertDescription>
))
.with('TOO_MANY_PASSKEYS', () => (
<AlertDescription>
You cannot have more than {MAXIMUM_PASSKEYS} passkeys.
<Trans>You cannot have more than {MAXIMUM_PASSKEYS} passkeys.</Trans>
</AlertDescription>
))
.with('InvalidStateError', () => (
<>
<AlertTitle className="text-sm">
Passkey creation cancelled due to one of the following reasons:
<Trans>
Passkey creation cancelled due to one of the following reasons:
</Trans>
</AlertTitle>
<AlertDescription>
<ul className="mt-1 list-inside list-disc">
<li>Cancelled by user</li>
<li>Passkey already exists for the provided authenticator</li>
<li>Exceeded timeout</li>
<li>
<Trans>Cancelled by user</Trans>
</li>
<li>
<Trans>Passkey already exists for the provided authenticator</Trans>
</li>
<li>
<Trans>Exceeded timeout</Trans>
</li>
</ul>
</AlertDescription>
</>
))
.otherwise(() => (
<AlertDescription>
Something went wrong. Please try again or contact support.
<Trans>Something went wrong. Please try again or contact support.</Trans>
</AlertDescription>
))}
</Alert>
@@ -221,11 +245,11 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
Continue
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</fieldset>

View File

@@ -1,6 +1,10 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
@@ -13,6 +17,9 @@ export const metadata: Metadata = {
};
export default async function SettingsManagePasskeysPage() {
setupI18nSSR();
const { _ } = useLingui();
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
if (!isPasskeyEnabled) {
@@ -21,7 +28,11 @@ export default async function SettingsManagePasskeysPage() {
return (
<div>
<SettingsHeader title="Passkeys" subtitle="Manage your passkeys." hideDivider={true}>
<SettingsHeader
title={_(msg`Passkeys`)}
subtitle={_(msg`Manage your passkeys.`)}
hideDivider={true}
>
<CreatePasskeyDialog />
</SettingsHeader>

View File

@@ -1,6 +1,8 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -45,6 +47,7 @@ export const UserPasskeysDataTableActions = ({
passkeyId,
passkeyName,
}: UserPasskeysDataTableActionsProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -61,15 +64,16 @@ export const UserPasskeysDataTableActions = ({
trpc.auth.updatePasskey.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Passkey has been updated',
title: _(msg`Success`),
description: _(msg`Passkey has been updated`),
});
},
onError: () => {
toast({
title: 'Something went wrong',
description:
'We are unable to update this passkey at the moment. Please try again later.',
title: _(msg`Something went wrong`),
description: _(
msg`We are unable to update this passkey at the moment. Please try again later.`,
),
duration: 10000,
variant: 'destructive',
});
@@ -80,15 +84,16 @@ export const UserPasskeysDataTableActions = ({
trpc.auth.deletePasskey.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Passkey has been removed',
title: _(msg`Success`),
description: _(msg`Passkey has been removed`),
});
},
onError: () => {
toast({
title: 'Something went wrong',
description:
'We are unable to remove this passkey at the moment. Please try again later.',
title: _(msg`Something went wrong`),
description: _(
msg`We are unable to remove this passkey at the moment. Please try again later.`,
),
duration: 10000,
variant: 'destructive',
});
@@ -102,15 +107,21 @@ export const UserPasskeysDataTableActions = ({
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="outline">Edit</Button>
<Button variant="outline">
<Trans>Edit</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Update passkey</DialogTitle>
<DialogTitle>
<Trans>Update passkey</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
You are currently updating the <strong>{passkeyName}</strong> passkey.
<Trans>
You are currently updating the <strong>{passkeyName}</strong> passkey.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -129,7 +140,9 @@ export const UserPasskeysDataTableActions = ({
name="name"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>Name</FormLabel>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -141,12 +154,12 @@ export const UserPasskeysDataTableActions = ({
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={isUpdatingPasskey}>
Update
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -160,15 +173,21 @@ export const UserPasskeysDataTableActions = ({
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
<Button variant="destructive">Delete</Button>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Delete passkey</DialogTitle>
<DialogTitle>
<Trans>Delete passkey</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
<Trans>
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -176,7 +195,7 @@ export const UserPasskeysDataTableActions = ({
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
<Trans>Cancel</Trans>
</Button>
</DialogClose>
@@ -189,7 +208,7 @@ export const UserPasskeysDataTableActions = ({
variant="destructive"
loading={isDeletingPasskey}
>
Delete
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>

View File

@@ -2,6 +2,8 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@@ -15,6 +17,8 @@ import { TableCell } from '@documenso/ui/primitives/table';
import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
export const UserPasskeysDataTable = () => {
const { _ } = useLingui();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
@@ -52,22 +56,22 @@ export const UserPasskeysDataTable = () => {
<DataTable
columns={[
{
header: 'Name',
header: _(msg`Name`),
accessorKey: 'name',
},
{
header: 'Created',
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
},
{
header: 'Last used',
header: _(msg`Last used`),
accessorKey: 'updatedAt',
cell: ({ row }) =>
row.original.lastUsedAt
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
: 'Never',
: msg`Never`,
},
{
id: 'actions',

View File

@@ -1,5 +1,8 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -9,6 +12,7 @@ export type AcceptTeamInvitationButtonProps = {
};
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const {
@@ -18,17 +22,17 @@ export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButto
} = trpc.team.acceptTeamInvitation.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Accepted team invitation',
title: _(msg`Success`),
description: _(msg`Accepted team invitation`),
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
title: _(msg`Something went wrong`),
description: _(msg`Unable to join this team at this time.`),
variant: 'destructive',
duration: 10000,
description: 'Unable to join this team at this time.',
});
},
});
@@ -39,7 +43,7 @@ export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButto
loading={isLoading}
disabled={isLoading || isSuccess}
>
Accept
<Trans>Accept</Trans>
</Button>
);
};

View File

@@ -1,5 +1,8 @@
'use client';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -9,6 +12,7 @@ export type DeclineTeamInvitationButtonProps = {
};
export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const {
@@ -18,17 +22,17 @@ export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationBut
} = trpc.team.declineTeamInvitation.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Declined team invitation',
title: _(msg`Success`),
description: _(msg`Declined team invitation`),
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
title: _(msg`Something went wrong`),
description: _(msg`Unable to decline this team invitation at this time.`),
variant: 'destructive',
duration: 10000,
description: 'Unable to decline this team invitation at this time.',
});
},
});
@@ -40,7 +44,7 @@ export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationBut
disabled={isLoading || isSuccess}
variant="ghost"
>
Decline
<Trans>Decline</Trans>
</Button>
);
};

View File

@@ -1,5 +1,7 @@
'use client';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence } from 'framer-motion';
import { trpc } from '@documenso/trpc/react';
@@ -13,11 +15,16 @@ 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="Teams" subtitle="Manage all teams you are currently associated with.">
<SettingsHeader
title={_(msg`Teams`)}
subtitle={_(msg`Manage all teams you are currently associated with.`)}
>
<CreateTeamDialog />
</SettingsHeader>

View File

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

View File

@@ -1,5 +1,6 @@
'use client';
import { Plural, Trans } from '@lingui/macro';
import { AnimatePresence } from 'framer-motion';
import { BellIcon } from 'lucide-react';
@@ -33,23 +34,48 @@ export const TeamInvitations = () => {
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
<AlertDescription className="mr-2">
You have <strong>{data.length}</strong> pending team invitation
{data.length > 1 ? 's' : ''}.
<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">
View invites
<Trans>View invites</Trans>
</button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Pending invitations</DialogTitle>
<DialogTitle>
<Trans>Pending invitations</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
<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>

View File

@@ -1,5 +1,7 @@
import { Trans } from '@lingui/macro';
import { DateTime } from 'luxon';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
import { Button } from '@documenso/ui/primitives/button';
@@ -9,25 +11,31 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { ApiTokenForm } from '~/components/forms/token';
export default async function ApiTokensPage() {
setupI18nSSR();
const { user } = await getRequiredServerComponentSession();
const tokens = await getUserTokens({ userId: user.id });
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<h3 className="text-2xl font-semibold">
<Trans>API Tokens</Trans>
</h3>
<p className="text-muted-foreground mt-2 text-sm">
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>
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" />
@@ -36,12 +44,14 @@ export default async function ApiTokensPage() {
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">Your existing tokens</h4>
<h4 className="text-xl font-medium">
<Trans>Your existing tokens</Trans>
</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
Your tokens will be shown here once you create them.
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
@@ -55,22 +65,30 @@ export default async function ApiTokensPage() {
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
<Trans>
Created on{' '}
<LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
</Trans>
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
<Trans>
Expires on{' '}
<LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
</Trans>
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Token doesn't have an expiration date
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<DeleteTokenDialog token={token}>
<Button variant="destructive">Delete</Button>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</DeleteTokenDialog>
</div>
</div>

View File

@@ -3,6 +3,8 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@@ -38,6 +40,7 @@ export type WebhookPageOptions = {
};
export default function WebhookPage({ params }: WebhookPageOptions) {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
@@ -68,16 +71,18 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
});
toast({
title: 'Webhook updated',
description: 'The webhook has been updated successfully.',
title: _(msg`Webhook updated`),
description: _(msg`The webhook has been updated successfully.`),
duration: 5000,
});
router.refresh();
} catch (err) {
toast({
title: 'Failed to update webhook',
description: 'We encountered an error while updating the webhook. Please try again later.',
title: _(msg`Failed to update webhook`),
description: _(
msg`We encountered an error while updating the webhook. Please try again later.`,
),
variant: 'destructive',
});
}
@@ -86,8 +91,8 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
return (
<div>
<SettingsHeader
title="Edit webhook"
subtitle="On this page, you can edit the webhook and its settings."
title={_(msg`Edit webhook`)}
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
/>
{isLoading && (
@@ -108,13 +113,15 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormLabel required>
<Trans>Webhook URL</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
The URL for Documenso to send webhook events to.
<Trans>The URL for Documenso to send webhook events to.</Trans>
</FormDescription>
<FormMessage />
@@ -127,7 +134,9 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>Enabled</FormLabel>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<div>
<FormControl>
@@ -150,7 +159,9 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>Triggers</FormLabel>
<FormLabel required>
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
listValues={value}
@@ -161,7 +172,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
</FormControl>
<FormDescription>
The events that will trigger a webhook to be sent to your URL.
<Trans> The events that will trigger a webhook to be sent to your URL.</Trans>
</FormDescription>
<FormMessage />
@@ -174,14 +185,18 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormLabel>
<Trans>Secret</Trans>
</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
<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>
@@ -190,7 +205,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) {
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update webhook
<Trans>Update webhook</Trans>
</Button>
</div>
</fieldset>

View File

@@ -2,6 +2,8 @@
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
@@ -17,13 +19,15 @@ import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/
import { LocaleDate } from '~/components/formatter/locale-date';
export default function WebhookPage() {
const { _ } = useLingui();
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
return (
<div>
<SettingsHeader
title="Webhooks"
subtitle="On this page, you can create new Webhooks and manage the existing ones."
title={_(msg`Webhooks`)}
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
>
<CreateWebhookDialog />
</SettingsHeader>
@@ -38,7 +42,9 @@ export default function WebhookPage() {
// 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">
You have no webhooks yet. Your webhooks will be shown here once you create them.
<Trans>
You have no webhooks yet. Your webhooks will be shown here once you create them.
</Trans>
</p>
</div>
)}
@@ -66,29 +72,37 @@ export default function WebhookPage() {
</h5>
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
{webhook.enabled ? 'Enabled' : 'Disabled'}
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
<Trans>
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</Trans>
</p>
<p className="text-muted-foreground mt-2 text-xs">
Created on{' '}
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
<Trans>
Created on{' '}
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
</Trans>
</p>
</div>
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
<Button asChild variant="outline">
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
<Link href={`/settings/webhooks/${webhook.id}`}>
<Trans>Edit</Trans>
</Link>
</Button>
<DeleteWebhookDialog webhook={webhook}>
<Button variant="destructive">Delete</Button>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</DeleteWebhookDialog>
</div>
</div>