flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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