From 96539cda09cbaec6575ee49e0945c51dbba83fb5 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Tue, 7 Jul 2026 13:26:43 +0000 Subject: [PATCH] feat: redesign api tokens settings page Aligns the API tokens settings page with the webhooks settings pattern. Token creation moves from an inline form into a dialog triggered from the settings header, which reveals the newly created token in-dialog with a copy button. The "never expire" switch is folded into the expiration select as a "Never" option, and the token list is now a data table with created and expiry dates, an expired badge, and skeleton, empty, and error states. The delete confirmation dialog footer and copy now match the other confirm dialogs. --- .../dialogs/token-create-dialog.tsx | 241 +++++++++++++++++ .../dialogs/token-delete-dialog.tsx | 27 +- apps/remix/app/components/forms/token.tsx | 254 ------------------ .../t.$teamUrl+/settings.tokens.tsx | 166 +++++++----- 4 files changed, 358 insertions(+), 330 deletions(-) create mode 100644 apps/remix/app/components/dialogs/token-create-dialog.tsx delete mode 100644 apps/remix/app/components/forms/token.tsx diff --git a/apps/remix/app/components/dialogs/token-create-dialog.tsx b/apps/remix/app/components/dialogs/token-create-dialog.tsx new file mode 100644 index 000000000..1a002ffca --- /dev/null +++ b/apps/remix/app/components/dialogs/token-create-dialog.tsx @@ -0,0 +1,241 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types'; +import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import type { z } from 'zod'; + +import { useCurrentTeam } from '~/providers/team'; + +const NEVER_EXPIRE = 'NEVER' as const; + +export const EXPIRATION_DATES = { + ONE_WEEK: msg`7 days`, + ONE_MONTH: msg`1 month`, + THREE_MONTHS: msg`3 months`, + SIX_MONTHS: msg`6 months`, + ONE_YEAR: msg`12 months`, + [NEVER_EXPIRE]: msg`Never`, +} as const; + +const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({ + tokenName: true, + expirationDate: true, +}); + +type TCreateTokenFormSchema = z.infer; + +export type TokenCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +export const TokenCreateDialog = ({ trigger, ...props }: TokenCreateDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const [open, setOpen] = useState(false); + const [createdToken, setCreatedToken] = useState(null); + + const form = useForm({ + resolver: zodResolver(ZCreateTokenFormSchema), + defaultValues: { + tokenName: '', + expirationDate: 'THREE_MONTHS', + }, + }); + + const { mutateAsync: createToken } = trpc.apiToken.create.useMutation(); + + const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => { + try { + const { token } = await createToken({ + teamId: team.id, + tokenName, + expirationDate: expirationDate === NEVER_EXPIRE ? null : expirationDate, + }); + + setCreatedToken(token); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`) + .otherwise(() => msg`Something went wrong. Please try again later.`); + + toast({ + title: _(msg`An error occurred`), + description: _(errorMessage), + variant: 'destructive', + duration: 5000, + }); + } + }; + + useEffect(() => { + if (open) { + form.reset(); + setCreatedToken(null); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} {...props}> + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + {createdToken ? ( + <> + + + Token created + + + + Copy your token now. For security reasons you will not be able to see it again. + + + +
+ +
+ toast({ title: _(msg`Token copied to clipboard`) })} + /> +
+
+ + + + + + ) : ( + <> + + + Create API token + + + + Use API tokens to authenticate with the Documenso API. + + + +
+ +
+ ( + + + Name + + + + + + + + A name to help you identify this token later. + + + + + )} + /> + + ( + + + Expires in + + + + + + + + + )} + /> + + + + + + +
+
+ + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index 63f1465b4..4dc5f9925 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe - Are you sure you want to delete this token? + Delete token @@ -139,21 +139,18 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe /> -
- + - -
+
diff --git a/apps/remix/app/components/forms/token.tsx b/apps/remix/app/components/forms/token.tsx deleted file mode 100644 index 4239b0c76..000000000 --- a/apps/remix/app/components/forms/token.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { trpc } from '@documenso/trpc/react'; -import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; -import { Switch } from '@documenso/ui/primitives/switch'; -import { useToast } from '@documenso/ui/primitives/use-toast'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type { ApiToken } from '@prisma/client'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { match } from 'ts-pattern'; -import type { z } from 'zod'; - -import { useCurrentTeam } from '~/providers/team'; - -export const EXPIRATION_DATES = { - ONE_WEEK: msg`7 days`, - ONE_MONTH: msg`1 month`, - THREE_MONTHS: msg`3 months`, - SIX_MONTHS: msg`6 months`, - ONE_YEAR: msg`12 months`, -} as const; - -const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({ - tokenName: true, - expirationDate: true, -}); - -type TCreateTokenFormSchema = z.infer; - -type NewlyCreatedToken = { - id: number; - token: string; -}; - -export type ApiTokenFormProps = { - className?: string; - tokens?: Pick[]; -}; - -export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => { - const [, copy] = useCopyToClipboard(); - - const team = useCurrentTeam(); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const [newlyCreatedToken, setNewlyCreatedToken] = useState(); - const [noExpirationDate, setNoExpirationDate] = useState(false); - - const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({ - onSuccess(data) { - setNewlyCreatedToken(data); - }, - }); - - const form = useForm({ - resolver: zodResolver(ZCreateTokenFormSchema), - defaultValues: { - tokenName: '', - expirationDate: '', - }, - }); - - const copyToken = async (token: string) => { - try { - const copied = await copy(token); - - if (!copied) { - throw new Error('Unable to copy the token'); - } - - toast({ - title: _(msg`Token copied to clipboard`), - description: _(msg`The token was copied to your clipboard.`), - }); - } catch (error) { - toast({ - title: _(msg`Unable to copy token`), - description: _(msg`We were unable to copy the token to your clipboard. Please try again.`), - variant: 'destructive', - }); - } - }; - - const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => { - try { - await createTokenMutation({ - teamId: team.id, - tokenName, - expirationDate: noExpirationDate ? null : expirationDate, - }); - - toast({ - title: _(msg`Token created`), - description: _(msg`A new token was created successfully.`), - duration: 5000, - }); - - form.reset(); - } catch (err) { - const error = AppError.parseError(err); - - const errorMessage = match(error.code) - .with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`) - .otherwise(() => msg`Something went wrong. Please try again later.`); - - toast({ - title: _(msg`An error occurred`), - description: _(errorMessage), - variant: 'destructive', - duration: 5000, - }); - } - }; - - return ( -
-
- -
- ( - - - Token name - - -
- - - -
- - - Please enter a meaningful name for your token. This will help you identify it later. - - - -
- )} - /> - -
- ( - - - Token expiration date - - -
- - - -
- - -
- )} - /> - -
- - Never expire - -
- -
-
-
- - - -
- -
-
-
- - - - {newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && ( - - - -

- - Your token was created successfully! Make sure to copy it because you won't be able to see it again! - -

- -

- {newlyCreatedToken.token} -

- - -
-
-
- )} -
-
- ); -}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx index 40ac2aac5..f3f5bb897 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx @@ -1,14 +1,18 @@ import { trpc } from '@documenso/trpc/react'; +import type { TGetApiTokensResponse } from '@documenso/trpc/server/api-token-router/get-api-tokens.types'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Badge } from '@documenso/ui/primitives/badge'; import { Button } from '@documenso/ui/primitives/button'; +import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; +import { Trans, useLingui } from '@lingui/react/macro'; import { TeamMemberRole } from '@prisma/client'; -import { DateTime } from 'luxon'; +import { useMemo } from 'react'; +import { TokenCreateDialog } from '~/components/dialogs/token-create-dialog'; import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog'; -import { ApiTokenForm } from '~/components/forms/token'; import { SettingsHeader } from '~/components/general/settings-header'; import { useOptionalCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -18,33 +22,88 @@ export function meta() { } export default function ApiTokensPage() { - const { i18n } = useLingui(); - - const { data: tokens } = trpc.apiToken.getMany.useQuery(); + const { t, i18n } = useLingui(); const team = useOptionalCurrentTeam(); + const isUnauthorized = !!team && team.currentTeamRole !== TeamMemberRole.ADMIN; + + const { + data: tokens, + isLoading, + isError, + } = trpc.apiToken.getMany.useQuery(undefined, { + enabled: !isUnauthorized, + }); + + const columns = useMemo(() => { + return [ + { + header: t`Name`, + cell: ({ row }) => {row.original.name}, + }, + { + header: t`Created`, + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + { + header: t`Expires`, + cell: ({ row }) => { + if (!row.original.expires) { + return ( + + Never + + ); + } + + if (row.original.expires < new Date()) { + return ( + + Expired + + ); + } + + return i18n.date(row.original.expires); + }, + }, + { + header: t`Actions`, + cell: ({ row }) => ( + + + + ), + }, + ] satisfies DataTableColumnDef[]; + }, []); + return (
API Tokens} subtitle={ - On this page, you can create and manage API tokens. See our{' '} + Create and manage API tokens. See our{' '} - Documentation + documentation {' '} for more information. } - /> + > + {!isUnauthorized && } + - {team && team?.currentTeamRole !== TeamMemberRole.ADMIN ? ( + {isUnauthorized ? (
@@ -56,58 +115,43 @@ export default function ApiTokensPage() {
) : ( - <> - - -
- -

- Your existing tokens -

- - {tokens && tokens.length === 0 && ( -
-

- Your tokens will be shown here once you create them. + +

+ You have no API tokens yet. Your tokens will be shown here once you create them.

- )} - - {tokens && tokens.length > 0 && ( -
- {tokens.map((token) => ( -
-
-
-
{token.name}
- -

- Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)} -

- {token.expires ? ( -

- Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)} -

- ) : ( -

- Token doesn't have an expiration date -

- )} -
- -
- - - -
-
-
- ))} -
- )} - + } + skeleton={{ + enable: isLoading, + rows: 3, + component: ( + <> + + + + + + + + + + + + + + ), + }} + /> )}
);