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.

209 linesCodeBlameRaw
1import { KeyRound, ShieldAlert, ShieldCheck, Smartphone } from "lucide-react";
2import { useState } from "react";
3import { Form } from "react-router";
4
5import type { Route } from "./+types/two-factor";
6import { ConfirmItIsYou } from "../../components/emails-section";
7import { TwoFactorCodeField } from "../../components/one-time-code";
8import { QrCode } from "../../components/qr-code";
9import { CopyLine, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
10import { Badge } from "../../components/ui/badge";
11import { Button } from "../../components/ui/button";
12import { Card } from "../../components/ui/card";
13import { Unavailable } from "../../components/boundary";
14import { githubSignIn } from "../../lib/github.server";
15import { page } from "../../lib/meta";
16import { accounts } from "../../lib/services.server";
17import { assertSameOrigin, requireUser } from "../../lib/session.server";
18import { TWO_FACTOR_INTENTS, twoFactorAction } from "../../lib/two-factor.server";
19
20export function meta(args: Route.MetaArgs) {
21 return page(args, { title: "Two-factor authentication · Settings · g1t" });
22}
23
24export async function loader({ request, context }: Route.LoaderArgs) {
25 const user = requireUser(context, request);
26 const [status, github] = await Promise.all([accounts.twoFactorStatus(user).catch(() => null), githubSignIn.account(user).catch(() => null)]);
27 return {
28 status: status?.ok ? status.value : null,
29 hasPassword: github?.hasPassword ?? true,
30 };
31}
32
33export async function action({ request, context }: Route.ActionArgs) {
34 assertSameOrigin(request);
35 const user = requireUser(context, request);
36 const form = await request.formData();
37 if ((TWO_FACTOR_INTENTS as readonly string[]).includes(String(form.get("intent")))) {
38 return (await twoFactorAction(user, form, request)) ?? null;
39 }
40 return null;
41}
42
43/** A base32 secret in groups of four, as people type it. */
44function grouped(secret: string): string {
45 return secret.match(/.{1,4}/g)?.join(" ") ?? secret;
46}
47
48/** Recovery codes, shown once, with a way to keep them. */
49function RecoveryCodes({ codes }: { codes: string[] }) {
50 const [copied, setCopied] = useState(false);
51 return (
52 <section className="rounded-xl border border-warn/40 bg-surface p-4" aria-labelledby="recovery-codes">
53 <h2 id="recovery-codes" className="flex items-center gap-2 text-sm font-medium">
54 <KeyRound size={16} className="text-warn" /> Save your recovery codes
55 </h2>
56 <p className="mt-1 text-sm text-muted">Each one signs you in once if you lose your phone. They are shown only now: keep them in your password manager.</p>
57 <ul className="mt-4 grid grid-cols-2 gap-2 font-mono text-sm sm:grid-cols-5">
58 {codes.map((code) => (
59 <li key={code} className="rounded-md border border-line bg-bg px-2 py-1.5 text-center">
60 {code}
61 </li>
62 ))}
63 </ul>
64 <Button
65 type="button"
66 variant="link"
67 size="inline"
68 className="mt-4 text-sm underline"
69 onClick={() => {
70 void navigator.clipboard.writeText(codes.join("\n"));
71 setCopied(true);
72 }}
73 >
74 {copied ? "Copied" : "Copy all"}
75 </Button>
76 </section>
77 );
78}
79
80export default function TwoFactorSettings({ loaderData, actionData }: Route.ComponentProps) {
81 const { status, hasPassword } = loaderData;
82 const setup = actionData?.setup;
83 if (!status) {
84 return <Unavailable what="Two-factor authentication" />;
85 }
86 return (
87 <div className="space-y-6">
88 {actionData?.reauth && <ConfirmItIsYou pending={actionData.reauth} hasPassword={hasPassword} />}
89 {actionData?.codes && <RecoveryCodes codes={actionData.codes} />}
90 {actionData?.notice && !actionData.codes && (
91 <p role="status" className="text-sm text-muted">
92 {actionData.notice}
93 </p>
94 )}
95
96 {status.required_by.length > 0 && !status.enabled && (
97 <div role="alert" className="flex gap-3 rounded-xl border border-warn/40 bg-surface p-4 text-sm">
98 <ShieldAlert size={16} className="mt-0.5 shrink-0 text-warn" />
99 <p>
100 <span className="font-medium">{status.required_by.join(", ")}</span> {status.required_by.length === 1 ? "requires" : "require"} two-factor
101 authentication. You cannot use {status.required_by.length === 1 ? "it" : "them"} until you turn it on here.
102 </p>
103 </div>
104 )}
105
106 {status.enabled ? (
107 <>
108 <Card asChild className="p-4">
109 <section>
110 <div className="flex flex-wrap items-center gap-3">
111 <ShieldCheck size={18} className="text-success" />
112 <h2 className="grow text-sm font-medium">Authenticator app</h2>
113 <Badge tone="success">On</Badge>
114 </div>
115 <p className="mt-2 text-sm text-muted">
116 Signing in with your password asks for a code from your app too. Git over HTTPS takes an access token, never your password.
117 {status.enabled_at && (
118 <>
119 {" "}
120 Turned on <TimeAgo at={status.enabled_at} />.
121 </>
122 )}
123 </p>
124 <p className="mt-2 text-sm text-muted">{status.recovery_codes_left} of 10 recovery codes left.</p>
125 <Form method="post" className="mt-4">
126 <input type="hidden" name="intent" value="two-factor-codes" />
127 <SubmitButton variant="outline" match={{ intent: "two-factor-codes" }} pending="Making codes…">
128 Make new recovery codes
129 </SubmitButton>
130 </Form>
131 </section>
132 </Card>
133
134 <Card asChild tone="plain" className="p-4">
135 <section>
136 <h2 className="text-sm font-medium">Turn off two-factor authentication</h2>
137 <p className="mt-1 text-sm text-muted">
138 {status.required_by.length > 0
139 ? `You would lose access to ${status.required_by.join(", ")} until you turn it on again.`
140 : "Your account is safer with it on."}
141 </p>
142 <Form method="post" className="mt-4 flex flex-col items-start gap-3">
143 <input type="hidden" name="intent" value="two-factor-disable" />
144 <TwoFactorCodeField label="Code from your app" recovery />
145 <SubmitButton variant="destructive" match={{ intent: "two-factor-disable" }} pending="Turning off…">
146 Turn off
147 </SubmitButton>
148 </Form>
149 <ErrorText>{actionData?.error}</ErrorText>
150 </section>
151 </Card>
152 </>
153 ) : setup ? (
154 <Card asChild className="p-4">
155 <section>
156 <h2 className="flex items-center gap-2 text-sm font-medium">
157 <Smartphone size={16} className="text-accent" /> Set up your authenticator app
158 </h2>
159 <ol className="mt-4 space-y-5 text-sm">
160 <li>
161 <p className="text-muted">1. Scan this with an authenticator app, such as 1Password, Google Authenticator or Authy.</p>
162 <div className="mt-3 inline-block rounded-lg bg-white p-2">
163 <QrCode value={setup.uri} label="QR code for your authenticator app" />
164 </div>
165 <p className="mt-3 text-muted">Or enter this key by hand:</p>
166 <div className="mt-2 max-w-sm">
167 <CopyLine text={grouped(setup.secret)} />
168 </div>
169 </li>
170 <li>
171 <p className="text-muted">2. Enter the six-digit code your app shows.</p>
172 <Form method="post" className="mt-3 flex flex-col items-start gap-3">
173 <input type="hidden" name="intent" value="two-factor-enable" />
174 <input type="hidden" name="secret" value={setup.secret} />
175 <input type="hidden" name="uri" value={setup.uri} />
176 <TwoFactorCodeField label="Code from your app" />
177 <SubmitButton match={{ intent: "two-factor-enable" }} pending="Checking…">
178 Turn on
179 </SubmitButton>
180 </Form>
181 <ErrorText>{actionData?.error}</ErrorText>
182 </li>
183 </ol>
184 </section>
185 </Card>
186 ) : (
187 <Card asChild className="p-4">
188 <section>
189 <div className="flex flex-wrap items-center gap-3">
190 <Smartphone size={18} className="text-faint" />
191 <h2 className="grow text-sm font-medium">Authenticator app</h2>
192 <Badge>Off</Badge>
193 </div>
194 <p className="mt-2 text-sm text-muted">
195 A code from an app on your phone, asked for each time you sign in with your password, so a stolen password is not enough.
196 </p>
197 <Form method="post" className="mt-4">
198 <input type="hidden" name="intent" value="two-factor-start" />
199 <SubmitButton match={{ intent: "two-factor-start" }} pending="Starting…">
200 Set up
201 </SubmitButton>
202 </Form>
203 <ErrorText>{actionData?.error}</ErrorText>
204 </section>
205 </Card>
206 )}
207 </div>
208 );
209}