g1t/apps/web/app/routes/settings.tsx

138 lines4,753 bytesCodeBlame
1import { identity } from "../lib/services.server";
2import { Form } from "react-router";
3
4import type { Route } from "./+types/settings";
5import { Button, ErrorText, Field, Input } from "../components/ui";
6import { assertSameOrigin, requireUser } from "../lib/session.server";
7
8export function meta({}: Route.MetaArgs) {
9 return [{ title: "Settings · g1t" }];
10}
11
12export async function loader({ request, context }: Route.LoaderArgs) {
13 const user = requireUser(context, request);
14 const [keys, tokens] = await Promise.all([
15 identity.listSshKeys(user),
16 identity.listAccessTokens(user),
17 ]);
18 return { user, keys, tokens };
19}
20
21export async function action({ request, context }: Route.ActionArgs) {
22 assertSameOrigin(request);
23 const user = requireUser(context, request);
24 const form = await request.formData();
25 const id = String(form.get("id") ?? "");
26
27 switch (form.get("intent")) {
28 case "add-key": {
29 const result = await identity.addSshKey(
30 user,
31 String(form.get("title") ?? ""),
32 String(form.get("key") ?? ""),
33 );
34 return result.ok ? null : { keyError: result.error.message };
35 }
36 case "delete-key":
37 await identity.removeSshKey(user, id);
38 return null;
39 case "add-token": {
40 const created = await identity.createAccessToken(
41 user,
42 String(form.get("name") ?? ""),
43 );
44 return { newToken: created.token };
45 }
46 case "delete-token":
47 await identity.removeAccessToken(user, id);
48 return null;
49 }
50 return null;
51}
52
53function DeleteButton({ intent, id }: { intent: string; id: string }) {
54 return (
55 <Form method="post" className="ml-auto">
56 <input type="hidden" name="intent" value={intent} />
57 <input type="hidden" name="id" value={id} />
58 <Button variant="quiet" type="submit">
59 Delete
60 </Button>
61 </Form>
62 );
63}
64
65export default function Settings({
66 loaderData,
67 actionData,
68}: Route.ComponentProps) {
69 const { user, keys, tokens } = loaderData;
70 return (
71 <main className="mx-auto max-w-2xl space-y-12 px-4 py-12">
72 <h1 className="text-xl font-semibold">
73 Settings <span className="font-mono text-muted">{user.username}</span>
74 </h1>
75
76 <section>
77 <h2 className="font-medium">SSH keys</h2>
78 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
79 {keys.map((key) => (
80 <li key={key.id} className="flex items-center gap-4 px-4 py-3">
81 <div className="min-w-0">
82 <p className="text-sm">{key.title}</p>
83 <p className="truncate font-mono text-xs text-muted">
84 {key.fingerprint}
85 </p>
86 </div>
87 <DeleteButton intent="delete-key" id={key.id} />
88 </li>
89 ))}
90 </ul>
91 <Form method="post" className="mt-4 space-y-3">
92 <input type="hidden" name="intent" value="add-key" />
93 <Field label="Title (optional)">
94 <Input name="title" maxLength={100} />
95 </Field>
96 <Field label="Public key">
97 <Input name="key" placeholder="ssh-ed25519 AAAA…" required />
98 </Field>
99 <ErrorText>{actionData?.keyError}</ErrorText>
100 <Button type="submit">Add SSH key</Button>
101 </Form>
102 </section>
103
104 <section>
105 <h2 className="font-medium">Access tokens</h2>
106 <p className="mt-1 text-sm text-muted">
107 Use a token as the password when git asks for one over HTTPS, and to
108 authenticate agents and the API.
109 </p>
110 {actionData?.newToken && (
111 <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4">
112 <p className="text-sm">Copy it now. It will not be shown again.</p>
113 <pre className="mt-2 overflow-x-auto font-mono text-sm text-accent">
114 {actionData.newToken}
115 </pre>
116 </div>
117 )}
118 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
119 {tokens.map((token) => (
120 <li key={token.id} className="flex items-center gap-4 px-4 py-3">
121 <p className="text-sm">{token.name}</p>
122 <DeleteButton intent="delete-token" id={token.id} />
123 </li>
124 ))}
125 </ul>
126 <Form method="post" className="mt-4 flex items-end gap-3">
127 <input type="hidden" name="intent" value="add-token" />
128 <div className="grow">
129 <Field label="Name">
130 <Input name="name" maxLength={100} placeholder="laptop" />
131 </Field>
132 </div>
133 <Button type="submit">Create token</Button>
134 </Form>
135 </section>
136 </main>
137 );
138}