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.

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