Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rotate api key #477

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialog open={openAlert} onOpenChange={setOpenAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
Expand All @@ -71,7 +86,10 @@ function DeleteKey({ tokenId, open, setOpen }: DeleteDialogProps) {
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutateAsync({ tokenId })}
onClick={async () => {
setLoading(true);
await deleteApiKey({ tokenId: accessToken });
}}
>
Continue
</AlertDialogAction>
Expand All @@ -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 (
<Card className="mx-auto mt-3 w-[28rem] sm:w-[38rem] md:w-full">
<div className="mx-3">
<Modal
title="Access token rotated"
subtitle={
<Tldr
message="
You will not see this key again, so please make sure to copy and store it in a safe place.
"
/>
}
dialogProps={{
defaultOpen: openViewer,
open: openViewer,
onOpenChange: (val) => {
setOpenViewer(val);
},
}}
>
<Fragment>
<span className="font-semibold">Your API Key</span>
<Card
className="cursor-copy break-words p-3 mt-2"
onClick={() => {
copy(accessToken as string);
toast.success("Access token copied to clipboard!");
}}
>
<code className="text-sm font-mono text-rose-600">{accessToken}</code>
</Card>
<span className="text-xs text-gray-700">
Click the access token above to copy
</span>
</Fragment>
</Modal>
);
}

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 (
<AlertDialog open={openAlert} onOpenChange={setOpenAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to rotate this key? please make sure to
replace existing API keys if you have used it anywhere.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setLoading(true);
await rotateApiKey({ tokenId: accessToken });
}}
>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

type AccessTokens = RouterOutputs["accessToken"]["listAll"]["accessTokens"];

const AccessTokenTable = ({ tokens }: { tokens: AccessTokens }) => {
const [loading, setLoading] = useState<boolean>(false);
const [selectedToken, setSelectedToken] = useState<string>("");
const [accessToken, setAccessToken] = useState<string>("");
const [openRotateAlert, setOpenRotateAlert] = useState<boolean>(false);
const [openDeleteAlert, setOpenDeleteAlert] = useState<boolean>(false);
const [showTokenViewerModal, setShowTokenViewerModal] =
useState<boolean>(false);

const handleDeleteKey = (key: string) => {
setSelectedToken(key);
setOpenDeleteAlert(true);
};
const handleRotateKey = (key: string) => {
setSelectedToken(key);
setOpenRotateAlert(true);
};

return (
<>
<Card className="mx-auto mt-3 w-[28rem] sm:w-[38rem] md:w-full">
<div className="mx-3">
<Tldr
message="
For security reasons, we have no ways to retrieve your complete access token. If you lose your access key, you will need to create or rotate and replace with a new one.
"
/>
</div>

<Table>
<TableHeader>
<TableRow>
<TableHead>Access token</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last used</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{tokens.map((token: AccessTokens[number]) => (
<TableRow key={token.id}>
<TableCell className="flex cursor-pointer items-center">
<code className="text-xs">{`${token.clientId}:***`}</code>
</TableCell>
<TableCell suppressHydrationWarning>
{dayjsExt().to(token.createdAt)}
</TableCell>
<TableCell suppressHydrationWarning>
{token.lastUsed ? dayjsExt().to(token.lastUsed) : "Never"}
</TableCell>

<TableCell>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger>
<RiMore2Fill className="cursor-pointer text-muted-foreground hover:text-primary/80" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Options</DropdownMenuLabel>
<DropdownMenuSeparator />

<DropdownMenuItem onClick={() => {}}>
Rotate key
</DropdownMenuItem>

<Allow action="delete" subject="developer">
{(allow) => (
<DropdownMenuItem
disabled={!allow}
onSelect={() => setOpen(true)}
>
Delete key
</DropdownMenuItem>
)}
</Allow>
</DropdownMenuContent>
</DropdownMenu>
<DeleteKey
open={open}
setOpen={(val) => setOpen(val)}
tokenId={token.id}
/>
</div>
</TableCell>
/>
</div>

<Table>
<TableHeader>
<TableRow>
<TableHead>Access token</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last used</TableHead>
<TableHead />
</TableRow>
))}
</TableBody>
</Table>
</Card>
</TableHeader>
<TableBody>
{tokens.map((token: AccessTokens[number]) => (
<TableRow key={token.id}>
<TableCell className="flex cursor-pointer items-center">
<code className="text-xs">{`${token.clientId}:***`}</code>
</TableCell>
<TableCell suppressHydrationWarning>
{dayjsExt().to(token.createdAt)}
</TableCell>
<TableCell suppressHydrationWarning>
{token.lastUsed ? dayjsExt().to(token.lastUsed) : "Never"}
</TableCell>

<TableCell>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger>
<RiMore2Fill className="cursor-pointer text-muted-foreground hover:text-primary/80" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Options</DropdownMenuLabel>
<DropdownMenuSeparator />

<Allow action="update" subject="developer">
{(allow) => (
<DropdownMenuItem
disabled={!allow}
onSelect={() => handleRotateKey(token.id)}
>
Rotate key
</DropdownMenuItem>
)}
</Allow>

<Allow action="delete" subject="developer">
{(allow) => (
<DropdownMenuItem
disabled={!allow}
onSelect={() => handleDeleteKey(token.id)}
>
Delete key
</DropdownMenuItem>
)}
</Allow>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<DeleteKeyAlert
openAlert={openDeleteAlert}
setOpenAlert={(val) => setOpenDeleteAlert(val)}
accessToken={selectedToken}
setLoading={setLoading}
/>
<RotateKeyAlert
openAlert={openRotateAlert}
setOpenAlert={(val: boolean) => setOpenRotateAlert(val)}
setOpenViewer={setShowTokenViewerModal}
accessToken={selectedToken}
setAccessToken={setAccessToken}
setLoading={setLoading}
/>
<TokenViewerModal
accessToken={accessToken}
openViewer={showTokenViewerModal}
setOpenViewer={setShowTokenViewerModal}
/>
</Card>
{loading && <Loading />}
</>
);
};

Expand Down
1 change: 1 addition & 0 deletions src/server/audit/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const AuditSchema = z.object({
"update.unshared",

"accessToken.created",
"accessToken.rotated",
"accessToken.deleted",

"bucket.created",
Expand Down
Loading