♻️ Refactor API token management to use tRPC (#2305)

This commit is contained in:
Baptiste Arnaud 2025-10-31 10:13:25 +01:00 committed by GitHub
parent 842f8ef0bb
commit 41c91f98ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 181 additions and 149 deletions

View File

@ -1,11 +1,14 @@
import { T } from "@tolgee/react";
type Props = {
date: string;
date: string | Date;
};
export const TimeSince = ({ date }: Props) => {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
const seconds = Math.floor(
(Date.now() - (date instanceof Date ? date : new Date(date)).getTime()) /
1000,
);
let interval = seconds / 31536000;

View File

@ -1,3 +1,4 @@
import { useMutation } from "@tanstack/react-query";
import { T, useTranslate } from "@tolgee/react";
import { byId, isDefined } from "@typebot.io/lib/utils";
import { Button } from "@typebot.io/ui/components/Button";
@ -5,22 +6,17 @@ import { Checkbox } from "@typebot.io/ui/components/Checkbox";
import { Skeleton } from "@typebot.io/ui/components/Skeleton";
import { Table } from "@typebot.io/ui/components/Table";
import { useOpenControls } from "@typebot.io/ui/hooks/useOpenControls";
import type { ClientUser } from "@typebot.io/user/schemas";
import { useState } from "react";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { TimeSince } from "@/components/TimeSince";
import { trpc } from "@/lib/queryClient";
import { toast } from "@/lib/toast";
import { useApiTokens } from "../hooks/useApiTokens";
import { deleteApiTokenQuery } from "../queries/deleteApiTokenQuery";
import type { ApiTokenFromServer } from "../types";
import { CreateApiTokenDialog } from "./CreateApiTokenDialog";
type Props = { user: ClientUser };
export const ApiTokensList = ({ user }: Props) => {
export const ApiTokensList = () => {
const { t } = useTranslate();
const { apiTokens, isLoading, mutate } = useApiTokens({
userId: user.id,
const { apiTokens, isLoading, refetch } = useApiTokens({
onError: (e) =>
toast({
title: "Failed to fetch tokens",
@ -34,16 +30,17 @@ export const ApiTokensList = ({ user }: Props) => {
} = useOpenControls();
const [deletingId, setDeletingId] = useState<string>();
const refreshListWithNewToken = (token: ApiTokenFromServer) => {
if (!apiTokens) return;
mutate({ apiTokens: [token, ...apiTokens] });
};
const { mutate: deleteToken } = useMutation(
trpc.user.deleteApiToken.mutationOptions({
onSuccess: () => {
refetch();
setDeletingId(undefined);
},
}),
);
const deleteToken = async (tokenId?: string) => {
if (!apiTokens || !tokenId) return;
const { error } = await deleteApiTokenQuery({ userId: user.id, tokenId });
if (!error)
mutate({ apiTokens: apiTokens.filter((t) => t.id !== tokenId) });
const handleTokenCreated = () => {
refetch();
};
return (
@ -55,9 +52,8 @@ export const ApiTokensList = ({ user }: Props) => {
{t("account.apiTokens.createButton.label")}
</Button>
<CreateApiTokenDialog
userId={user.id}
isOpen={isCreateOpen}
onNewToken={refreshListWithNewToken}
onNewToken={handleTokenCreated}
onClose={onCreateClose}
/>
</div>
@ -107,7 +103,7 @@ export const ApiTokensList = ({ user }: Props) => {
</Table.Root>
<ConfirmDialog
isOpen={isDefined(deletingId)}
onConfirm={() => deleteToken(deletingId)}
onConfirm={() => deletingId && deleteToken({ tokenId: deletingId })}
onClose={() => setDeletingId(undefined)}
actionType="destructive"
confirmButtonLabel={t("account.apiTokens.deleteButton.label")}

View File

@ -1,3 +1,4 @@
import { useMutation } from "@tanstack/react-query";
import { useTranslate } from "@tolgee/react";
import { Button } from "@typebot.io/ui/components/Button";
import { Dialog } from "@typebot.io/ui/components/Dialog";
@ -5,20 +6,17 @@ import { Input } from "@typebot.io/ui/components/Input";
import type { FormEvent } from "react";
import { useRef, useState } from "react";
import { CopyInput } from "@/components/inputs/CopyInput";
import { createApiTokenQuery } from "../queries/createApiTokenQuery";
import type { ApiTokenFromServer } from "../types";
import { trpc } from "@/lib/queryClient";
type Props = {
userId: string;
isOpen: boolean;
onNewToken: (token: ApiTokenFromServer) => void;
onNewToken: () => void;
onClose: () => void;
};
const ANIMATION_DURATION = 150;
export const CreateApiTokenDialog = ({
userId,
isOpen,
onClose,
onNewToken,
@ -26,18 +24,20 @@ export const CreateApiTokenDialog = ({
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslate();
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [newTokenValue, setNewTokenValue] = useState<string>();
const createToken = async (e: FormEvent) => {
const { mutate: createToken, isPending: isSubmitting } = useMutation(
trpc.user.createApiToken.mutationOptions({
onSuccess: (data) => {
setNewTokenValue(data.apiToken.token);
onNewToken();
},
}),
);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const { data } = await createApiTokenQuery(userId, { name });
if (data?.apiToken) {
setNewTokenValue(data.apiToken.token);
onNewToken(data.apiToken);
}
setIsSubmitting(false);
createToken({ name });
};
const handleClose = () => {
@ -51,7 +51,7 @@ export const CreateApiTokenDialog = ({
return (
<Dialog.Root isOpen={isOpen} onClose={handleClose}>
<Dialog.Popup
render={<form onSubmit={createToken} />}
render={<form onSubmit={handleSubmit} />}
initialFocus={inputRef}
>
<Dialog.Title>
@ -87,7 +87,7 @@ export const CreateApiTokenDialog = ({
{newTokenValue ? null : (
<Button
disabled={name.length === 0 || isSubmitting}
onClick={createToken}
onClick={handleSubmit}
type="submit"
>
{t("account.apiTokens.createModal.createButton.label")}

View File

@ -76,7 +76,7 @@ export const MyAccountForm = () => {
/>
</div>
)}
{user && <ApiTokensList user={user} />}
{user && <ApiTokensList />}
</div>
);
};

View File

@ -1,29 +1,16 @@
import useSWR from "swr";
import { fetcher } from "@/helpers/fetcher";
import type { ApiTokenFromServer } from "../types";
type ServerResponse = {
apiTokens: ApiTokenFromServer[];
};
import { useQuery } from "@tanstack/react-query";
import { trpc } from "@/lib/queryClient";
export const useApiTokens = ({
userId,
onError,
}: {
userId?: string;
onError: (error: Error) => void;
onError: (error: { message: string }) => void;
}) => {
const { data, error, mutate } = useSWR<ServerResponse, Error>(
userId ? `/api/users/${userId}/api-tokens` : null,
fetcher,
{
dedupingInterval: undefined,
},
);
const { data, error, refetch } = useQuery(trpc.user.listApiTokens.queryOptions());
if (error) onError(error);
return {
apiTokens: data?.apiTokens,
isLoading: !error && !data,
mutate,
refetch,
};
};

View File

@ -1,14 +0,0 @@
import { sendRequest } from "@typebot.io/lib/utils";
import type { ApiTokenFromServer } from "../types";
export const createApiTokenQuery = (
userId: string,
{ name }: { name: string },
) =>
sendRequest<{ apiToken: ApiTokenFromServer & { token: string } }>({
url: `/api/users/${userId}/api-tokens`,
method: "POST",
body: {
name,
},
});

View File

@ -1,14 +0,0 @@
import { sendRequest } from "@typebot.io/lib/utils";
import type { Prisma } from "@typebot.io/prisma/types";
export const deleteApiTokenQuery = ({
userId,
tokenId,
}: {
userId: string;
tokenId: string;
}) =>
sendRequest<{ apiToken: Prisma.ApiToken }>({
url: `/api/users/${userId}/api-tokens/${tokenId}`,
method: "DELETE",
});

View File

@ -0,0 +1,45 @@
import { generateId } from "@typebot.io/lib/utils";
import prisma from "@typebot.io/prisma";
import { z } from "@typebot.io/zod";
import { authenticatedProcedure } from "@/helpers/server/trpc";
const apiTokenWithTokenSchema = z.object({
id: z.string(),
name: z.string(),
createdAt: z.date(),
token: z.string(),
});
export const createApiToken = authenticatedProcedure
.meta({
openapi: {
method: "POST",
path: "/v1/users/me/api-tokens",
tags: ["User"],
protect: true,
},
})
.input(
z.object({
name: z.string(),
}),
)
.output(
z.object({
apiToken: apiTokenWithTokenSchema,
}),
)
.mutation(async ({ ctx: { user }, input: { name } }) => {
const apiToken = await prisma.apiToken.create({
data: { name, ownerId: user.id, token: generateId(24) },
});
return {
apiToken: {
id: apiToken.id,
name: apiToken.name,
createdAt: apiToken.createdAt,
token: apiToken.token,
},
};
});

View File

@ -0,0 +1,50 @@
import { TRPCError } from "@trpc/server";
import prisma from "@typebot.io/prisma";
import { z } from "@typebot.io/zod";
import { authenticatedProcedure } from "@/helpers/server/trpc";
const apiTokenSchema = z.object({
id: z.string(),
name: z.string(),
createdAt: z.date(),
token: z.string(),
ownerId: z.string(),
});
export const deleteApiToken = authenticatedProcedure
.meta({
openapi: {
method: "DELETE",
path: "/v1/users/me/api-tokens/{tokenId}",
tags: ["User"],
protect: true,
},
})
.input(
z.object({
tokenId: z.string(),
}),
)
.output(
z.object({
apiToken: apiTokenSchema,
}),
)
.mutation(async ({ input: { tokenId }, ctx: { user } }) => {
const existingToken = await prisma.apiToken.findUnique({
where: { id: tokenId },
select: { ownerId: true },
});
if (!existingToken || existingToken.ownerId !== user.id)
throw new TRPCError({
code: "NOT_FOUND",
message: "API token not found",
});
const apiToken = await prisma.apiToken.delete({
where: { id: tokenId },
});
return { apiToken };
});

View File

@ -0,0 +1,37 @@
import prisma from "@typebot.io/prisma";
import { z } from "@typebot.io/zod";
import { authenticatedProcedure } from "@/helpers/server/trpc";
const apiTokenSchema = z.object({
id: z.string(),
name: z.string(),
createdAt: z.date(),
});
export const listApiTokens = authenticatedProcedure
.meta({
openapi: {
method: "GET",
path: "/v1/users/me/api-tokens",
tags: ["User"],
protect: true,
},
})
.input(z.void())
.output(
z.object({
apiTokens: z.array(apiTokenSchema),
}),
)
.query(async ({ ctx: { user } }) => {
const apiTokens = await prisma.apiToken.findMany({
where: { ownerId: user.id },
select: {
id: true,
name: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
});
return { apiTokens };
});

View File

@ -1,9 +1,15 @@
import { router } from "@/helpers/server/trpc";
import { acceptTerms } from "./acceptTerms";
import { createApiToken } from "./createApiToken";
import { deleteApiToken } from "./deleteApiToken";
import { listApiTokens } from "./listApiTokens";
import { updateUser } from "./updateUser";
export const publicUserRouter = router({
update: updateUser,
listApiTokens,
createApiToken,
deleteApiToken,
});
export const internalUserRouter = router({

View File

@ -1,5 +0,0 @@
export type ApiTokenFromServer = {
id: string;
name: string;
createdAt: string;
};

View File

@ -1,39 +0,0 @@
import { methodNotAllowed, notAuthenticated } from "@typebot.io/lib/api/utils";
import { generateId } from "@typebot.io/lib/utils";
import prisma from "@typebot.io/prisma";
import type { NextApiRequest, NextApiResponse } from "next";
import { getAuthenticatedUser } from "@/features/auth/helpers/getAuthenticatedUser";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res);
if (!user) return notAuthenticated(res);
if (req.method === "GET") {
const apiTokens = await prisma.apiToken.findMany({
where: { ownerId: user.id },
select: {
id: true,
name: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
});
return res.send({ apiTokens });
}
if (req.method === "POST") {
const data = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
const apiToken = await prisma.apiToken.create({
data: { name: data.name, ownerId: user.id, token: generateId(24) },
});
return res.send({
apiToken: {
id: apiToken.id,
name: apiToken.name,
createdAt: apiToken.createdAt,
token: apiToken.token,
},
});
}
methodNotAllowed(res);
};
export default handler;

View File

@ -1,20 +0,0 @@
import { methodNotAllowed, notAuthenticated } from "@typebot.io/lib/api/utils";
import prisma from "@typebot.io/prisma";
import type { NextApiRequest, NextApiResponse } from "next";
import { getAuthenticatedUser } from "@/features/auth/helpers/getAuthenticatedUser";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res);
if (!user) return notAuthenticated(res);
if (req.method === "DELETE") {
const id = req.query.tokenId as string;
const apiToken = await prisma.apiToken.delete({
where: { id },
});
return res.send({ apiToken });
}
methodNotAllowed(res);
};
export default handler;