diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/table.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/table.tsx index efeddc9f4..296c08683 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/table.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/settings/developer/components/table.tsx @@ -1,6 +1,8 @@ "use client"; import { dayjsExt } from "@/common/dayjs"; +import Loading from "@/components/common/loading"; +import Modal from "@/components/common/modal"; import Tldr from "@/components/common/tldr"; import { Allow } from "@/components/rbac/allow"; import { @@ -34,31 +36,44 @@ import { api } from "@/trpc/react"; import type { RouterOutputs } from "@/trpc/shared"; import { RiMore2Fill } from "@remixicon/react"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { toast } from "sonner"; +import { useCopyToClipboard } from "usehooks-ts"; interface DeleteDialogProps { - tokenId: string; - open: boolean; - setOpen: (val: boolean) => void; + accessToken: string; + openAlert: boolean; + setOpenAlert: (val: boolean) => void; + setLoading: (val: boolean) => void; } -function DeleteKey({ tokenId, open, setOpen }: DeleteDialogProps) { +function DeleteKeyAlert({ + accessToken, + openAlert, + setOpenAlert, + setLoading, +}: DeleteDialogProps) { const router = useRouter(); - const deleteMutation = api.accessToken.delete.useMutation({ - onSuccess: ({ message }) => { - toast.success(message); - router.refresh(); + const { mutateAsync: deleteApiKey } = api.accessToken.delete.useMutation({ + onSuccess: ({ success, message }) => { + if (success) { + toast.success(message); + router.refresh(); + } }, onError: (error) => { console.error(error); toast.error("An error occurred while creating the access token."); }, + + onSettled: () => { + setLoading(false); + }, }); return ( - + Are you sure? @@ -71,7 +86,10 @@ function DeleteKey({ tokenId, open, setOpen }: DeleteDialogProps) { Cancel deleteMutation.mutateAsync({ tokenId })} + onClick={async () => { + setLoading(true); + await deleteApiKey({ tokenId: accessToken }); + }} > Continue @@ -81,81 +99,237 @@ function DeleteKey({ tokenId, open, setOpen }: DeleteDialogProps) { ); } -type AccessTokens = RouterOutputs["accessToken"]["listAll"]["accessTokens"]; +type TokenViewerModalProps = Omit< + DeleteDialogProps, + "openAlert" | "setOpenAlert" | "setLoading" +> & { + openViewer: boolean; + setOpenViewer: (val: boolean) => void; +}; -const AccessTokenTable = ({ tokens }: { tokens: AccessTokens }) => { - const [open, setOpen] = useState(false); +function TokenViewerModal({ + accessToken, + openViewer, + setOpenViewer, +}: TokenViewerModalProps) { + const [_copied, copy] = useCopyToClipboard(); return ( - -
+ + } + dialogProps={{ + defaultOpen: openViewer, + open: openViewer, + onOpenChange: (val) => { + setOpenViewer(val); + }, + }} + > + + Your API Key + { + copy(accessToken as string); + toast.success("Access token copied to clipboard!"); + }} + > + {accessToken} + + + Click the access token above to copy + + + + ); +} + +interface RotateKeyProps extends DeleteDialogProps { + setOpenViewer: (val: boolean) => void; + setAccessToken: (key: string) => void; +} + +function RotateKeyAlert({ + accessToken, + openAlert, + setOpenAlert, + setOpenViewer, + setAccessToken, + setLoading, +}: RotateKeyProps) { + const router = useRouter(); + const [_copied, copy] = useCopyToClipboard(); + + const { mutateAsync: rotateApiKey } = api.accessToken.rotate.useMutation({ + onSuccess: ({ success, token }) => { + if (success && token) { + toast.promise(copy(token), { + loading: "Rotating access token", + success: "Successfully rotated the api key.", + error: "Error rotating the access token", + }); + setAccessToken(token); + setOpenViewer(true); + router.refresh(); + } + }, + + onError: (error) => { + console.error(error); + toast.error("An error occurred while creating the API key."); + }, + + onSettled: () => { + setLoading(false); + }, + }); + return ( + + + + Are you sure? + + Are you sure you want to rotate this key? please make sure to + replace existing API keys if you have used it anywhere. + + + + Cancel + { + setLoading(true); + await rotateApiKey({ tokenId: accessToken }); + }} + > + Continue + + + + + ); +} + +type AccessTokens = RouterOutputs["accessToken"]["listAll"]["accessTokens"]; + +const AccessTokenTable = ({ tokens }: { tokens: AccessTokens }) => { + const [loading, setLoading] = useState(false); + const [selectedToken, setSelectedToken] = useState(""); + const [accessToken, setAccessToken] = useState(""); + const [openRotateAlert, setOpenRotateAlert] = useState(false); + const [openDeleteAlert, setOpenDeleteAlert] = useState(false); + const [showTokenViewerModal, setShowTokenViewerModal] = + useState(false); + + const handleDeleteKey = (key: string) => { + setSelectedToken(key); + setOpenDeleteAlert(true); + }; + const handleRotateKey = (key: string) => { + setSelectedToken(key); + setOpenRotateAlert(true); + }; + + return ( + <> + +
+ -
- - - - - Access token - Created - Last used - - - - - {tokens.map((token: AccessTokens[number]) => ( - - - {`${token.clientId}:***`} - - - {dayjsExt().to(token.createdAt)} - - - {token.lastUsed ? dayjsExt().to(token.lastUsed) : "Never"} - - - -
- - - - - - Options - - - {}}> - Rotate key - - - - {(allow) => ( - setOpen(true)} - > - Delete key - - )} - - - - setOpen(val)} - tokenId={token.id} - /> -
-
+ /> + + +
+ + + Access token + Created + Last used + - ))} - -
-
+ + + {tokens.map((token: AccessTokens[number]) => ( + + + {`${token.clientId}:***`} + + + {dayjsExt().to(token.createdAt)} + + + {token.lastUsed ? dayjsExt().to(token.lastUsed) : "Never"} + + + +
+ + + + + + Options + + + + {(allow) => ( + handleRotateKey(token.id)} + > + Rotate key + + )} + + + + {(allow) => ( + handleDeleteKey(token.id)} + > + Delete key + + )} + + + +
+
+
+ ))} +
+ + setOpenDeleteAlert(val)} + accessToken={selectedToken} + setLoading={setLoading} + /> + setOpenRotateAlert(val)} + setOpenViewer={setShowTokenViewerModal} + accessToken={selectedToken} + setAccessToken={setAccessToken} + setLoading={setLoading} + /> + + + {loading && } + ); }; diff --git a/src/server/audit/schema.ts b/src/server/audit/schema.ts index d952ead73..1ad07d362 100644 --- a/src/server/audit/schema.ts +++ b/src/server/audit/schema.ts @@ -59,6 +59,7 @@ export const AuditSchema = z.object({ "update.unshared", "accessToken.created", + "accessToken.rotated", "accessToken.deleted", "bucket.created", diff --git a/src/trpc/routers/access-token/router.ts b/src/trpc/routers/access-token/router.ts index adc33320e..f828eb080 100644 --- a/src/trpc/routers/access-token/router.ts +++ b/src/trpc/routers/access-token/router.ts @@ -1,7 +1,6 @@ import { createSecureHash, initializeAccessToken } from "@/lib/crypto"; import { AccessTokenType } from "@/prisma/enums"; import { Audit } from "@/server/audit"; - import { createTRPCRouter, withAccessControl } from "@/trpc/api/trpc"; import { TRPCError } from "@trpc/server"; import z from "zod"; @@ -43,6 +42,7 @@ export const accessTokenRouter = createTRPCRouter({ create: withAccessControl .input(z.object({ typeEnum: z.nativeEnum(AccessTokenType) })) + .meta({ policies: { developer: { allow: ["create"] } } }) .mutation(async ({ ctx, input }) => { const { db, @@ -92,6 +92,93 @@ export const accessTokenRouter = createTRPCRouter({ }; }), + rotate: withAccessControl + .input(z.object({ tokenId: z.string() })) + .meta({ policies: { developer: { allow: ["update"] } } }) + .mutation(async ({ ctx, input }) => { + try { + const { + db, + membership: { userId, companyId }, + session, + requestIp, + userAgent, + } = ctx; + const { user } = session; + const { tokenId } = input; + + const key = await db.$transaction(async (tx) => { + const existingToken = await tx.accessToken.findUnique({ + where: { + id: tokenId, + userId, + active: true, + }, + }); + + if (!existingToken) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Access token not found", + }); + } + + const { clientId, clientSecret } = initializeAccessToken({ + prefix: existingToken.typeEnum, + }); + const hashedClientSecret = await createSecureHash(clientSecret); + + const rotated = await tx.accessToken.update({ + where: { + id: existingToken.id, + }, + data: { + clientId, + clientSecret: hashedClientSecret, + }, + }); + + await Audit.create( + { + action: "accessToken.rotated", + companyId, + actor: { type: "user", id: user.id }, + context: { + userAgent, + requestIp, + }, + target: [{ type: "accessToken", id: rotated.id }], + summary: `${user.name} rotated the access-token of rowID : ${rotated.id}`, + }, + tx, + ); + return rotated; + }); + + return { + success: true, + token: `${key.clientId}:${key.clientSecret}`, + clientId: key.clientId, + createdAt: key.createdAt, + }; + } catch (error) { + console.error("Error rotating the api access token :", error); + if (error instanceof TRPCError) { + return { + success: false, + message: error.message, + }; + } + return { + success: false, + message: + error instanceof Error + ? error.message + : "Oops, something went wrong. Please try again later.", + }; + } + }), + delete: withAccessControl .input(z.object({ tokenId: z.string() })) .mutation(async ({ ctx, input }) => {