Compare commits

..

1 Commits

Author SHA1 Message Date
ephraimduncan 7d6947640a feat: redesign envelope editor step navigation and unify brand colors 2026-07-08 02:16:30 +00:00
10 changed files with 499 additions and 504 deletions
@@ -1,241 +0,0 @@
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<typeof ZCreateTokenFormSchema>;
export type TokenCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TokenCreateDialog = ({ trigger, ...props }: TokenCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<string | null>(null);
const form = useForm<TCreateTokenFormSchema>({
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 (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="flex-shrink-0">
<Trans>Create token</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-lg" position="center">
{createdToken ? (
<>
<DialogHeader>
<DialogTitle>
<Trans>Token created</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Copy your token now. For security reasons you will not be able to see it again.</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Input
className="pr-12 font-mono text-sm"
aria-label={_(msg`Your new API token`)}
name="createdToken"
readOnly
value={createdToken}
/>
<div className="absolute top-0 right-2 bottom-0 flex items-center justify-center">
<CopyTextButton
value={createdToken}
onCopySuccess={() => toast({ title: _(msg`Token copied to clipboard`) })}
/>
</div>
</div>
<DialogFooter>
<Button type="button" onClick={() => setOpen(false)}>
<Trans>Done</Trans>
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>
<Trans>Create API token</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Use API tokens to authenticate with the Documenso API.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
<Trans>A name to help you identify this token later.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Expires in</Trans>
</FormLabel>
<FormControl>
<Select value={field.value ?? NEVER_EXPIRE} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{_(date)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete token</Trans>
<Trans>Are you sure you want to delete this token?</Trans>
</DialogTitle>
<DialogDescription>
@@ -139,18 +139,21 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setIsOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" className="flex-1" onClick={() => setIsOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>Delete</Trans>
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>I'm sure! Delete it</Trans>
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
+254
View File
@@ -0,0 +1,254 @@
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<typeof ZCreateTokenFormSchema>;
type NewlyCreatedToken = {
id: number;
token: string;
};
export type ApiTokenFormProps = {
className?: string;
tokens?: Pick<ApiToken, 'id'>[];
};
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [, copy] = useCopyToClipboard();
const team = useCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
const [noExpirationDate, setNoExpirationDate] = useState(false);
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
onSuccess(data) {
setNewlyCreatedToken(data);
},
});
const form = useForm<TCreateTokenFormSchema>({
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 (
<div className={cn(className)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">
<Trans>Token name</Trans>
</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Input type="text" {...field} />
</FormControl>
</div>
<FormDescription className="text-xs italic">
<Trans>Please enter a meaningful name for your token. This will help you identify it later.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">
<Trans>Token expiration date</Trans>
</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
<SelectTrigger className="w-full">
<SelectValue placeholder={_(msg`Choose...`)} />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{_(date)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel className="mt-2 text-muted-foreground">
<Trans>Never expire</Trans>
</FormLabel>
<div className="block md:py-1.5">
<Switch
className="mt-2 bg-background"
checked={noExpirationDate}
onCheckedChange={setNoExpirationDate}
/>
</div>
</div>
</div>
<Button type="submit" className="hidden md:inline-flex" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
<div className="md:hidden">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
<AnimatePresence>
{newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: -40 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 40 }}
>
<Card gradient>
<CardContent className="p-4">
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Your token was created successfully! Make sure to copy it because you won't be able to see it again!
</Trans>
</p>
<p className="my-4 rounded-md bg-muted-foreground/10 px-2.5 py-1 font-mono text-sm">
{newlyCreatedToken.token}
</p>
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken.token)}>
<Trans>Copy token</Trans>
</Button>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -216,7 +216,7 @@ export const EnvelopeEditorFieldsPage = () => {
<div className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
<div
className={cn('h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0', {
'bg-green-500': currentEnvelopeItem?.id === item.id,
'bg-primary': currentEnvelopeItem?.id === item.id,
})}
/>
<EnvelopeItemEditDialog
@@ -9,9 +9,10 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { motion } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import {
ArrowLeftIcon,
CheckIcon,
CopyPlusIcon,
DownloadCloudIcon,
EyeIcon,
@@ -229,101 +230,125 @@ export const EnvelopeEditor = () => {
</div>
) : (
<div className="px-4">
<h3 className="flex items-end justify-between font-semibold text-foreground text-sm">
<h3 className="flex items-baseline justify-between font-semibold text-foreground text-sm tracking-tight">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-muted-foreground text-xs">
<span className="text-muted-foreground text-xs tabular-nums">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
</div>
</div>
)}
<div
className={cn('space-y-3', {
'px-4': !minimizeLeftSidebar,
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
<nav
className={cn('flex flex-col', {
'mt-5 px-4': !minimizeLeftSidebar,
'mt-4 items-center': minimizeLeftSidebar,
})}
>
{envelopeEditorSteps.map((step) => {
{envelopeEditorSteps.map((step, index) => {
const Icon = step.icon;
const isActive = searchParamsStep === step.id;
const isCompleted = (currentStepData.order ?? 0) > step.order;
const isLast = index === envelopeEditorSteps.length - 1;
return (
<button
key={step.id}
data-testid={`envelope-editor-step-${step.id}`}
type="button"
className={cn(
`cursor-pointer rounded-lg text-left transition-colors ${
isActive
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`,
{
'p-3': !minimizeLeftSidebar,
},
)}
title={minimizeLeftSidebar ? t(step.title) : undefined}
aria-current={isActive ? 'step' : undefined}
className="group flex cursor-pointer rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div className="flex flex-col items-center">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
className={cn(
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border transition-[color,border-color,background-color,box-shadow] duration-150',
{
'border-primary bg-primary text-primary-foreground': isCompleted,
'border-primary bg-primary/10 text-documenso-700 ring-4 ring-primary/10 dark:text-primary':
isActive,
'border-border bg-background text-muted-foreground group-hover:border-muted-foreground/40 group-hover:text-foreground':
!isActive && !isCompleted,
},
)}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`} />
{isCompleted ? (
<motion.span
className="flex"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<CheckIcon className="h-3.5 w-3.5" />
</motion.span>
) : (
<Icon className="h-3.5 w-3.5" />
)}
</div>
{!minimizeLeftSidebar && (
<div>
<div
className={`font-medium text-sm ${
isActive
? 'text-green-900 dark:text-green-400'
: 'text-foreground dark:text-muted-foreground'
}`}
>
{t(step.title)}
</div>
<div className="text-muted-foreground text-xs">{t(step.description)}</div>
{!isLast && (
<div className="relative my-1 min-h-4 w-px flex-1 overflow-hidden bg-border">
<motion.div
className="absolute inset-x-0 top-0 bg-primary"
initial={false}
animate={{ height: isCompleted ? '100%' : '0%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</div>
)}
</div>
{!minimizeLeftSidebar && (
<div className={cn('ml-3 min-w-0 pt-1', !isLast && 'pb-5')}>
<div
className={cn('font-medium text-sm transition-colors duration-150', {
'text-documenso-800 dark:text-primary': isActive,
'text-foreground': isCompleted,
'text-muted-foreground group-hover:text-foreground': !isActive && !isCompleted,
})}
>
{t(step.title)}
</div>
<AnimatePresence initial={false}>
{isActive && (
<motion.div
className="overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
<div className="mt-0.5 text-muted-foreground text-xs">{t(step.description)}</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</button>
);
})}
</div>
</nav>
<Separator
className={cn('my-6', {
className={cn('my-5', {
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
})}
/>
{/* Quick Actions. */}
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
className={cn('space-y-1 px-4 [&_.lucide]:text-muted-foreground', {
'px-2': minimizeLeftSidebar,
})}
>
{!minimizeLeftSidebar && (
<h4 className="font-semibold text-foreground text-sm">
<h4 className="mb-2 px-3 font-medium text-muted-foreground text-xs uppercase tracking-wider">
<Trans>Quick Actions</Trans>
</h4>
)}
@@ -467,7 +492,7 @@ export const EnvelopeEditor = () => {
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
className="w-full justify-start hover:bg-destructive/10 hover:text-destructive hover:[&_.lucide]:text-destructive"
title={t(msg`Delete Envelope`)}
>
<Trash2Icon className="h-4 w-4" />
@@ -493,13 +518,14 @@ export const EnvelopeEditor = () => {
{/* Footer of left sidebar. */}
{!editorConfig.embedded && (
<div
className={cn('mt-auto px-4', {
className={cn('mt-auto border-border border-t px-4 pt-3', {
'px-2': minimizeLeftSidebar,
})}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
size="sm"
className={cn('w-full justify-start text-muted-foreground hover:text-foreground', {
'flex items-center justify-center': minimizeLeftSidebar,
})}
asChild
@@ -24,26 +24,26 @@ export const EnvelopeItemSelector = ({
title={typeof primaryText === 'string' ? primaryText : undefined}
className={`group flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
isSelected
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
? 'border-primary/40 bg-primary/10 text-documenso-800 dark:text-primary'
: 'border-border bg-muted/50 hover:bg-muted/70'
}`}
{...buttonProps}
>
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full font-medium text-xs ${
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
isSelected ? 'bg-primary/20 text-documenso-700 dark:text-primary' : 'bg-muted text-muted-foreground'
}`}
>
{number}
</div>
<div className="min-w-0 text-left">
<div className="truncate font-medium text-sm">{primaryText}</div>
<div className="text-gray-500 text-xs">{secondaryText}</div>
<div className="text-muted-foreground text-xs">{secondaryText}</div>
</div>
{actionSlot ?? (
<div
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
'bg-green-500': isSelected,
'bg-primary': isSelected,
})}
/>
)}
@@ -1,18 +1,14 @@
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 { Trans, useLingui } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { useMemo } from 'react';
import { DateTime } from 'luxon';
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';
@@ -22,88 +18,33 @@ export function meta() {
}
export default function ApiTokensPage() {
const { t, i18n } = useLingui();
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getMany.useQuery();
const team = useOptionalCurrentTeam();
const isUnauthorized = !!team && team.currentTeamRole !== TeamMemberRole.ADMIN;
const {
data: tokens,
isLoading,
isError,
} = trpc.apiToken.getMany.useQuery(undefined, {
enabled: !isUnauthorized,
});
const columns = useMemo(() => {
return [
{
header: t`Name`,
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.name}</span>,
},
{
header: t`Created`,
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: t`Expires`,
cell: ({ row }) => {
if (!row.original.expires) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
if (row.original.expires < new Date()) {
return (
<Badge variant="destructive" size="small">
<Trans>Expired</Trans>
</Badge>
);
}
return i18n.date(row.original.expires);
},
},
{
header: t`Actions`,
cell: ({ row }) => (
<TokenDeleteDialog token={row.original}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
),
},
] satisfies DataTableColumnDef<TGetApiTokensResponse[number]>[];
}, []);
return (
<div>
<SettingsHeader
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
Create and manage API tokens. See our{' '}
On this page, you can create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
rel="noopener"
>
documentation
Documentation
</a>{' '}
for more information.
</Trans>
}
>
{!isUnauthorized && <TokenCreateDialog />}
</SettingsHeader>
/>
{isUnauthorized ? (
{team && team?.currentTeamRole !== TeamMemberRole.ADMIN ? (
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="warning">
<div>
<AlertTitle>
@@ -115,43 +56,58 @@ export default function ApiTokensPage() {
</div>
</Alert>
) : (
<DataTable
columns={columns}
data={tokens ?? []}
perPage={0}
currentPage={0}
totalPages={0}
error={{
enable: isError,
}}
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
<Trans>You have no API tokens yet. Your tokens will be shown here once you create them.</Trans>
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="mt-8 mb-4" />
<h4 className="font-medium text-xl">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="mt-2 text-muted-foreground text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
/>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="mt-2 text-muted-foreground text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
+1 -1
View File
@@ -41,7 +41,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fade-in-90 slide-in-from-bottom-10 sm:zoom-in-90 sm:slide-in-from-bottom-0 fixed z-50 grid w-full max-w-lg scale-100 animate-in gap-4 border bg-background p-6 opacity-100 shadow-lg focus:outline-none sm:rounded-lg md:w-full',
'fade-in-90 slide-in-from-bottom-10 sm:zoom-in-90 sm:slide-in-from-bottom-0 fixed z-50 grid w-full max-w-lg scale-100 animate-in gap-4 border bg-background p-6 opacity-100 shadow-lg sm:rounded-lg md:w-full',
className,
)}
{...props}
+1 -1
View File
@@ -61,7 +61,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full animate-in gap-4 border bg-background p-6 shadow-lg focus:outline-none sm:max-w-lg sm:rounded-lg',
'data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full animate-in gap-4 border bg-background p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
{
'rounded-b-xl': position === 'start',
'rounded-t-xl': position === 'end',
+81 -84
View File
@@ -49,93 +49,90 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-[61] scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg focus:outline-none',
{
variants: {
position: {
top: 'animate-in slide-in-from-top w-full duration-300',
bottom: 'animate-in slide-in-from-bottom w-full duration-300',
left: 'animate-in slide-in-from-left h-full duration-300',
right: 'animate-in slide-in-from-right h-full duration-300',
},
size: {
content: '',
default: '',
sm: '',
lg: '',
xl: '',
full: '',
},
const sheetVariants = cva('fixed z-[61] scale-100 gap-4 bg-background p-6 opacity-100 shadow-lg border', {
variants: {
position: {
top: 'animate-in slide-in-from-top w-full duration-300',
bottom: 'animate-in slide-in-from-bottom w-full duration-300',
left: 'animate-in slide-in-from-left h-full duration-300',
right: 'animate-in slide-in-from-right h-full duration-300',
},
compoundVariants: [
{
position: ['top', 'bottom'],
size: 'content',
class: 'max-h-screen',
},
{
position: ['top', 'bottom'],
size: 'default',
class: 'h-1/3',
},
{
position: ['top', 'bottom'],
size: 'sm',
class: 'h-1/4',
},
{
position: ['top', 'bottom'],
size: 'lg',
class: 'h-1/2',
},
{
position: ['top', 'bottom'],
size: 'xl',
class: 'h-5/6',
},
{
position: ['top', 'bottom'],
size: 'full',
class: 'h-screen',
},
{
position: ['right', 'left'],
size: 'content',
class: 'max-w-screen',
},
{
position: ['right', 'left'],
size: 'default',
class: 'w-1/3',
},
{
position: ['right', 'left'],
size: 'sm',
class: 'w-1/4',
},
{
position: ['right', 'left'],
size: 'lg',
class: 'w-1/2',
},
{
position: ['right', 'left'],
size: 'xl',
class: 'w-5/6',
},
{
position: ['right', 'left'],
size: 'full',
class: 'w-screen',
},
],
defaultVariants: {
position: 'right',
size: 'default',
size: {
content: '',
default: '',
sm: '',
lg: '',
xl: '',
full: '',
},
},
);
compoundVariants: [
{
position: ['top', 'bottom'],
size: 'content',
class: 'max-h-screen',
},
{
position: ['top', 'bottom'],
size: 'default',
class: 'h-1/3',
},
{
position: ['top', 'bottom'],
size: 'sm',
class: 'h-1/4',
},
{
position: ['top', 'bottom'],
size: 'lg',
class: 'h-1/2',
},
{
position: ['top', 'bottom'],
size: 'xl',
class: 'h-5/6',
},
{
position: ['top', 'bottom'],
size: 'full',
class: 'h-screen',
},
{
position: ['right', 'left'],
size: 'content',
class: 'max-w-screen',
},
{
position: ['right', 'left'],
size: 'default',
class: 'w-1/3',
},
{
position: ['right', 'left'],
size: 'sm',
class: 'w-1/4',
},
{
position: ['right', 'left'],
size: 'lg',
class: 'w-1/2',
},
{
position: ['right', 'left'],
size: 'xl',
class: 'w-5/6',
},
{
position: ['right', 'left'],
size: 'full',
class: 'w-screen',
},
],
defaultVariants: {
position: 'right',
size: 'default',
},
});
export interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,