| 1 | import { Form, Link, redirect } from "react-router"; |
| 2 | |
| 3 | import type { Route } from "./+types/account"; |
| 4 | import { DeleteAccountAction } from "../../components/delete-account"; |
| 5 | import { DangerZone } from "../../components/danger-zone"; |
| 6 | import { DeveloperModeSwitch } from "../../components/developer-mode-switch"; |
| 7 | import { ThemeSwitch } from "../../components/theme-switch"; |
| 8 | import { ErrorText, SubmitButton } from "../../components/ui"; |
| 9 | import { githubSignIn } from "../../lib/github.server"; |
| 10 | import { page } from "../../lib/meta"; |
| 11 | import { accounts, privacy } from "../../lib/services.server"; |
| 12 | import { assertSameOrigin, clientOf, endSession, requireUser, sessionTokenOf } from "../../lib/session.server"; |
| 13 | |
| 14 | export function meta(args: Route.MetaArgs) { |
| 15 | return page(args, { title: "Account · Settings · g1t" }); |
| 16 | } |
| 17 | |
| 18 | export 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 | */ |
| 40 | export 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 | |
| 61 | export 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 | } |