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