g1t/apps/web/app/components/emails-section.tsx

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