mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-06-13 21:02:56 +08:00
♻️ Refactor API token management to use tRPC (#2305)
This commit is contained in:
parent
842f8ef0bb
commit
41c91f98ae
@ -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;
|
||||
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -76,7 +76,7 @@ export const MyAccountForm = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{user && <ApiTokensList user={user} />}
|
||||
{user && <ApiTokensList />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -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",
|
||||
});
|
||||
45
apps/builder/src/features/user/server/createApiToken.ts
Normal file
45
apps/builder/src/features/user/server/createApiToken.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
50
apps/builder/src/features/user/server/deleteApiToken.ts
Normal file
50
apps/builder/src/features/user/server/deleteApiToken.ts
Normal 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 };
|
||||
});
|
||||
|
||||
37
apps/builder/src/features/user/server/listApiTokens.ts
Normal file
37
apps/builder/src/features/user/server/listApiTokens.ts
Normal 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 };
|
||||
});
|
||||
@ -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({
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
export type ApiTokenFromServer = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
@ -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;
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user