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

346 lines14,051 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

API and MCP server, Rust identity service, registration, site redesign1import { identity } from "../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell2import { Form, Link } from "react-router";
Initial g1t: services, event bus, intents and attempts3
4import type { Route } from "./+types/settings";
Social cards for every page: og.g1t.sh5import { page } from "../lib/meta";
Workspace names and icons, and a component kit for every control6import { AvatarField } from "../components/avatar-field";
OAuth 2.1 sign-in for MCP clients and other applications7import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui";
Workspace names and icons, and a component kit for every control8import { readAvatarUpload } from "../lib/avatar-upload";
Initial g1t: services, event bus, intents and attempts9import { assertSameOrigin, requireUser } from "../lib/session.server";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains10import { ProfileSection } from "../components/profile-form";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look11import { InvitesSection } from "../components/invites-section";
12import { inviteAction, loadInvites } from "../lib/invites.server";
13import { EmailsSection, SecurityLogSection } from "../components/emails-section";
14import { EMAIL_INTENTS, emailAction, loadEmails } from "../lib/emails.server";
15import { GithubMark } from "../components/github";
16import { githubSignIn } from "../lib/github.server";
Initial g1t: services, event bus, intents and attempts17
Social cards for every page: og.g1t.sh18export function meta(args: Route.MetaArgs) {
19 return page(args, { title: "Settings · g1t" });
Initial g1t: services, event bus, intents and attempts20}
21
22export async function loader({ request, context }: Route.LoaderArgs) {
23 const user = requireUser(context, request);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look24 const [keys, tokens, applications, profile, github] = await Promise.all([
API and MCP server, Rust identity service, registration, site redesign25 identity.listSshKeys(user),
26 identity.listAccessTokens(user),
OAuth 2.1 sign-in for MCP clients and other applications27 identity.listOAuthGrants(user),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains28 identity.profile(user.username),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look29 githubSignIn.account(user).catch(() => null),
Initial g1t: services, event bus, intents and attempts30 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look31 const [invites, emails] = await Promise.all([loadInvites(user), loadEmails(user)]);
32 return { user, keys, tokens, applications, profile, github, invites, emails, origin: new URL(request.url).origin };
Initial g1t: services, event bus, intents and attempts33}
34
35export async function action({ request, context }: Route.ActionArgs) {
36 assertSameOrigin(request);
37 const user = requireUser(context, request);
38 const form = await request.formData();
39 const id = String(form.get("id") ?? "");
40
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41 // Email addresses (lib/emails.server.ts): sensitive changes may first ask
42 // for the password.
43 if ((EMAIL_INTENTS as readonly string[]).includes(String(form.get("intent")))) {
44 return { emailAction: await emailAction(user, form, request) };
45 }
46
Initial g1t: services, event bus, intents and attempts47 switch (form.get("intent")) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look48 // Invites: making and revoking them (lib/invites.server.ts).
49 case "create-invite":
50 case "revoke-invite": {
51 const done = await inviteAction(user, form);
52 return { inviteCreated: done?.inviteCreated, inviteError: done?.inviteError };
53 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains54 // Identity checks every field again, and the website most of all.
55 case "profile": {
56 const text = (name: string) => String(form.get(name) ?? "");
57 const result = await identity.updateProfile(user, {
58 name: text("name"),
59 bio: text("bio"),
60 location: text("location"),
61 website: text("website"),
62 pronouns: text("pronouns"),
63 });
64 return result.ok ? { profileSaved: true } : { profileError: result.error.message };
65 }
Workspace names and icons, and a component kit for every control66 // The picture: identity checks the image's bytes again.
67 case "avatar": {
68 const upload = await readAvatarUpload(form);
69 if ("error" in upload) return { avatarError: upload.error };
70 const result = await identity.setUserAvatar(user, upload.image);
71 return result.ok ? null : { avatarError: result.error.message };
72 }
73 case "remove-avatar": {
74 const result = await identity.setUserAvatar(user, null);
75 return result.ok ? null : { avatarError: result.error.message };
76 }
Initial g1t: services, event bus, intents and attempts77 case "add-key": {
API and MCP server, Rust identity service, registration, site redesign78 const result = await identity.addSshKey(
Initial g1t: services, event bus, intents and attempts79 user,
80 String(form.get("title") ?? ""),
81 String(form.get("key") ?? ""),
82 );
83 return result.ok ? null : { keyError: result.error.message };
84 }
85 case "delete-key":
API and MCP server, Rust identity service, registration, site redesign86 await identity.removeSshKey(user, id);
Initial g1t: services, event bus, intents and attempts87 return null;
88 case "add-token": {
API and MCP server, Rust identity service, registration, site redesign89 const created = await identity.createAccessToken(
Initial g1t: services, event bus, intents and attempts90 user,
Agents as a team: lifecycle, merge queue, billing and a new shell91 String(form.get("label") ?? ""),
Initial g1t: services, event bus, intents and attempts92 );
93 return { newToken: created.token };
94 }
95 case "delete-token":
API and MCP server, Rust identity service, registration, site redesign96 await identity.removeAccessToken(user, id);
Initial g1t: services, event bus, intents and attempts97 return null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look98 case "unlink-github": {
99 const result = await githubSignIn.unlink(user);
100 return result.ok ? null : { githubError: result.error.message };
101 }
OAuth 2.1 sign-in for MCP clients and other applications102 case "sign-out-application":
103 await identity.revokeOAuthGrant(user, id);
104 return null;
Initial g1t: services, event bus, intents and attempts105 }
106 return null;
107}
108
OAuth 2.1 sign-in for MCP clients and other applications109function DeleteButton({
110 intent,
111 id,
112 label = "Delete",
113}: {
114 intent: string;
115 id: string;
116 label?: string;
117}) {
Initial g1t: services, event bus, intents and attempts118 return (
119 <Form method="post" className="ml-auto">
120 <input type="hidden" name="intent" value={intent} />
121 <input type="hidden" name="id" value={id} />
122 <Button variant="quiet" type="submit">
OAuth 2.1 sign-in for MCP clients and other applications123 {label}
Initial g1t: services, event bus, intents and attempts124 </Button>
125 </Form>
126 );
127}
128
129export default function Settings({
130 loaderData,
131 actionData,
132}: Route.ComponentProps) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look133 const { user, keys, tokens, applications, profile, github, invites, emails, origin } = loaderData;
Initial g1t: services, event bus, intents and attempts134 return (
Account settings in the same shape as a workspace's135 <main className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
136 <header className="mb-8 border-b border-line pb-6">
137 <h1 className="text-2xl font-semibold tracking-tight">Account</h1>
138 <p className="mt-1.5 text-sm text-muted">
139 How <span className="font-mono text-fg">{user.username}</span> signs in from git, tools and agents.
140 </p>
141 </header>
142 <div className="max-w-2xl space-y-12">
Workspace names and icons, and a component kit for every control143 <section id="picture" className="scroll-mt-20">
144 <h2 className="font-medium">Picture</h2>
145 <div className="mt-4">
146 <AvatarField
147 name={user.username}
148 image={user.avatar}
149 error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
150 about="Shown beside your username in the sidebar and on mission control. Without one, g1t draws your first letter."
151 />
152 </div>
153 </section>
Agents and memory, checks and conflicts, profiles, slug renames, custom domains154 <ProfileSection
155 username={user.username}
156 profile={profile}
157 error={actionData && "profileError" in actionData ? actionData.profileError : undefined}
158 saved={Boolean(actionData && "profileSaved" in actionData)}
159 />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look160 <EmailsSection
161 data={emails.emails}
162 actionData={actionData && "emailAction" in actionData ? actionData.emailAction : undefined}
163 hasPassword={github?.hasPassword ?? true}
164 />
165 {github?.enabled && (
166 <section id="github" className="scroll-mt-20">
167 <h2 className="font-medium">GitHub</h2>
168 <p className="mt-1 text-sm text-muted">
169 Sign in with your GitHub account, and import or mirror repositories you can reach there.
170 </p>
171 {github.account ? (
172 <div className="mt-4 flex items-center gap-4 rounded-md border border-line px-4 py-3">
173 <GithubMark className="size-5 shrink-0" />
174 <div className="min-w-0">
175 <p className="text-sm">
176 Linked to <span className="font-mono">@{github.account.login}</span>
177 </p>
178 <p className="text-xs text-faint">
179 Since <TimeAgo at={github.account.linkedAt} />
180 {!github.account.authorized && " · access ended: link it again to import repositories"}
181 </p>
182 </div>
183 <div className="ml-auto flex items-center gap-2">
184 {!github.account.authorized && (
185 <a href="/auth/github?link=1" className="text-sm text-muted hover:text-fg">
186 Link again
187 </a>
188 )}
189 <Form method="post">
190 <input type="hidden" name="intent" value="unlink-github" />
191 <Button variant="quiet" type="submit" disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}>
192 Unlink
193 </Button>
194 </Form>
195 </div>
196 </div>
197 ) : (
198 <div className="mt-4">
199 <a
200 href="/auth/github?link=1"
201 className="inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/90 hover:border-line-strong hover:bg-surface hover:text-fg"
202 >
203 <GithubMark /> Link GitHub
204 </a>
205 </div>
206 )}
207 {github.account && !github.hasPassword && (
208 <p className="mt-2 text-xs text-faint">
209 GitHub is the only way you sign in, so it cannot be unlinked yet. Sign out and use Forgot your password to set one.
210 </p>
211 )}
212 <ErrorText>{actionData && "githubError" in actionData ? actionData.githubError : undefined}</ErrorText>
213 </section>
214 )}
215 <InvitesSection
216 overview={invites}
217 origin={origin}
218 created={actionData && "inviteCreated" in actionData ? actionData.inviteCreated : undefined}
219 error={actionData && "inviteError" in actionData ? actionData.inviteError : undefined}
220 />
Account settings in the same shape as a workspace's221 <section id="ssh-keys" className="scroll-mt-20">
Initial g1t: services, event bus, intents and attempts222 <h2 className="font-medium">SSH keys</h2>
223 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
224 {keys.map((key) => (
225 <li key={key.id} className="flex items-center gap-4 px-4 py-3">
226 <div className="min-w-0">
227 <p className="text-sm">{key.title}</p>
228 <p className="truncate font-mono text-xs text-muted">
229 {key.fingerprint}
230 </p>
231 </div>
232 <DeleteButton intent="delete-key" id={key.id} />
233 </li>
234 ))}
235 </ul>
236 <Form method="post" className="mt-4 space-y-3">
237 <input type="hidden" name="intent" value="add-key" />
238 <Field label="Title (optional)">
239 <Input name="title" maxLength={100} />
240 </Field>
241 <Field label="Public key">
242 <Input name="key" placeholder="ssh-ed25519 AAAA…" required />
243 </Field>
244 <ErrorText>{actionData?.keyError}</ErrorText>
245 <Button type="submit">Add SSH key</Button>
246 </Form>
247 </section>
248
Account settings in the same shape as a workspace's249 <section id="tokens" className="scroll-mt-20">
Initial g1t: services, event bus, intents and attempts250 <h2 className="font-medium">Access tokens</h2>
251 <p className="mt-1 text-sm text-muted">
252 Use a token as the password when git asks for one over HTTPS, and to
Agents as a team: lifecycle, merge queue, billing and a new shell253 authenticate agents and the API. A token here acts as you.
Initial g1t: services, event bus, intents and attempts254 </p>
Agents as a team: lifecycle, merge queue, billing and a new shell255 {(user.workspaces ?? []).length > 0 && (
256 <p className="mt-1 text-sm text-muted">
257 For CI and integrations that work for a team, use a workspace's
258 own tokens instead:{" "}
259 {(user.workspaces ?? []).map((membership, i) => (
260 <span key={membership.slug}>
261 {i > 0 && ", "}
262 <Link
263 to={`/${membership.slug}/-/tokens`}
264 className="font-mono text-fg underline underline-offset-4"
265 >
266 {membership.slug}
267 </Link>
268 </span>
269 ))}
270 .
271 </p>
272 )}
Initial g1t: services, event bus, intents and attempts273 {actionData?.newToken && (
274 <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4">
275 <p className="text-sm">Copy it now. It will not be shown again.</p>
276 <pre className="mt-2 overflow-x-auto font-mono text-sm text-accent">
277 {actionData.newToken}
278 </pre>
279 </div>
280 )}
281 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
282 {tokens.map((token) => (
283 <li key={token.id} className="flex items-center gap-4 px-4 py-3">
Agents as a team: lifecycle, merge queue, billing and a new shell284 <div className="min-w-0">
285 <p className="truncate text-sm">{token.name}</p>
286 <p className="text-xs text-faint">
287 Created <TimeAgo at={token.createdAt} /> ·{" "}
288 {token.lastUsedAt ? (
289 <>
290 last used <TimeAgo at={token.lastUsedAt} />
291 </>
292 ) : (
293 "never used"
294 )}
295 </p>
296 </div>
Initial g1t: services, event bus, intents and attempts297 <DeleteButton intent="delete-token" id={token.id} />
298 </li>
299 ))}
300 </ul>
301 <Form method="post" className="mt-4 flex items-end gap-3">
302 <input type="hidden" name="intent" value="add-token" />
303 <div className="grow">
304 <Field label="Name">
Agents as a team: lifecycle, merge queue, billing and a new shell305 <Input name="label" maxLength={100} placeholder="laptop" />
Initial g1t: services, event bus, intents and attempts306 </Field>
307 </div>
308 <Button type="submit">Create token</Button>
309 </Form>
310 </section>
OAuth 2.1 sign-in for MCP clients and other applications311
Account settings in the same shape as a workspace's312 <section id="applications" className="scroll-mt-20">
OAuth 2.1 sign-in for MCP clients and other applications313 <h2 className="font-medium">Connected applications</h2>
314 <p className="mt-1 text-sm text-muted">
315 Applications you signed in to through your browser, such as an agent
316 connected to the g1t MCP server. Signing one out ends its access at
317 once.
318 </p>
319 {applications.length === 0 ? (
320 <p className="mt-4 text-sm text-faint">None yet.</p>
321 ) : (
322 <ul className="mt-4 divide-y divide-line rounded-md border border-line">
323 {applications.map((application) => (
324 <li key={application.id} className="flex items-center gap-4 px-4 py-3">
325 <div>
326 <p className="text-sm">{application.clientName}</p>
327 <p className="text-xs text-faint">
328 Connected <TimeAgo at={application.createdAt} /> · last used{" "}
329 <TimeAgo at={application.lastUsedAt} />
330 </p>
331 </div>
332 <DeleteButton
333 intent="sign-out-application"
334 id={application.id}
335 label="Sign out"
336 />
337 </li>
338 ))}
339 </ul>
340 )}
341 </section>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look342 <SecurityLogSection log={emails.log} />
Account settings in the same shape as a workspace's343 </div>
Initial g1t: services, event bus, intents and attempts344 </main>
345 );
346}