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

211 lines7,361 bytesCodeBlame
1import { identity } from "../lib/services.server";
2import { Form, Link } from "react-router";
3
4import type { Route } from "./+types/settings";
5import { Button, ErrorText, Field, Input, TimeAgo } 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, applications] = await Promise.all([
15 identity.listSshKeys(user),
16 identity.listAccessTokens(user),
17 identity.listOAuthGrants(user),
18 ]);
19 return { user, keys, tokens, applications };
20}
21
22export async function action({ request, context }: Route.ActionArgs) {
23 assertSameOrigin(request);
24 const user = requireUser(context, request);
25 const form = await request.formData();
26 const id = String(form.get("id") ?? "");
27
28 switch (form.get("intent")) {
29 case "add-key": {
30 const result = await identity.addSshKey(
31 user,
32 String(form.get("title") ?? ""),
33 String(form.get("key") ?? ""),
34 );
35 return result.ok ? null : { keyError: result.error.message };
36 }
37 case "delete-key":
38 await identity.removeSshKey(user, id);
39 return null;
40 case "add-token": {
41 const created = await identity.createAccessToken(
42 user,
43 String(form.get("label") ?? ""),
44 );
45 return { newToken: created.token };
46 }
47 case "delete-token":
48 await identity.removeAccessToken(user, id);
49 return null;
50 case "sign-out-application":
51 await identity.revokeOAuthGrant(user, id);
52 return null;
53 }
54 return null;
55}
56
57function DeleteButton({
58 intent,
59 id,
60 label = "Delete",
61}: {
62 intent: string;
63 id: string;
64 label?: string;
65}) {
66 return (
67 <Form method="post" className="ml-auto">
68 <input type="hidden" name="intent" value={intent} />
69 <input type="hidden" name="id" value={id} />
70 <Button variant="quiet" type="submit">
71 {label}
72 </Button>
73 </Form>
74 );
75}
76
77export default function Settings({
78 loaderData,
79 actionData,
80}: Route.ComponentProps) {
81 const { user, keys, tokens, applications } = loaderData;
82 return (
83 <main className="mx-auto max-w-2xl space-y-12 px-4 py-12">
84 <h1 className="text-xl font-semibold">
85 Settings <span className="font-mono text-muted">{user.username}</span>
86 </h1>
87
88 <section>
89 <h2 className="font-medium">SSH keys</h2>
90 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
91 {keys.map((key) => (
92 <li key={key.id} className="flex items-center gap-4 px-4 py-3">
93 <div className="min-w-0">
94 <p className="text-sm">{key.title}</p>
95 <p className="truncate font-mono text-xs text-muted">
96 {key.fingerprint}
97 </p>
98 </div>
99 <DeleteButton intent="delete-key" id={key.id} />
100 </li>
101 ))}
102 </ul>
103 <Form method="post" className="mt-4 space-y-3">
104 <input type="hidden" name="intent" value="add-key" />
105 <Field label="Title (optional)">
106 <Input name="title" maxLength={100} />
107 </Field>
108 <Field label="Public key">
109 <Input name="key" placeholder="ssh-ed25519 AAAA…" required />
110 </Field>
111 <ErrorText>{actionData?.keyError}</ErrorText>
112 <Button type="submit">Add SSH key</Button>
113 </Form>
114 </section>
115
116 <section>
117 <h2 className="font-medium">Access tokens</h2>
118 <p className="mt-1 text-sm text-muted">
119 Use a token as the password when git asks for one over HTTPS, and to
120 authenticate agents and the API. A token here acts as you.
121 </p>
122 {(user.workspaces ?? []).length > 0 && (
123 <p className="mt-1 text-sm text-muted">
124 For CI and integrations that work for a team, use a workspace's
125 own tokens instead:{" "}
126 {(user.workspaces ?? []).map((membership, i) => (
127 <span key={membership.slug}>
128 {i > 0 && ", "}
129 <Link
130 to={`/${membership.slug}/-/tokens`}
131 className="font-mono text-fg underline underline-offset-4"
132 >
133 {membership.slug}
134 </Link>
135 </span>
136 ))}
137 .
138 </p>
139 )}
140 {actionData?.newToken && (
141 <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4">
142 <p className="text-sm">Copy it now. It will not be shown again.</p>
143 <pre className="mt-2 overflow-x-auto font-mono text-sm text-accent">
144 {actionData.newToken}
145 </pre>
146 </div>
147 )}
148 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
149 {tokens.map((token) => (
150 <li key={token.id} className="flex items-center gap-4 px-4 py-3">
151 <div className="min-w-0">
152 <p className="truncate text-sm">{token.name}</p>
153 <p className="text-xs text-faint">
154 Created <TimeAgo at={token.createdAt} /> ·{" "}
155 {token.lastUsedAt ? (
156 <>
157 last used <TimeAgo at={token.lastUsedAt} />
158 </>
159 ) : (
160 "never used"
161 )}
162 </p>
163 </div>
164 <DeleteButton intent="delete-token" id={token.id} />
165 </li>
166 ))}
167 </ul>
168 <Form method="post" className="mt-4 flex items-end gap-3">
169 <input type="hidden" name="intent" value="add-token" />
170 <div className="grow">
171 <Field label="Name">
172 <Input name="label" maxLength={100} placeholder="laptop" />
173 </Field>
174 </div>
175 <Button type="submit">Create token</Button>
176 </Form>
177 </section>
178
179 <section>
180 <h2 className="font-medium">Connected applications</h2>
181 <p className="mt-1 text-sm text-muted">
182 Applications you signed in to through your browser, such as an agent
183 connected to the g1t MCP server. Signing one out ends its access at
184 once.
185 </p>
186 {applications.length === 0 ? (
187 <p className="mt-4 text-sm text-faint">None yet.</p>
188 ) : (
189 <ul className="mt-4 divide-y divide-line rounded-md border border-line">
190 {applications.map((application) => (
191 <li key={application.id} className="flex items-center gap-4 px-4 py-3">
192 <div>
193 <p className="text-sm">{application.clientName}</p>
194 <p className="text-xs text-faint">
195 Connected <TimeAgo at={application.createdAt} /> · last used{" "}
196 <TimeAgo at={application.lastUsedAt} />
197 </p>
198 </div>
199 <DeleteButton
200 intent="sign-out-application"
201 id={application.id}
202 label="Sign out"
203 />
204 </li>
205 ))}
206 </ul>
207 )}
208 </section>
209 </main>
210 );
211}