| 1 | "use client"; |
| 2 | |
| 3 | import { useMutation, useQuery |
| 4 | import { TerminalSquare } |
| 5 | import { Fragment, useState |
| 6 | import { toast } |
| 7 | |
| 8 | import { Button } |
| 9 | import { Card, CardContent |
| 10 | import { Input } |
| 11 | import { |
| 12 | Item, |
| 13 | ItemActions, |
| 14 | ItemContent, |
| 15 | ItemDescription, |
| 16 | ItemGroup, |
| 17 | ItemMedia, |
| 18 | ItemSeparator, |
| 19 | ItemTitle, |
| 20 | } from "@/components/ui/item"; |
| 21 | import { Skeleton } |
| 22 | import { Textarea } |
| 23 | import { apiClient, apiErrorMessage |
| 24 | import { DOCS_URL } |
| 25 | import { cn } |
| 26 | |
| 27 | export type SshKeysProps |
| 28 | className?: string |
| 29 | }; |
| 30 | |
| 31 | type SshKey |
| 32 | id: string |
| 33 | title: string |
| 34 | type: string |
| 35 | fingerprint: string |
| 36 | lastUsedAt: string |
| 37 | createdAt: string |
| 38 | }; |
| 39 | |
| 40 | const QUERY_KEY = [ |
| 41 | |
| 42 | /** Keys that let this account fetch and push over SSH. Anyone holding the matching private key connects as this account, which is why the list shows when each one was last used. */ |
| 43 | export function SshKeys({ |
| 44 | const queryClient = |
| 45 | const [publicKey, |
| 46 | const [title, |
| 47 | |
| 48 | const { data, |
| 49 | queryKey: QUERY_KEY, |
| 50 | queryFn: async () |
| 51 | const { data, |
| 52 | if (error) throw |
| 53 | return data.keys |
| 54 | }, |
| 55 | }); |
| 56 | |
| 57 | const invalidate = |
| 58 | queryClient.invalidateQueries({ |
| 59 | |
| 60 | const add = |
| 61 | mutationFn: async () |
| 62 | const { error |
| 63 | body: { |
| 64 | publicKey: publicKey.trim |
| 65 | ...(title.trim |
| 66 | }, |
| 67 | }); |
| 68 | if (error) throw |
| 69 | }, |
| 70 | onSuccess: async () |
| 71 | setPublicKey(""); |
| 72 | setTitle(""); |
| 73 | toast.success("Key added |
| 74 | await invalidate(); |
| 75 | }, |
| 76 | onError: (error: |
| 77 | }); |
| 78 | |
| 79 | const remove = |
| 80 | mutationFn: async ( |
| 81 | const { error |
| 82 | params: { path: { |
| 83 | }); |
| 84 | if (error) throw |
| 85 | }, |
| 86 | onSuccess: async () |
| 87 | toast.success("Key removed |
| 88 | await invalidate(); |
| 89 | }, |
| 90 | onError: (error: |
| 91 | }); |
| 92 | |
| 93 | const isBusy = |
| 94 | const keys = |
| 95 | |
| 96 | return ( |
| 97 | <div> |
| 98 | <h2 className="mb-3 text-sm font-semibold |
| 99 | |
| 100 | <Card className={ |
| 101 | <CardContent className=" |
| 102 | <ItemGroup className=" |
| 103 | {isPending ? ( |
| 104 | <KeyRowSkeleton /> |
| 105 | ) : keys.length |
| 106 | <Item> |
| 107 | <ItemContent> |
| 108 | <ItemDescription> |
| 109 | No keys yet. Clone and push over HTTPS, or add a key to use |
| 110 | SSH. |
| 111 | </ItemDescription> |
| 112 | </ItemContent> |
| 113 | </Item> |
| 114 | ) : ( |
| 115 | keys.map((key |
| 116 | <Fragment key={ |
| 117 | {index > 0 |
| 118 | <Item> |
| 119 | <ItemMedia variant=" |
| 120 | <TerminalSquare /> |
| 121 | </ItemMedia> |
| 122 | <ItemContent> |
| 123 | <ItemTitle>{key. |
| 124 | <ItemDescription className=" |
| 125 | SHA256:{key.fingerprint} |
| 126 | </ItemDescription> |
| 127 | <ItemDescription> |
| 128 | {key.type} · |
| 129 | </ItemDescription> |
| 130 | </ItemContent> |
| 131 | <ItemActions> |
| 132 | <Button |
| 133 | size="sm" |
| 134 | variant="ghost" |
| 135 | disabled={isBusy} |
| 136 | onClick={() => |
| 137 | > |
| 138 | Remove |
| 139 | </Button> |
| 140 | </ItemActions> |
| 141 | </Item> |
| 142 | </Fragment> |
| 143 | )) |
| 144 | )} |
| 145 | </ItemGroup> |
| 146 | </CardContent> |
| 147 | |
| 148 | <form |
| 149 | className="flex flex-col gap-2 border-t p-4" |
| 150 | onSubmit={(event) |
| 151 | event.preventDefault(); |
| 152 | if (publicKey.trim()) |
| 153 | }} |
| 154 | > |
| 155 | <Input |
| 156 | name="title" |
| 157 | placeholder="Title, e.g. work laptop" |
| 158 | value={title} |
| 159 | disabled={isBusy} |
| 160 | onChange={(event) |
| 161 | /> |
| 162 | <Textarea |
| 163 | name="publicKey" |
| 164 | className="h-24 font-mono text-xs" |
| 165 | placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... you@laptop" |
| 166 | value={publicKey} |
| 167 | disabled={isBusy} |
| 168 | onChange={(event) |
| 169 | required |
| 170 | /> |
| 171 | <Button |
| 172 | type="submit" |
| 173 | size="sm" |
| 174 | className="self-end" |
| 175 | disabled={isBusy || |
| 176 | > |
| 177 | Add key |
| 178 | </Button> |
| 179 | </form> |
| 180 | </Card> |
| 181 | |
| 182 | <p className="mt-2 text-xs text-muted-foreground |
| 183 | Paste the contents of <code>~/.ssh/id_ed25519.pub</code> — the public |
| 184 | half, never the file without <code>.pub</code>.{ |
| 185 | <a |
| 186 | className="underline underline-offset-2" |
| 187 | href={`${DOCS_URL |
| 188 | target="_blank" |
| 189 | rel="noreferrer" |
| 190 | > |
| 191 | Read the guide |
| 192 | </a> |
| 193 | . |
| 194 | </p> |
| 195 | </div> |
| 196 | ); |
| 197 | } |
| 198 | |
| 199 | function lastUsed(timestamp: |
| 200 | if (!timestamp) |
| 201 | return `Last used ${new |
| 202 | } |
| 203 | |
| 204 | function KeyRowSkeleton() { |
| 205 | return ( |
| 206 | <Item> |
| 207 | <ItemMedia variant=" |
| 208 | <TerminalSquare /> |
| 209 | </ItemMedia> |
| 210 | <ItemContent className=" |
| 211 | <Skeleton className=" |
| 212 | <Skeleton className=" |
| 213 | </ItemContent> |
| 214 | </Item> |
| 215 | ); |
| 216 | } |