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

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