Skip to content

g1t/apps/web/app/routes/settings/two-factor.tsx

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