mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
feat: allow user to choose expiry date
This commit is contained in:
@ -48,9 +48,15 @@ export default async function ApiTokensPage() {
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
|
||||
</p>
|
||||
{token.expires ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Token doesn't have an expiration date
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
export const EXPIRATION_DATES = {
|
||||
ONE_WEEK: '7 days',
|
||||
ONE_MONTH: '1 month',
|
||||
THREE_MONTHS: '3 months',
|
||||
SIX_MONTHS: '6 months',
|
||||
ONE_YEAR: '12 months',
|
||||
} as const;
|
||||
@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
@ -26,9 +26,21 @@ import {
|
||||
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';
|
||||
|
||||
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema;
|
||||
import { EXPIRATION_DATES } from '../(dashboard)/settings/token/contants';
|
||||
|
||||
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
|
||||
|
||||
@ -43,6 +55,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState('');
|
||||
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
||||
|
||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
|
||||
onSuccess(data) {
|
||||
@ -54,9 +67,21 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
|
||||
resolver: zodResolver(ZCreateTokenFormSchema),
|
||||
defaultValues: {
|
||||
tokenName: '',
|
||||
expirationDate: '',
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (newlyCreatedToken) {
|
||||
const timer = setTimeout(() => {
|
||||
setNewlyCreatedToken('');
|
||||
}, 30000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [newlyCreatedToken]);
|
||||
|
||||
const copyToken = async (token: string) => {
|
||||
try {
|
||||
const copied = await copy(token);
|
||||
@ -78,10 +103,11 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async ({ tokenName }: TCreateTokenMutationSchema) => {
|
||||
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
|
||||
try {
|
||||
await createTokenMutation({
|
||||
tokenName,
|
||||
expirationDate: noExpirationDate ? null : expirationDate,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -116,30 +142,21 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
|
||||
<div className={cn(className)}>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="mt-6 flex w-full flex-col gap-4 md:flex-row ">
|
||||
<fieldset className="mt-6 flex w-full flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="text-muted-foreground">Token Name</FormLabel>
|
||||
<FormLabel className="text-muted-foreground">Token name</FormLabel>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<FormControl className="flex-1">
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="hidden md:inline-flex"
|
||||
disabled={!form.formState.isDirty}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
Create token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<FormDescription className="text-xs italic">
|
||||
Please enter a meaningful name for your token. This will help you identify it
|
||||
later.
|
||||
</FormDescription>
|
||||
@ -149,6 +166,65 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expirationDate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="text-muted-foreground">Token expiration date</FormLabel>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<FormControl className="flex-1">
|
||||
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Choose..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{date}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormLabel className="text-muted-foreground mt-2">Never expire</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={field.value}
|
||||
onCheckedChange={(val) => {
|
||||
setNoExpirationDate((prev) => !prev);
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="hidden md:inline-flex"
|
||||
disabled={!form.formState.isDirty}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
Create token
|
||||
</Button>
|
||||
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
export const ONE_SECOND = 1000;
|
||||
export const ONE_MINUTE = ONE_SECOND * 60;
|
||||
export const ONE_HOUR = ONE_MINUTE * 60;
|
||||
export const ONE_DAY = ONE_HOUR * 24;
|
||||
export const ONE_WEEK = ONE_DAY * 7;
|
||||
export const ONE_MONTH = ONE_DAY * 30;
|
||||
export const ONE_YEAR = ONE_DAY * 365;
|
||||
export const ONE_MONTH = Duration.fromObject({ months: 1 });
|
||||
export const THREE_MONTHS = Duration.fromObject({ months: 3 });
|
||||
export const SIX_MONTHS = Duration.fromObject({ months: 6 });
|
||||
export const ONE_YEAR = Duration.fromObject({ years: 1 });
|
||||
|
||||
@ -1,26 +1,42 @@
|
||||
import type { Duration } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
// temporary choice for testing only
|
||||
import { ONE_YEAR } from '../../constants/time';
|
||||
import * as timeConstants from '../../constants/time';
|
||||
import { alphaid } from '../../universal/id';
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
type TimeConstants = typeof timeConstants & {
|
||||
[key: string]: number | Duration;
|
||||
};
|
||||
|
||||
type CreateApiTokenInput = {
|
||||
userId: number;
|
||||
tokenName: string;
|
||||
expirationDate: string | null;
|
||||
};
|
||||
|
||||
export const createApiToken = async ({ userId, tokenName }: CreateApiTokenInput) => {
|
||||
export const createApiToken = async ({
|
||||
userId,
|
||||
tokenName,
|
||||
expirationDate,
|
||||
}: CreateApiTokenInput) => {
|
||||
const apiToken = `api_${alphaid(16)}`;
|
||||
|
||||
const hashedToken = hashString(apiToken);
|
||||
|
||||
const timeConstantsRecords: TimeConstants = timeConstants;
|
||||
|
||||
const dbToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
token: hashedToken,
|
||||
name: tokenName,
|
||||
userId,
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
expires: expirationDate
|
||||
? DateTime.now().plus(timeConstantsRecords[expirationDate]).toJSDate()
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ApiToken" ALTER COLUMN "expires" DROP NOT NULL;
|
||||
@ -103,7 +103,7 @@ model ApiToken {
|
||||
name String
|
||||
token String @unique
|
||||
algorithm ApiTokenAlgorithm @default(SHA512)
|
||||
expires DateTime
|
||||
expires DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@ -46,10 +46,12 @@ export const apiTokenRouter = router({
|
||||
.input(ZCreateTokenMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { tokenName } = input;
|
||||
const { tokenName, expirationDate } = input;
|
||||
|
||||
return await createApiToken({
|
||||
userId: ctx.user.id,
|
||||
tokenName,
|
||||
expirationDate,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
|
||||
@ -8,6 +8,7 @@ export type TGetApiTokenByIdQuerySchema = z.infer<typeof ZGetApiTokenByIdQuerySc
|
||||
|
||||
export const ZCreateTokenMutationSchema = z.object({
|
||||
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
|
||||
expirationDate: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type TCreateTokenMutationSchema = z.infer<typeof ZCreateTokenMutationSchema>;
|
||||
|
||||
Reference in New Issue
Block a user