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