|
1 | 1 | "use client"; |
2 | 2 |
|
3 | 3 | import { dayjsExt } from "@/common/dayjs"; |
| 4 | +import Loading from "@/components/common/loading"; |
| 5 | +import Modal from "@/components/common/modal"; |
4 | 6 | import Tldr from "@/components/common/tldr"; |
5 | 7 | import { Allow } from "@/components/rbac/allow"; |
6 | 8 | import { |
@@ -33,14 +35,29 @@ import { |
33 | 35 | import { api } from "@/trpc/react"; |
34 | 36 | import { RiMore2Fill } from "@remixicon/react"; |
35 | 37 | import { useRouter } from "next/navigation"; |
36 | | -import { useState } from "react"; |
| 38 | +import { Fragment, useEffect, useRef, useState } from "react"; |
37 | 39 | import { toast } from "sonner"; |
| 40 | +import { useCopyToClipboard } from "usehooks-ts"; |
38 | 41 |
|
39 | 42 | interface DeleteDialogProps { |
40 | 43 | keyId: string; |
41 | 44 | open: boolean; |
42 | 45 | setOpen: (val: boolean) => void; |
43 | 46 | } |
| 47 | +interface RotateKeyProps extends DeleteDialogProps { |
| 48 | + setShowModal: (val: boolean) => void; |
| 49 | + setApiKey: (key: string) => void; |
| 50 | + setLoading: (val: boolean) => void; |
| 51 | +} |
| 52 | +type KeyModalProps = Omit<DeleteDialogProps, "keyId"> & { |
| 53 | + apiKey: string; |
| 54 | +}; |
| 55 | + |
| 56 | +interface ApiKey { |
| 57 | + keyId: string; |
| 58 | + createdAt: Date; |
| 59 | + lastUsed: Date | null; |
| 60 | +} |
44 | 61 |
|
45 | 62 | function DeleteKey({ keyId, open, setOpen }: DeleteDialogProps) { |
46 | 63 | const router = useRouter(); |
@@ -80,87 +97,211 @@ function DeleteKey({ keyId, open, setOpen }: DeleteDialogProps) { |
80 | 97 | ); |
81 | 98 | } |
82 | 99 |
|
83 | | -interface ApiKey { |
84 | | - keyId: string; |
85 | | - createdAt: Date; |
86 | | - lastUsed: Date | null; |
| 100 | +function RotateKey({ |
| 101 | + keyId, |
| 102 | + open, |
| 103 | + setOpen, |
| 104 | + setShowModal, |
| 105 | + setApiKey, |
| 106 | + setLoading, |
| 107 | +}: RotateKeyProps) { |
| 108 | + const router = useRouter(); |
| 109 | + const [_copied, copy] = useCopyToClipboard(); |
| 110 | + |
| 111 | + const { mutateAsync: rotateApiKey } = api.apiKey.rotate.useMutation({ |
| 112 | + onSuccess: ({ token, keyId }) => { |
| 113 | + const key = `${keyId}:${token}` as string; |
| 114 | + toast.success("Successfully rotated the api key."); |
| 115 | + copy(key); |
| 116 | + setApiKey(key); |
| 117 | + setShowModal(true); |
| 118 | + router.refresh(); |
| 119 | + }, |
| 120 | + |
| 121 | + onError: (error) => { |
| 122 | + console.error(error); |
| 123 | + toast.error("An error occurred while creating the API key."); |
| 124 | + }, |
| 125 | + }); |
| 126 | + return ( |
| 127 | + <AlertDialog open={open} onOpenChange={setOpen}> |
| 128 | + <AlertDialogContent> |
| 129 | + <AlertDialogHeader> |
| 130 | + <AlertDialogTitle>Are you sure?</AlertDialogTitle> |
| 131 | + <AlertDialogDescription> |
| 132 | + Are you sure you want to rotate this key? please make sure to |
| 133 | + replace existing API keys if you have used it anywhere. |
| 134 | + </AlertDialogDescription> |
| 135 | + </AlertDialogHeader> |
| 136 | + <AlertDialogFooter> |
| 137 | + <AlertDialogCancel>Cancel</AlertDialogCancel> |
| 138 | + <AlertDialogAction |
| 139 | + onClick={async () => { |
| 140 | + setLoading(true); |
| 141 | + await rotateApiKey({ keyId }); |
| 142 | + }} |
| 143 | + > |
| 144 | + Continue |
| 145 | + </AlertDialogAction> |
| 146 | + </AlertDialogFooter> |
| 147 | + </AlertDialogContent> |
| 148 | + </AlertDialog> |
| 149 | + ); |
87 | 150 | } |
88 | 151 |
|
89 | | -const ApiKeysTable = ({ keys }: { keys: ApiKey[] }) => { |
90 | | - const [open, setOpen] = useState(false); |
| 152 | +function KeyModal({ apiKey, open, setOpen }: KeyModalProps) { |
| 153 | + const [_copied, copy] = useCopyToClipboard(); |
91 | 154 |
|
92 | 155 | return ( |
93 | | - <Card className="mx-auto mt-3 w-[28rem] sm:w-[38rem] md:w-full"> |
94 | | - <div className="mx-3"> |
| 156 | + <Modal |
| 157 | + title="API key created" |
| 158 | + subtitle={ |
95 | 159 | <Tldr |
96 | 160 | message=" |
| 161 | + You will not see this key again, so please make sure to copy and store it in a safe place. |
| 162 | + " |
| 163 | + /> |
| 164 | + } |
| 165 | + dialogProps={{ |
| 166 | + defaultOpen: open, |
| 167 | + open, |
| 168 | + onOpenChange: (val) => { |
| 169 | + setOpen(val); |
| 170 | + }, |
| 171 | + }} |
| 172 | + > |
| 173 | + <Fragment> |
| 174 | + <span className="font-semibold">Your API Key</span> |
| 175 | + <Card |
| 176 | + className="cursor-copy break-words p-3 mt-2" |
| 177 | + onClick={() => { |
| 178 | + copy(apiKey as string); |
| 179 | + toast.success("API key copied to clipboard!"); |
| 180 | + }} |
| 181 | + > |
| 182 | + <code className="text-sm font-mono text-rose-600">{apiKey}</code> |
| 183 | + </Card> |
| 184 | + <span className="text-xs text-gray-700"> |
| 185 | + Click the API key above to copy |
| 186 | + </span> |
| 187 | + </Fragment> |
| 188 | + </Modal> |
| 189 | + ); |
| 190 | +} |
| 191 | + |
| 192 | +const ApiKeysTable = ({ keys }: { keys: ApiKey[] }) => { |
| 193 | + const [loading, setLoading] = useState<boolean>(false); |
| 194 | + const [openDeleteAlert, setOpenDeleteAlert] = useState<boolean>(false); |
| 195 | + const [openRotateAlert, setOpenRotateAlert] = useState<boolean>(false); |
| 196 | + const [copyApiKeyModal, setCopyApiKeyModal] = useState<boolean>(false); |
| 197 | + |
| 198 | + const [apiKey, setApiKey] = useState<string>(""); |
| 199 | + const [selectedKey, setSelected] = useState<string>(""); |
| 200 | + |
| 201 | + const handleDeleteKey = (key: string) => { |
| 202 | + setSelected(key); |
| 203 | + setOpenDeleteAlert(true); |
| 204 | + }; |
| 205 | + const handleRotateKey = (key: string) => { |
| 206 | + setSelected(key); |
| 207 | + setOpenRotateAlert(true); |
| 208 | + }; |
| 209 | + |
| 210 | + return ( |
| 211 | + <> |
| 212 | + <Card className="mx-auto mt-3 w-[28rem] sm:w-[38rem] md:w-full"> |
| 213 | + <div className="mx-3"> |
| 214 | + <Tldr |
| 215 | + message=" |
97 | 216 | For security reasons, we have no ways to retrieve your complete API keys. If you lose your API key, you will need to create or rotate and replace with a new one. |
98 | 217 | " |
99 | | - /> |
100 | | - </div> |
101 | | - |
102 | | - <Table> |
103 | | - <TableHeader> |
104 | | - <TableRow> |
105 | | - <TableHead>Key</TableHead> |
106 | | - <TableHead>Created</TableHead> |
107 | | - <TableHead>Last used</TableHead> |
108 | | - <TableHead /> |
109 | | - </TableRow> |
110 | | - </TableHeader> |
111 | | - <TableBody> |
112 | | - {keys.map((key) => ( |
113 | | - <TableRow key={key.keyId}> |
114 | | - <TableCell className="flex cursor-pointer items-center"> |
115 | | - <code className="text-xs"> |
116 | | - {`${key.keyId.slice(0, 3)}...${key.keyId.slice(-3)}:****`} |
117 | | - </code> |
118 | | - </TableCell> |
119 | | - <TableCell suppressHydrationWarning> |
120 | | - {dayjsExt().to(key.createdAt)} |
121 | | - </TableCell> |
122 | | - <TableCell suppressHydrationWarning> |
123 | | - {key.lastUsed ? dayjsExt().to(key.lastUsed) : "Never"} |
124 | | - </TableCell> |
125 | | - |
126 | | - <TableCell> |
127 | | - <div className="flex items-center gap-4"> |
128 | | - <DropdownMenu> |
129 | | - <DropdownMenuTrigger> |
130 | | - <RiMore2Fill className="cursor-pointer text-muted-foreground hover:text-primary/80" /> |
131 | | - </DropdownMenuTrigger> |
132 | | - <DropdownMenuContent> |
133 | | - <DropdownMenuLabel>Options</DropdownMenuLabel> |
134 | | - <DropdownMenuSeparator /> |
135 | | - |
136 | | - <DropdownMenuItem onClick={() => {}}> |
137 | | - Rotate key |
138 | | - </DropdownMenuItem> |
139 | | - |
140 | | - <Allow action="delete" subject="api-keys"> |
141 | | - {(allow) => ( |
142 | | - <DropdownMenuItem |
143 | | - disabled={!allow} |
144 | | - onSelect={() => setOpen(true)} |
145 | | - > |
146 | | - Delete key |
147 | | - </DropdownMenuItem> |
148 | | - )} |
149 | | - </Allow> |
150 | | - </DropdownMenuContent> |
151 | | - </DropdownMenu> |
152 | | - <DeleteKey |
153 | | - open={open} |
154 | | - setOpen={(val) => setOpen(val)} |
155 | | - keyId={key.keyId} |
156 | | - /> |
157 | | - </div> |
158 | | - </TableCell> |
| 218 | + /> |
| 219 | + </div> |
| 220 | + |
| 221 | + <Table> |
| 222 | + <TableHeader> |
| 223 | + <TableRow> |
| 224 | + <TableHead>Key</TableHead> |
| 225 | + <TableHead>Created</TableHead> |
| 226 | + <TableHead>Last used</TableHead> |
| 227 | + <TableHead /> |
159 | 228 | </TableRow> |
160 | | - ))} |
161 | | - </TableBody> |
162 | | - </Table> |
163 | | - </Card> |
| 229 | + </TableHeader> |
| 230 | + <TableBody> |
| 231 | + {keys.map((key) => ( |
| 232 | + <TableRow key={key.keyId}> |
| 233 | + <TableCell className="flex cursor-pointer items-center"> |
| 234 | + <code className="text-xs"> |
| 235 | + {`${key.keyId.slice(0, 3)}...${key.keyId.slice(-3)}:****`} |
| 236 | + </code> |
| 237 | + </TableCell> |
| 238 | + <TableCell suppressHydrationWarning> |
| 239 | + {dayjsExt().to(key.createdAt)} |
| 240 | + </TableCell> |
| 241 | + <TableCell suppressHydrationWarning> |
| 242 | + {key.lastUsed ? dayjsExt().to(key.lastUsed) : "Never"} |
| 243 | + </TableCell> |
| 244 | + |
| 245 | + <TableCell> |
| 246 | + <div className="flex items-center gap-4"> |
| 247 | + <DropdownMenu> |
| 248 | + <DropdownMenuTrigger> |
| 249 | + <RiMore2Fill className="cursor-pointer text-muted-foreground hover:text-primary/80" /> |
| 250 | + </DropdownMenuTrigger> |
| 251 | + <DropdownMenuContent> |
| 252 | + <DropdownMenuLabel>Options</DropdownMenuLabel> |
| 253 | + <DropdownMenuSeparator /> |
| 254 | + |
| 255 | + <Allow action="update" subject="api-keys"> |
| 256 | + {(allow) => ( |
| 257 | + <DropdownMenuItem |
| 258 | + disabled={!allow} |
| 259 | + onSelect={() => handleRotateKey(key.keyId)} |
| 260 | + > |
| 261 | + Rotate key |
| 262 | + </DropdownMenuItem> |
| 263 | + )} |
| 264 | + </Allow> |
| 265 | + |
| 266 | + <Allow action="delete" subject="api-keys"> |
| 267 | + {(allow) => ( |
| 268 | + <DropdownMenuItem |
| 269 | + disabled={!allow} |
| 270 | + onSelect={() => handleDeleteKey(key.keyId)} |
| 271 | + > |
| 272 | + Delete key |
| 273 | + </DropdownMenuItem> |
| 274 | + )} |
| 275 | + </Allow> |
| 276 | + </DropdownMenuContent> |
| 277 | + </DropdownMenu> |
| 278 | + </div> |
| 279 | + </TableCell> |
| 280 | + </TableRow> |
| 281 | + ))} |
| 282 | + </TableBody> |
| 283 | + </Table> |
| 284 | + <DeleteKey |
| 285 | + open={openDeleteAlert} |
| 286 | + setOpen={(val) => setOpenDeleteAlert(val)} |
| 287 | + keyId={selectedKey} |
| 288 | + /> |
| 289 | + <RotateKey |
| 290 | + open={openRotateAlert} |
| 291 | + setOpen={(val: boolean) => setOpenRotateAlert(val)} |
| 292 | + setShowModal={setCopyApiKeyModal} |
| 293 | + setApiKey={setApiKey} |
| 294 | + keyId={selectedKey} |
| 295 | + setLoading={setLoading} |
| 296 | + /> |
| 297 | + <KeyModal |
| 298 | + apiKey={apiKey} |
| 299 | + open={copyApiKeyModal} |
| 300 | + setOpen={setCopyApiKeyModal} |
| 301 | + /> |
| 302 | + </Card> |
| 303 | + {loading && <Loading />} |
| 304 | + </> |
164 | 305 | ); |
165 | 306 | }; |
166 | 307 |
|
|
0 commit comments