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.

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