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

250 lines10,618 bytesCodeBlame

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1import { 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 <h2 className="font-medium">Emails</h2>
98 <p className="mt-1 text-sm text-muted">
99 Your primary address gets account mail and password resets. Any confirmed address signs you in and can
100 reset your password, and commits that carry it are shown as yours.
101 </p>
102
103 {actionData?.reauth && (
104 <div className="mt-4">
105 <ConfirmItIsYou pending={actionData.reauth} hasPassword={hasPassword} />
106 </div>
107 )}
108
109 <ul className="mt-4 divide-y divide-line rounded-md border border-line">
110 {data.emails.map((email) => {
111 const can = addressActions(email, data.emails);
112 return (
113 <li key={email.email} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center">
114 <div className="flex min-w-0 grow items-start gap-3">
115 <Mail size={16} className="mt-0.5 shrink-0 text-faint" />
116 <div className="min-w-0">
117 <p className="truncate text-sm">{email.email}</p>
118 <p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-faint">
119 {email.primary && <Pill>Primary</Pill>}
120 {email.backup && <Pill>Backup</Pill>}
121 {email.verified ? (
122 <span>
123 Confirmed {email.verifiedAt ? <TimeAgo at={email.verifiedAt} /> : null}
124 </span>
125 ) : (
126 <span className="text-warn">Unconfirmed: check your inbox for the link</span>
127 )}
128 </p>
129 </div>
130 </div>
131 <div className="flex shrink-0 flex-wrap gap-2 sm:justify-end">
132 {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" />}
133 {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" />}
134 {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" />}
135 </div>
136 </li>
137 );
138 })}
139 </ul>
140
141 <Form method="post" className="mt-4">
142 <input type="hidden" name="intent" value="add-email" />
143 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
144 <div className="grow">
145 <Field label="Add an email address">
146 <Input name="email" type="email" autoComplete="email" placeholder="you@example.com" required disabled={full} />
147 </Field>
148 </div>
149 <Button type="submit" disabled={full}>
150 Add
151 </Button>
152 </div>
153 <p className="mt-1.5 text-xs text-faint">
154 {full ? `You have ${data.limit} addresses, the most an account can have.` : "g1t sends it a link to confirm it."}
155 </p>
156 </Form>
157 <ErrorText>{actionData?.emailError}</ErrorText>
158 {actionData?.emailNotice && (
159 <p role="status" className="mt-2 text-sm text-muted">
160 {actionData.emailNotice}
161 </p>
162 )}
163
164 <div className="mt-8">
165 <h3 className="text-sm font-medium">Backup address</h3>
166 <p className="mt-1 text-sm text-muted">
167 Security notices, such as a new address or a changed password, go to your primary and to this address.
168 </p>
169 <Form method="post" className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end">
170 <input type="hidden" name="intent" value="backup-email" />
171 <label className="block grow">
172 <span className="sr-only">Backup address</span>
173 <select
174 name="backup"
175 defaultValue={backup?.email ?? ""}
176 disabled={backups.length === 0}
177 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"
178 >
179 <option value="">Primary address only</option>
180 {backups.map((email) => (
181 <option key={email.email} value={email.email}>
182 {email.email}
183 </option>
184 ))}
185 </select>
186 </label>
187 <Button variant="quiet" type="submit" disabled={backups.length === 0}>
188 Save
189 </Button>
190 </Form>
191 {backups.length === 0 && (
192 <p className="mt-2 text-xs text-faint">Add and confirm a second address to choose a backup.</p>
193 )}
194 </div>
195
196 <div className="mt-8">
197 <h3 className="text-sm font-medium">Privacy</h3>
198 <Form method="post" className="mt-3 space-y-3">
199 <input type="hidden" name="intent" value="email-privacy" />
200 <SwitchCard name="private" defaultChecked={data.privateEmail} title="Keep my email address private">
201 Merges and other commits g1t makes for you on the web, or an agent makes for you, use{" "}
202 <span className="font-mono text-fg [overflow-wrap:anywhere]">{data.noreply}</span> instead of your primary address.
203 </SwitchCard>
204 <SwitchCard name="block" defaultChecked={data.blockPrivatePushes} title="Block pushes that expose my email">
205 While your address is private, a push is refused if a commit in it has one of your confirmed
206 addresses as its author or committer. Commit with your noreply address instead.
207 </SwitchCard>
208 <div className="flex flex-wrap items-center justify-between gap-3">
209 <p className="text-xs text-faint">
210 Commits g1t makes for you now carry <span className="font-mono [overflow-wrap:anywhere]">{data.commitEmail}</span>.
211 </p>
212 <Button variant="quiet" type="submit">
213 Save
214 </Button>
215 </div>
216 </Form>
217 </div>
218 </section>
219 );
220}
221
222/** What happened to the account's security, newest first. */
223export function SecurityLogSection({ log }: { log: SecurityEvent[] }) {
224 return (
225 <section id="security-log" className="scroll-mt-20">
226 <h2 className="font-medium">Security log</h2>
227 <p className="mt-1 text-sm text-muted">
228 Changes to your addresses and password, by you or by g1t staff. If you do not recognise one, reset your
229 password.
230 </p>
231 {log.length === 0 ? (
232 <p className="mt-4 text-sm text-faint">Nothing yet.</p>
233 ) : (
234 <ul className="mt-4 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}