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.

250 linesCodeBlameRaw
1import { Mail, ShieldCheck } from "lucide-react";
2import { Form } from "react-router";
3
4import { type AccountEmails, type SecurityEvent, securityEventLabel } from "@g1t/contracts";
5
6import { addressActions, backupChoices } from "../lib/emails";
7import type { EmailActionData } from "../lib/emails.server";
8import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
9import { Badge } from "./ui/badge";
10import { SelectField } from "./ui/select";
11import { SwitchCard } from "./ui/switch";
12
13/** One small form posting one intent about one address. */
14function AddressButton({ intent, email, label, pending }: { intent: string; email: string; label: string; pending: string }) {
15 return (
16 <Form method="post">
17 <input type="hidden" name="intent" value={intent} />
18 <input type="hidden" name="email" value={email} />
19 <SubmitButton variant="outline" pending={pending} match={{ intent, email }}>
20 {label}
21 </SubmitButton>
22 </Form>
23 );
24}
25
26/** What a waiting change does, for the line that asks for the password. */
27function describe(intent: string): string {
28 switch (intent) {
29 case "add-email":
30 return "adding";
31 case "remove-email":
32 return "removing";
33 case "primary-email":
34 return "making primary";
35 default:
36 return "for";
37 }
38}
39
40/** Asks for the password before a sensitive change goes through. */
41export function ConfirmItIsYou({ pending, hasPassword }: { pending: NonNullable<EmailActionData>["reauth"]; hasPassword: boolean }) {
42 if (!pending) return null;
43 return (
44 <div role="alert" className="rounded-xl border border-accent/40 bg-surface p-4">
45 <p className="flex items-center gap-2 text-sm font-medium">
46 <ShieldCheck size={16} className="text-accent" /> Confirm it is you
47 </p>
48 {hasPassword ? (
49 <Form method="post" className="mt-3 space-y-3">
50 <p className="text-sm text-muted">
51 {pending.message}
52 {pending.fields.email ? (
53 <>
54 {" "}
55 ({describe(pending.intent)} <span className="text-fg">{pending.fields.email}</span>)
56 </>
57 ) : null}
58 </p>
59 <input type="hidden" name="intent" value={pending.intent} />
60 {Object.entries(pending.fields).map(([name, value]) => (
61 <input key={name} type="hidden" name={name} value={value} />
62 ))}
63 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
64 <div className="grow">
65 <Field label="Password">
66 <Input name="password" type="password" autoComplete="current-password" required autoFocus />
67 </Field>
68 </div>
69 <SubmitButton pending="Confirming…" match={{ intent: pending.intent }}>
70 Confirm
71 </SubmitButton>
72 </div>
73 <p className="text-xs text-faint">You will not be asked again for 10 minutes.</p>
74 </Form>
75 ) : (
76 <p className="mt-2 text-sm text-muted">
77 Your account signs in with GitHub only. Sign out, sign in with GitHub again, and make this change within 10 minutes. Or set a password with Forgot
78 your password on the sign-in page.
79 </p>
80 )}
81 </div>
82 );
83}
84
85/** The answer to one form's post, under that form: what went wrong, or that it worked. */
86function Answer({ actionData }: { actionData: EmailActionData | undefined }) {
87 return (
88 <>
89 <ErrorText>{actionData?.emailError}</ErrorText>
90 {actionData?.emailNotice && (
91 <p role="status" className="mt-2 text-sm text-muted">
92 {actionData.emailNotice}
93 </p>
94 )}
95 </>
96 );
97}
98
99/**
100 * A person's email addresses, in their account settings. `intent` is the
101 * form that last posted, so its answer shows beside it.
102 */
103export function EmailsSection({
104 data,
105 actionData,
106 intent,
107 hasPassword,
108}: {
109 data: AccountEmails | null;
110 actionData: EmailActionData | undefined;
111 intent?: string;
112 hasPassword: boolean;
113}) {
114 if (!data) return null;
115 const answerFor = (one: string) => (intent === one ? actionData : undefined);
116 const elsewhere = intent === "backup-email" || intent === "email-privacy";
117 const backups = backupChoices(data.emails);
118 const backup = data.emails.find((email) => email.backup);
119 const full = data.emails.length >= data.limit;
120 return (
121 <section id="emails" className="scroll-mt-20">
122 {actionData?.reauth && (
123 <div className="mb-4">
124 <ConfirmItIsYou pending={actionData.reauth} hasPassword={hasPassword} />
125 </div>
126 )}
127
128 <ul className="divide-y divide-line rounded-md border border-line">
129 {data.emails.map((email) => {
130 const can = addressActions(email, data.emails);
131 return (
132 <li key={email.email} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center">
133 <div className="flex min-w-0 grow items-start gap-3">
134 <Mail size={16} className="mt-0.5 shrink-0 text-faint" />
135 <div className="min-w-0">
136 <p className="truncate text-sm">{email.email}</p>
137 <p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-faint">
138 {email.primary && <Badge size="md">Primary</Badge>}
139 {email.backup && <Badge size="md">Backup</Badge>}
140 {email.verified ? (
141 <span>Confirmed {email.verifiedAt ? <TimeAgo at={email.verifiedAt} /> : null}</span>
142 ) : (
143 <span className="text-warn">Unconfirmed: check your inbox for the link</span>
144 )}
145 </p>
146 </div>
147 </div>
148 <div className="flex shrink-0 flex-wrap gap-2 sm:justify-end">
149 {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" pending="Sending…" />}
150 {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" pending="Saving…" />}
151 {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" pending="Removing…" />}
152 </div>
153 </li>
154 );
155 })}
156 </ul>
157
158 {/* Keyed on the list, so an address once added leaves the field empty. */}
159 <Form key={data.emails.length} method="post" className="mt-4">
160 <input type="hidden" name="intent" value="add-email" />
161 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
162 <div className="grow">
163 <Field label="Add an email address">
164 <Input name="email" type="email" autoComplete="email" placeholder="you@example.com" required disabled={full} />
165 </Field>
166 </div>
167 <SubmitButton pending="Adding…" match={{ intent: "add-email" }} disabled={full}>
168 Add
169 </SubmitButton>
170 </div>
171 <p className="mt-1.5 text-xs text-faint">
172 {full ? `You have ${data.limit} addresses, the most an account can have.` : "g1t sends it a link to confirm it."}
173 </p>
174 </Form>
175 <Answer actionData={elsewhere ? undefined : actionData} />
176
177 <div className="mt-8">
178 <h2 className="font-medium">Backup address</h2>
179 <p className="mt-1 text-sm text-muted">Security notices, such as a new address or a changed password, go to your primary and to this address.</p>
180 <Form method="post" className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end">
181 <input type="hidden" name="intent" value="backup-email" />
182 <div className="block grow">
183 <SelectField
184 name="backup"
185 aria-label="Backup address"
186 defaultValue={backup?.email ?? ""}
187 disabled={backups.length === 0}
188 className="h-auto py-2"
189 options={[{ value: "", label: "Primary address only" }, ...backups.map((email) => ({ value: email.email, label: email.email }))]}
190 />
191 </div>
192 <SubmitButton variant="outline" pending="Saving…" match={{ intent: "backup-email" }} disabled={backups.length === 0}>
193 Save
194 </SubmitButton>
195 </Form>
196 <Answer actionData={answerFor("backup-email")} />
197 {backups.length === 0 && <p className="mt-2 text-xs text-faint">Add and confirm a second address to choose a backup.</p>}
198 </div>
199
200 <div className="mt-8">
201 <h2 className="font-medium">Privacy</h2>
202 <Form method="post" className="mt-3 space-y-3">
203 <input type="hidden" name="intent" value="email-privacy" />
204 <SwitchCard name="private" defaultChecked={data.privateEmail} title="Keep my email address private">
205 Merges and other commits g1t makes for you on the web, or an agent makes for you, use{" "}
206 <span className="font-mono text-fg wrap-anywhere">{data.noreply}</span> instead of your primary address.
207 </SwitchCard>
208 <SwitchCard name="block" defaultChecked={data.blockPrivatePushes} title="Block pushes that expose my email">
209 While your address is private, a push is refused if a commit in it has one of your confirmed addresses as its author or committer. Commit with your
210 noreply address instead.
211 </SwitchCard>
212 <div className="flex flex-wrap items-center justify-between gap-3">
213 <p className="text-xs text-faint">
214 Commits g1t makes for you now carry <span className="font-mono wrap-anywhere">{data.commitEmail}</span>.
215 </p>
216 <SubmitButton variant="outline" pending="Saving…" match={{ intent: "email-privacy" }}>
217 Save
218 </SubmitButton>
219 </div>
220 </Form>
221 <Answer actionData={answerFor("email-privacy")} />
222 </div>
223 </section>
224 );
225}
226
227/** What happened to the account's security, newest first. */
228export function SecurityLogSection({ log }: { log: SecurityEvent[] }) {
229 return (
230 <section id="security-log" className="scroll-mt-20">
231 {log.length === 0 ? (
232 <p className="text-sm text-faint">Nothing yet.</p>
233 ) : (
234 <ul className="divide-y divide-line rounded-md border border-line">
235 {log.map((event, at) => (
236 <li key={`${event.createdAt}:${at}`} className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:gap-4">
237 <p className="min-w-0 grow truncate text-sm">
238 {securityEventLabel(event)}
239 {event.byStaff && <span className="text-muted"> · by g1t staff{event.reason ? `: ${event.reason}` : ""}</span>}
240 </p>
241 <p className="shrink-0 text-xs text-faint">
242 <TimeAgo at={event.createdAt} />
243 </p>
244 </li>
245 ))}
246 </ul>
247 )}
248 </section>
249 );
250}