Skip to content

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

116 linesCodeBlameRaw
1import { Form, Link, redirect } from "react-router";
2
3import type { Route } from "./+types/account";
4import { DeleteAccountAction } from "../../components/delete-account";
5import { DangerZone } from "../../components/danger-zone";
6import { DeveloperModeSwitch } from "../../components/developer-mode-switch";
7import { ThemeSwitch } from "../../components/theme-switch";
8import { ErrorText, SubmitButton } from "../../components/ui";
9import { githubSignIn } from "../../lib/github.server";
10import { page } from "../../lib/meta";
11import { accounts, privacy } from "../../lib/services.server";
12import { assertSameOrigin, clientOf, endSession, requireUser, sessionTokenOf } from "../../lib/session.server";
13
14export function meta(args: Route.MetaArgs) {
15 return page(args, { title: "Account · Settings · g1t" });
16}
17
18export async function loader({ request, context }: Route.LoaderArgs) {
19 const user = requireUser(context, request);
20 // What deleting it would take, and anything in the way, shown before
21 // anyone types: workspaces you own alone, each with what billing needs.
22 const [deletion, github, erasure] = await Promise.all([
23 accounts.checkAccountDeletion(user).catch(() => null),
24 githubSignIn.account(user).catch(() => null),
25 privacy.mine(user).catch(() => undefined),
26 ]);
27 return {
28 erasure,
29 username: user.username,
30 deletion: deletion?.ok ? deletion.value : null,
31 hasPassword: github?.hasPassword ?? true,
32 };
33}
34
35/**
36 * Deleting the account: identity checks the person, the typed username,
37 * the proof that it is them, protection and the workspaces they own alone.
38 * Once it is deleted, this browser is signed out and sent home.
39 */
40export async function action({ request, context }: Route.ActionArgs) {
41 assertSameOrigin(request);
42 const user = requireUser(context, request);
43 const form = await request.formData();
44 if (form.get("intent") === "request-erasure") {
45 // Identity unreachable is not a refusal: say so, and where else to ask.
46 const result = await privacy.request(user).catch(() => null);
47 if (!result) return { erasureError: "We could not record your request just now. Try again, or email support@g1t.sh." };
48 return result.ok ? { erasureNotice: `Request ${result.value.id} received. We will respond by ${result.value.due_at.slice(0, 10)}.` } : { erasureError: result.error.message };
49 }
50 if (form.get("intent") !== "delete-account") return null;
51 const password = String(form.get("password") ?? "");
52 const result = await accounts.deleteAccount(user, String(form.get("confirm") ?? ""), {
53 sessionToken: sessionTokenOf(request),
54 password: password || null,
55 client: clientOf(request),
56 });
57 if (!result.ok) return { deleteError: result.error.message, reauth: result.error.code === "reauth_required" };
58 throw redirect("/", { headers: { "set-cookie": await endSession(request) } });
59}
60
61export default function AccountSettings({ loaderData, actionData }: Route.ComponentProps) {
62 const { username, deletion, hasPassword } = loaderData;
63 return (
64 <div className="space-y-8">
65 <section aria-labelledby="username-heading">
66 <h2 id="username-heading" className="font-medium">
67 Username
68 </h2>
69 <p className="mt-1 text-sm text-muted">
70 You are <span className="font-mono text-fg">{username}</span>. Your profile is at{" "}
71 <Link to={`/${username}`} className="text-accent underline-offset-4 hover:underline">
72 g1t.sh/{username}
73 </Link>
74 .
75 </p>
76 </section>
77 <section aria-labelledby="appearance-heading">
78 <h2 id="appearance-heading" className="font-medium">
79 Appearance
80 </h2>
81 <p className="mt-1 text-sm text-muted">
82 Light, dark, or Auto to follow your system. This browser remembers it.
83 </p>
84 <ThemeSwitch className="mt-3 w-full max-w-xs" />
85 </section>
86 <section id="developer" aria-labelledby="developer-heading" className="scroll-mt-20">
87 <h2 id="developer-heading" className="font-medium">
88 Developer
89 </h2>
90 <p className="mt-1 text-sm text-muted">For reporting what went wrong when something breaks.</p>
91 <div className="mt-3">
92 <DeveloperModeSwitch />
93 </div>
94 </section>
95 <section aria-labelledby="erasure-heading" className="space-y-3">
96 <h2 id="erasure-heading" className="font-medium">Erase my personal data</h2>
97 <p className="text-sm text-muted">Request erasure even if workspace ownership or billing prevents account closure. We review shared content and any records we must retain, then explain the outcome. This request does not immediately delete your account.</p>
98 {loaderData.erasure ? <p className="text-sm">Request {loaderData.erasure.id}: {loaderData.erasure.status}. Response due {loaderData.erasure.due_at.slice(0, 10)}.</p> : loaderData.erasure === undefined ? <p className="text-sm">Request status is unavailable. Try again or email <a href="mailto:support@g1t.sh" className="underline">support@g1t.sh</a>.</p> : null}
99 {actionData?.erasureNotice && <p role="status" className="text-sm">{actionData.erasureNotice}</p>}
100 <ErrorText>{actionData?.erasureError}</ErrorText>
101 {(!loaderData.erasure || ["completed", "refused"].includes(loaderData.erasure.status)) && <Form method="post">
102 <SubmitButton name="intent" value="request-erasure" pending="Recording request...">Request erasure</SubmitButton>
103 </Form>}
104 <p className="text-sm text-muted">For specific content, a request without signing in, or a question about an existing request, email <a href="mailto:support@g1t.sh" className="underline">support@g1t.sh</a>.</p>
105 </section>
106 <DangerZone>
107 <DeleteAccountAction
108 username={username}
109 deletion={deletion}
110 hasPassword={hasPassword}
111 error={actionData?.deleteError}
112 />
113 </DangerZone>
114 </div>
115 );
116}