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

239 lines10,058 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 { Button, ErrorText, Field, Input, Pill, TimeAgo } from "./ui";
9import { SwitchCard } from "./ui/switch";
10
11/** One small form posting one intent about one address. */
12function AddressButton({ intent, email, label }: { intent: string; email: string; label: string }) {
13 return (
14 <Form method="post">
15 <input type="hidden" name="intent" value={intent} />
16 <input type="hidden" name="email" value={email} />
17 <Button variant="quiet" type="submit">
18 {label}
19 </Button>
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 <Button type="submit">Confirm</Button>
68 </div>
69 <p className="text-xs text-faint">You will not be asked again for 10 minutes.</p>
70 </Form>
71 ) : (
72 <p className="mt-2 text-sm text-muted">
73 Your account signs in with GitHub only. Sign out, sign in with GitHub again, and make this change within 10
74 minutes. Or set a password with Forgot your password on the sign-in page.
75 </p>
76 )}
77 </div>
78 );
79}
80
81/** A person's email addresses, in their account settings. */
82export function EmailsSection({
83 data,
84 actionData,
85 hasPassword,
86}: {
87 data: AccountEmails | null;
88 actionData: EmailActionData | undefined;
89 hasPassword: boolean;
90}) {
91 if (!data) return null;
92 const backups = backupChoices(data.emails);
93 const backup = data.emails.find((email) => email.backup);
94 const full = data.emails.length >= data.limit;
95 return (
96 <section id="emails" className="scroll-mt-20">
97 {actionData?.reauth && (
98 <div className="mb-4">
99 <ConfirmItIsYou pending={actionData.reauth} hasPassword={hasPassword} />
100 </div>
101 )}
102
103 <ul className="divide-y divide-line rounded-md border border-line">
104 {data.emails.map((email) => {
105 const can = addressActions(email, data.emails);
106 return (
107 <li key={email.email} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center">
108 <div className="flex min-w-0 grow items-start gap-3">
109 <Mail size={16} className="mt-0.5 shrink-0 text-faint" />
110 <div className="min-w-0">
111 <p className="truncate text-sm">{email.email}</p>
112 <p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-faint">
113 {email.primary && <Pill>Primary</Pill>}
114 {email.backup && <Pill>Backup</Pill>}
115 {email.verified ? (
116 <span>
117 Confirmed {email.verifiedAt ? <TimeAgo at={email.verifiedAt} /> : null}
118 </span>
119 ) : (
120 <span className="text-warn">Unconfirmed: check your inbox for the link</span>
121 )}
122 </p>
123 </div>
124 </div>
125 <div className="flex shrink-0 flex-wrap gap-2 sm:justify-end">
126 {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" />}
127 {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" />}
128 {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" />}
129 </div>
130 </li>
131 );
132 })}
133 </ul>
134
135 <Form method="post" className="mt-4">
136 <input type="hidden" name="intent" value="add-email" />
137 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
138 <div className="grow">
139 <Field label="Add an email address">
140 <Input name="email" type="email" autoComplete="email" placeholder="you@example.com" required disabled={full} />
141 </Field>
142 </div>
143 <Button type="submit" disabled={full}>
144 Add
145 </Button>
146 </div>
147 <p className="mt-1.5 text-xs text-faint">
148 {full ? `You have ${data.limit} addresses, the most an account can have.` : "g1t sends it a link to confirm it."}
149 </p>
150 </Form>
151 <ErrorText>{actionData?.emailError}</ErrorText>
152 {actionData?.emailNotice && (
153 <p role="status" className="mt-2 text-sm text-muted">
154 {actionData.emailNotice}
155 </p>
156 )}
157
158 <div className="mt-8">
159 <h2 className="font-medium">Backup address</h2>
160 <p className="mt-1 text-sm text-muted">
161 Security notices, such as a new address or a changed password, go to your primary and to this address.
162 </p>
163 <Form method="post" className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end">
164 <input type="hidden" name="intent" value="backup-email" />
165 <label className="block grow">
166 <span className="sr-only">Backup address</span>
167 <select
168 name="backup"
169 defaultValue={backup?.email ?? ""}
170 disabled={backups.length === 0}
171 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"
172 >
173 <option value="">Primary address only</option>
174 {backups.map((email) => (
175 <option key={email.email} value={email.email}>
176 {email.email}
177 </option>
178 ))}
179 </select>
180 </label>
181 <Button variant="quiet" type="submit" disabled={backups.length === 0}>
182 Save
183 </Button>
184 </Form>
185 {backups.length === 0 && (
186 <p className="mt-2 text-xs text-faint">Add and confirm a second address to choose a backup.</p>
187 )}
188 </div>
189
190 <div className="mt-8">
191 <h2 className="font-medium">Privacy</h2>
192 <Form method="post" className="mt-3 space-y-3">
193 <input type="hidden" name="intent" value="email-privacy" />
194 <SwitchCard name="private" defaultChecked={data.privateEmail} title="Keep my email address private">
195 Merges and other commits g1t makes for you on the web, or an agent makes for you, use{" "}
196 <span className="font-mono text-fg [overflow-wrap:anywhere]">{data.noreply}</span> instead of your primary address.
197 </SwitchCard>
198 <SwitchCard name="block" defaultChecked={data.blockPrivatePushes} title="Block pushes that expose my email">
199 While your address is private, a push is refused if a commit in it has one of your confirmed
200 addresses as its author or committer. Commit with your noreply address instead.
201 </SwitchCard>
202 <div className="flex flex-wrap items-center justify-between gap-3">
203 <p className="text-xs text-faint">
204 Commits g1t makes for you now carry <span className="font-mono [overflow-wrap:anywhere]">{data.commitEmail}</span>.
205 </p>
206 <Button variant="quiet" type="submit">
207 Save
208 </Button>
209 </div>
210 </Form>
211 </div>
212 </section>
213 );
214}
215
216/** What happened to the account's security, newest first. */
217export function SecurityLogSection({ log }: { log: SecurityEvent[] }) {
218 return (
219 <section id="security-log" className="scroll-mt-20">
220 {log.length === 0 ? (
221 <p className="text-sm text-faint">Nothing yet.</p>
222 ) : (
223 <ul className="divide-y divide-line rounded-md border border-line">
224 {log.map((event, at) => (
225 <li key={`${event.createdAt}:${at}`} className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:gap-4">
226 <p className="min-w-0 grow truncate text-sm">
227 {securityEventLabel(event)}
228 {event.byStaff && <span className="text-muted"> · by g1t staff{event.reason ? `: ${event.reason}` : ""}</span>}
229 </p>
230 <p className="shrink-0 text-xs text-faint">
231 <TimeAgo at={event.createdAt} />
232 </p>
233 </li>
234 ))}
235 </ul>
236 )}
237 </section>
238 );
239}