g1t/apps/web/app/lib/emails.server.ts
| 1 | import type { AccountEmails, Reauth, Result, SecurityEvent, User } from "@g1t/contracts"; |
| 2 | |
| 3 | import { pendingFields } from "./emails"; |
| 4 | import { accounts } from "./services.server"; |
| 5 | import { clientOf, sessionTokenOf } from "./session.server"; |
| 6 | |
| 7 | /** A change that waits on the person proving it is them. */ |
| 8 | export type PendingChange = { |
| 9 | intent: string; |
| 10 | /** The form's other fields, sent again with the password. */ |
| 11 | fields: Record<string, string>; |
| 12 | message: string; |
| 13 | }; |
| 14 | |
| 15 | /** What the Emails section's forms answer. */ |
| 16 | export type EmailActionData = { |
| 17 | emailError?: string; |
| 18 | emailNotice?: string; |
| 19 | reauth?: PendingChange; |
| 20 | } | null; |
| 21 | |
| 22 | /** The intents the Emails section posts. */ |
| 23 | export const EMAIL_INTENTS = ["add-email", "remove-email", "resend-email", "primary-email", "backup-email", "email-privacy"] as const; |
| 24 | |
| 25 | /** A person's addresses and security log for their settings. */ |
| 26 | export async function loadEmails(user: User): Promise<{ emails: AccountEmails | null; log: SecurityEvent[] }> { |
| 27 | const [emails, log] = await Promise.all([ |
| 28 | accounts.listEmails(user).catch(() => null), |
| 29 | accounts.securityLog(user).catch(() => null), |
| 30 | ]); |
| 31 | return { |
| 32 | emails: emails?.ok ? emails.value : null, |
| 33 | log: log?.ok ? log.value : [], |
| 34 | }; |
| 35 | } |
| 36 | |
| 37 | /** A person's security log, newest first, for its own settings page. */ |
| 38 | export async function loadSecurityLog(user: User): Promise<SecurityEvent[]> { |
| 39 | const log = await accounts.securityLog(user).catch(() => null); |
| 40 | return log?.ok ? log.value : []; |
| 41 | } |
| 42 | |
| 43 | /** The proof a change carries: this session, and the password if it was just typed. */ |
| 44 | function proof(request: Request, form: FormData): Reauth { |
| 45 | const password = String(form.get("password") ?? ""); |
| 46 | return { sessionToken: sessionTokenOf(request), password: password || null, client: clientOf(request) }; |
| 47 | } |
| 48 | |
| 49 | function answer(result: Result<unknown>, form: FormData, notice?: string): EmailActionData { |
| 50 | if (result.ok) return notice ? { emailNotice: notice } : null; |
| 51 | if (result.error.code === "reauth_required") { |
| 52 | return { reauth: { intent: String(form.get("intent")), fields: pendingFields(form), message: result.error.message } }; |
| 53 | } |
| 54 | return { emailError: result.error.message }; |
| 55 | } |
| 56 | |
| 57 | /** Handles the Emails section's intents; undefined for any other intent. */ |
| 58 | export async function emailAction(user: User, form: FormData, request: Request): Promise<EmailActionData | undefined> { |
| 59 | const email = String(form.get("email") ?? "").trim(); |
| 60 | switch (form.get("intent")) { |
| 61 | case "add-email": |
| 62 | return answer( |
| 63 | await accounts.addEmail(user, email, proof(request, form)), |
| 64 | form, |
| 65 | `A confirmation link is on its way to ${email}.`, |
| 66 | ); |
| 67 | case "remove-email": |
| 68 | return answer(await accounts.removeEmail(user, email, proof(request, form)), form, `Removed ${email}.`); |
| 69 | case "resend-email": |
| 70 | return answer(await accounts.resendEmailVerification(user, email), form, `Sent the link to ${email} again.`); |
| 71 | case "primary-email": |
| 72 | return answer( |
| 73 | await accounts.updateEmailSettings(user, { primary: email }, proof(request, form)), |
| 74 | form, |
| 75 | `${email} is now your primary address.`, |
| 76 | ); |
| 77 | case "backup-email": |
| 78 | return answer( |
| 79 | await accounts.updateEmailSettings(user, { backup: String(form.get("backup") ?? "") }, proof(request, form)), |
| 80 | form, |
| 81 | "Saved where security notices go.", |
| 82 | ); |
| 83 | case "email-privacy": |
| 84 | return answer( |
| 85 | await accounts.updateEmailSettings( |
| 86 | user, |
| 87 | { privateEmail: form.get("private") === "on", blockPrivatePushes: form.get("block") === "on" }, |
| 88 | proof(request, form), |
| 89 | ), |
| 90 | form, |
| 91 | "Saved.", |
| 92 | ); |
| 93 | } |
| 94 | return undefined; |
| 95 | } |