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.

214 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([
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/** Recovery codes, shown once, with a way to keep them. */
52function RecoveryCodes({ codes }: { codes: string[] }) {
53 const [copied, setCopied] = useState(false);
54 return (
55 <section className="rounded-xl border border-warn/40 bg-surface p-4" aria-labelledby="recovery-codes">
56 <h2 id="recovery-codes" className="flex items-center gap-2 text-sm font-medium">
57 <KeyRound size={16} className="text-warn" /> Save your recovery codes
58 </h2>
59 <p className="mt-1 text-sm text-muted">
60 Each one signs you in once if you lose your phone. They are shown only now: keep them in your password manager.
61 </p>
62 <ul className="mt-4 grid grid-cols-2 gap-2 font-mono text-sm sm:grid-cols-5">
63 {codes.map((code) => (
64 <li key={code} className="rounded-md border border-line bg-bg px-2 py-1.5 text-center">
65 {code}
66 </li>
67 ))}
68 </ul>
69 <Button
70 type="button" variant="link" size="inline" className="mt-4 text-sm underline" onClick={() => {
71 void navigator.clipboard.writeText(codes.join("\n"));
72 setCopied(true);
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>{" "}
101 {status.required_by.length === 1 ? "requires" : "require"} two-factor authentication. You cannot use{" "}
102 {status.required_by.length === 1 ? "it" : "them"} until you turn it on here.
103 </p>
104 </div>
105 )}
106
107 {status.enabled ? (
108 <>
109 <Card asChild className="p-4">
110 <section>
111 <div className="flex flex-wrap items-center gap-3">
112 <ShieldCheck size={18} className="text-success" />
113 <h2 className="grow text-sm font-medium">Authenticator app</h2>
114 <Badge tone="success">On</Badge>
115 </div>
116 <p className="mt-2 text-sm text-muted">
117 Signing in with your password asks for a code from your app too. Git over HTTPS takes an access token,
118 never your password.
119 {status.enabled_at && (
120 <>
121 {" "}
122 Turned on <TimeAgo at={status.enabled_at} />.
123 </>
124 )}
125 </p>
126 <p className="mt-2 text-sm text-muted">
127 {status.recovery_codes_left} of 10 recovery codes left.
128 </p>
129 <Form method="post" className="mt-4">
130 <input type="hidden" name="intent" value="two-factor-codes" />
131 <SubmitButton variant="outline" match={{ intent: "two-factor-codes" }} pending="Making codes…">
132 Make new recovery codes
133 </SubmitButton>
134 </Form>
135 </section>
136 </Card>
137
138 <Card asChild tone="plain" className="p-4">
139 <section>
140 <h2 className="text-sm font-medium">Turn off two-factor authentication</h2>
141 <p className="mt-1 text-sm text-muted">
142 {status.required_by.length > 0
143 ? `You would lose access to ${status.required_by.join(", ")} until you turn it on again.`
144 : "Your account is safer with it on."}
145 </p>
146 <Form method="post" className="mt-4 flex flex-col items-start gap-3">
147 <input type="hidden" name="intent" value="two-factor-disable" />
148 <TwoFactorCodeField label="Code from your app" recovery />
149 <SubmitButton variant="destructive" match={{ intent: "two-factor-disable" }} pending="Turning off…">
150 Turn off
151 </SubmitButton>
152 </Form>
153 <ErrorText>{actionData?.error}</ErrorText>
154 </section>
155 </Card>
156 </>
157 ) : setup ? (
158 <Card asChild className="p-4">
159 <section>
160 <h2 className="flex items-center gap-2 text-sm font-medium">
161 <Smartphone size={16} className="text-accent" /> Set up your authenticator app
162 </h2>
163 <ol className="mt-4 space-y-5 text-sm">
164 <li>
165 <p className="text-muted">1. Scan this with an authenticator app, such as 1Password, Google Authenticator or Authy.</p>
166 <div className="mt-3 inline-block rounded-lg bg-white p-2">
167 <QrCode value={setup.uri} label="QR code for your authenticator app" />
168 </div>
169 <p className="mt-3 text-muted">Or enter this key by hand:</p>
170 <div className="mt-2 max-w-sm">
171 <CopyLine text={grouped(setup.secret)} />
172 </div>
173 </li>
174 <li>
175 <p className="text-muted">2. Enter the six-digit code your app shows.</p>
176 <Form method="post" className="mt-3 flex flex-col items-start gap-3">
177 <input type="hidden" name="intent" value="two-factor-enable" />
178 <input type="hidden" name="secret" value={setup.secret} />
179 <input type="hidden" name="uri" value={setup.uri} />
180 <TwoFactorCodeField label="Code from your app" />
181 <SubmitButton match={{ intent: "two-factor-enable" }} pending="Checking…">
182 Turn on
183 </SubmitButton>
184 </Form>
185 <ErrorText>{actionData?.error}</ErrorText>
186 </li>
187 </ol>
188 </section>
189 </Card>
190 ) : (
191 <Card asChild className="p-4">
192 <section>
193 <div className="flex flex-wrap items-center gap-3">
194 <Smartphone size={18} className="text-faint" />
195 <h2 className="grow text-sm font-medium">Authenticator app</h2>
196 <Badge>Off</Badge>
197 </div>
198 <p className="mt-2 text-sm text-muted">
199 A code from an app on your phone, asked for each time you sign in with your password, so a stolen password
200 is not enough.
201 </p>
202 <Form method="post" className="mt-4">
203 <input type="hidden" name="intent" value="two-factor-start" />
204 <SubmitButton match={{ intent: "two-factor-start" }} pending="Starting…">
205 Set up
206 </SubmitButton>
207 </Form>
208 <ErrorText>{actionData?.error}</ErrorText>
209 </section>
210 </Card>
211 )}
212 </div>
213 );
214}