Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 1 | import { KeyRound, ShieldAlert, ShieldCheck, Smartphone } from "lucide-react"; |
| 2 | import { useState } from "react"; | |
| 3 | import { Form } from "react-router"; | |
| 4 | ||
| 5 | import type { Route } from "./+types/two-factor"; | |
| 6 | import { ConfirmItIsYou } from "../../components/emails-section"; | |
| 7 | import { QrCode } from "../../components/qr-code"; | |
| 8 | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 9 | import { Badge } from "../../components/ui/badge"; | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 10 | import { Button } from "../../components/ui/button"; |
| 11 | import { Card } from "../../components/ui/card"; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 12 | import { githubSignIn } from "../../lib/github.server"; |
| 13 | import { page } from "../../lib/meta"; | |
| 14 | import { accounts } from "../../lib/services.server"; | |
| 15 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 16 | import { TWO_FACTOR_INTENTS, twoFactorAction } from "../../lib/two-factor.server"; | |
| 17 | ||
| 18 | export function meta(args: Route.MetaArgs) { | |
| 19 | return page(args, { title: "Two-factor authentication · Settings · g1t" }); | |
| 20 | } | |
| 21 | ||
| 22 | export 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 | ||
| 34 | export 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. */ | |
| 45 | function grouped(secret: string): string { | |
| 46 | return secret.match(/.{1,4}/g)?.join(" ") ?? secret; | |
| 47 | } | |
| 48 | ||
| 49 | /** The six digits an app shows, typed. */ | |
| 50 | function 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. */ | |
| 67 | function 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> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 84 | <Button |
| 85 | type="button" variant="link" size="inline" className="mt-4 text-sm underline" onClick={() => { | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 86 | void navigator.clipboard.writeText(codes.join("\n")); |
| 87 | setCopied(true); | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 88 | }}> |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 89 | {copied ? "Copied" : "Copy all"} |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 90 | </Button> |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 91 | </section> |
| 92 | ); | |
| 93 | } | |
| 94 | ||
| 95 | export 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 | <> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 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> | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 212 | <div className="flex flex-wrap items-center gap-3"> |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 213 | <Smartphone size={18} className="text-faint" /> |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 214 | <h2 className="grow text-sm font-medium">Authenticator app</h2> |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 215 | <Badge>Off</Badge> |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 216 | </div> |
| 217 | <p className="mt-2 text-sm text-muted"> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 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. | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 220 | </p> |
| 221 | <Form method="post" className="mt-4"> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 222 | <input type="hidden" name="intent" value="two-factor-start" /> |
| 223 | <SubmitButton match={{ intent: "two-factor-start" }} pending="Starting…"> | |
| 224 | Set up | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 225 | </SubmitButton> |
| 226 | </Form> | |
| 227 | <ErrorText>{actionData?.error}</ErrorText> | |
| 228 | </section> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 229 | </Card> |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 230 | )} |
| 231 | </div> | |
| 232 | ); | |
| 233 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.