Skip to content
256 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
78 minutes. Or set a password with Forgot 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>
142 Confirmed {email.verifiedAt ? <TimeAgo at={email.verifiedAt} /> : null}
143 </span>
144 ) : (
145 <span className="text-warn">Unconfirmed: check your inbox for the link</span>
146 )}
147 </p>
148 </div>
149 </div>
150 <div className="flex shrink-0 flex-wrap gap-2 sm:justify-end">
151 {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" pending="Sending…" />}
152 {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" pending="Saving…" />}
153 {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" pending="Removing…" />}
154 </div>
155 </li>
156 );
157 })}
158 </ul>
159
160 {/* Keyed on the list, so an address once added leaves the field empty. */}
161 <Form key={data.emails.length} method="post" className="mt-4">
162 <input type="hidden" name="intent" value="add-email" />
163 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
164 <div className="grow">
165 <Field label="Add an email address">
166 <Input name="email" type="email" autoComplete="email" placeholder="you@example.com" required disabled={full} />
167 </Field>
168 </div>
169 <SubmitButton pending="Adding…" match={{ intent: "add-email" }} disabled={full}>
170 Add
171 </SubmitButton>
172 </div>
173 <p className="mt-1.5 text-xs text-faint">
174 {full ? `You have ${data.limit} addresses, the most an account can have.` : "g1t sends it a link to confirm it."}
175 </p>
176 </Form>
177 <Answer actionData={elsewhere ? undefined : actionData} />
178
179 <div className="mt-8">
180 <h2 className="font-medium">Backup address</h2>
181 <p className="mt-1 text-sm text-muted">
182 Security notices, such as a new address or a changed password, go to your primary and to this address.
183 </p>
184 <Form method="post" className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end">
185 <input type="hidden" name="intent" value="backup-email" />
186 <div className="block grow">
187 <SelectField
188 name="backup"
189 aria-label="Backup address"
190 defaultValue={backup?.email ?? ""}
191 disabled={backups.length === 0}
192 className="h-auto py-2"
193 options={[{ value: "", label: "Primary address only" }, ...backups.map((email) => ({ value: email.email, label: email.email }))]}
194 />
195 </div>
196 <SubmitButton variant="outline" pending="Saving…" match={{ intent: "backup-email" }} disabled={backups.length === 0}>
197 Save
198 </SubmitButton>
199 </Form>
200 <Answer actionData={answerFor("backup-email")} />
201 {backups.length === 0 && (
202 <p className="mt-2 text-xs text-faint">Add and confirm a second address to choose a backup.</p>
203 )}
204 </div>
205
206 <div className="mt-8">
207 <h2 className="font-medium">Privacy</h2>
208 <Form method="post" className="mt-3 space-y-3">
209 <input type="hidden" name="intent" value="email-privacy" />
210 <SwitchCard name="private" defaultChecked={data.privateEmail} title="Keep my email address private">
211 Merges and other commits g1t makes for you on the web, or an agent makes for you, use{" "}
212 <span className="font-mono text-fg [overflow-wrap:anywhere]">{data.noreply}</span> instead of your primary address.
213 </SwitchCard>
214 <SwitchCard name="block" defaultChecked={data.blockPrivatePushes} title="Block pushes that expose my email">
215 While your address is private, a push is refused if a commit in it has one of your confirmed
216 addresses as its author or committer. Commit with your noreply address instead.
217 </SwitchCard>
218 <div className="flex flex-wrap items-center justify-between gap-3">
219 <p className="text-xs text-faint">
220 Commits g1t makes for you now carry <span className="font-mono [overflow-wrap:anywhere]">{data.commitEmail}</span>.
221 </p>
222 <SubmitButton variant="outline" pending="Saving…" match={{ intent: "email-privacy" }}>
223 Save
224 </SubmitButton>
225 </div>
226 </Form>
227 <Answer actionData={answerFor("email-privacy")} />
228 </div>
229 </section>
230 );
231}
232
233/** What happened to the account's security, newest first. */
234export function SecurityLogSection({ log }: { log: SecurityEvent[] }) {
235 return (
236 <section id="security-log" className="scroll-mt-20">
237 {log.length === 0 ? (
238 <p className="text-sm text-faint">Nothing yet.</p>
239 ) : (
240 <ul className="divide-y divide-line rounded-md border border-line">
241 {log.map((event, at) => (
242 <li key={`${event.createdAt}:${at}`} className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:gap-4">
243 <p className="min-w-0 grow truncate text-sm">
244 {securityEventLabel(event)}
245 {event.byStaff && <span className="text-muted"> · by g1t staff{event.reason ? `: ${event.reason}` : ""}</span>}
246 </p>
247 <p className="shrink-0 text-xs text-faint">
248 <TimeAgo at={event.createdAt} />
249 </p>
250 </li>
251 ))}
252 </ul>
253 )}
254 </section>
255 );
256}