| 1 | import { useSearchParams } from "@solidjs/router"; |
| 2 | import { createResource, createSignal, For, Show } from "solid-js"; |
| 3 | import { parseResponse } from "hono/client"; |
| 4 | import type { User } from "../types/users.ts"; |
| 5 | import { api, reason } from "../api.ts"; |
| 6 | import { Ago } from "../components/Ago.tsx"; |
| 7 | import { lastGood, Loaded } from "../components/Loaded.tsx"; |
| 8 | import { showConfirmDialog } from "../components/Dialog.tsx"; |
| 9 | import { addPasskey, authReason, authRequest } from "../auth.ts"; |
| 10 | import { Reveal } from "../components/Reveal.tsx"; |
| 11 | import { toast } from "../components/Toast.tsx"; |
| 12 | import "./Account.css"; |
| 13 | |
| 14 | /** Shared with the sidebar corner. */ |
| 15 | export const [account, { refetch: refetchAccount }] = createResource(() => window.location.pathname !== "/sign-in", () => parseResponse(api.account.$get())); |
| 16 | |
| 17 | export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) => |
| 18 | [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username; |
| 19 | |
| 20 | export function Avatar(props: { picture: string | null; name: string }) { |
| 21 | return ( |
| 22 | <Show when={props.picture} fallback={<span class="avatar monogram" aria-hidden="true">{props.name.slice(0, 1).toUpperCase()}</span>}> |
| 23 | {(src) => <img class="avatar" src={src()} alt="" />} |
| 24 | </Show> |
| 25 | ); |
| 26 | } |
| 27 | |
| 28 | const PICTURE_SIZE = 256; |
| 29 | const FIELDS = ["firstName", "lastName", "email"] as const; |
| 30 | |
| 31 | /** Center-crops to a square and encodes WebP, or PNG where the browser can't encode WebP; null if it can't read the file. */ |
| 32 | async function square(file: File) { |
| 33 | const image = await createImageBitmap(file).catch(() => null); |
| 34 | if (!image) return null; |
| 35 | const side = Math.min(image.width, image.height); |
| 36 | const canvas = Object.assign(document.createElement("canvas"), { width: PICTURE_SIZE, height: PICTURE_SIZE }); |
| 37 | canvas.getContext("2d")!.drawImage(image, (image.width - side) / 2, (image.height - side) / 2, side, side, |
| 38 | 0, 0, PICTURE_SIZE, PICTURE_SIZE); |
| 39 | image.close(); |
| 40 | const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/webp", 0.85)); |
| 41 | return blob && new File([blob], "picture", { type: blob.type }); |
| 42 | } |
| 43 | |
| 44 | export function Account() { |
| 45 | const [params] = useSearchParams<{ welcome?: string }>(); |
| 46 | const [adding, setAdding] = createSignal(false); |
| 47 | return ( |
| 48 | <div class="page account-page"> |
| 49 | <div class="page-head"> |
| 50 | <h1>profile</h1> |
| 51 | </div> |
| 52 | <Loaded data={account} what="your profile" retry={refetchAccount} skeleton={ |
| 53 | <div class="account-grid"> |
| 54 | <div class="skeleton" style={{ height: "252px" }} /> |
| 55 | <div class="skeleton" style={{ height: "160px" }} /> |
| 56 | </div> |
| 57 | }> |
| 58 | {(user) => ( |
| 59 | <div class="account-grid"> |
| 60 | <Show when={params.welcome}> |
| 61 | <section class="card"> |
| 62 | <h2 class="card-title">want to add a passkey?</h2> |
| 63 | <p class="muted">Sign in with your fingerprint, face, or device PIN. Your password stays available.</p> |
| 64 | <button class="button primary" disabled={adding()} aria-busy={adding()} onClick={async () => { |
| 65 | setAdding(true); |
| 66 | try { await addPasskey("passkey"); await refetchAccount(); window.history.replaceState(null,"","/account"); toast("Added a passkey"); } |
| 67 | catch(failure) { toast(authReason(failure)); } |
| 68 | finally { setAdding(false); } |
| 69 | }}>add a passkey</button>{" "} |
| 70 | <a class="button" href="/">maybe later</a> |
| 71 | </section> |
| 72 | </Show> |
| 73 | <Show when={user().requiredActions.length}><section class="card"><p class="muted">Finish your profile and change any temporary password to open Snowglobe and Files.</p></section></Show> |
| 74 | <Profile user={user()} /> |
| 75 | <SignIn user={user()} /> |
| 76 | </div> |
| 77 | )} |
| 78 | </Loaded> |
| 79 | </div> |
| 80 | ); |
| 81 | } |
| 82 | |
| 83 | type Self = NonNullable<typeof account.latest>; |
| 84 | |
| 85 | function Profile(props: { user: Self }) { |
| 86 | const [dirty, setDirty] = createSignal(false); |
| 87 | const [pending, setPending] = createSignal<"save" | "picture">(); |
| 88 | const [error, setError] = createSignal(""); |
| 89 | const [pictureError, setPictureError] = createSignal(""); |
| 90 | const inputs = {} as Record<(typeof FIELDS)[number], HTMLInputElement>; |
| 91 | |
| 92 | const changes = () => Object.fromEntries(FIELDS.flatMap((name) => { |
| 93 | const value = inputs[name].value.trim(); |
| 94 | return value === (props.user[name] ?? "") ? [] : [[name, value]]; |
| 95 | })); |
| 96 | const reset = () => { |
| 97 | for (const name of FIELDS) inputs[name].value = props.user[name] ?? ""; |
| 98 | setDirty(false); |
| 99 | setError(""); |
| 100 | }; |
| 101 | |
| 102 | const busy = async (key: "save" | "picture", work: () => Promise<unknown>, fail = setError) => { |
| 103 | setPending(key); |
| 104 | fail(""); |
| 105 | try { |
| 106 | await work(); |
| 107 | } catch (failure) { |
| 108 | fail(reason(failure)); |
| 109 | } finally { |
| 110 | setPending(); |
| 111 | } |
| 112 | }; |
| 113 | |
| 114 | const save = (event: SubmitEvent) => { |
| 115 | event.preventDefault(); |
| 116 | if (pending() === "save") return; |
| 117 | const json = changes(); |
| 118 | if (!Object.keys(json).length) return reset(); |
| 119 | return busy("save", async () => { |
| 120 | await parseResponse(api.account.$patch({ json })); |
| 121 | await refetchAccount(); |
| 122 | reset(); |
| 123 | }); |
| 124 | }; |
| 125 | |
| 126 | const upload = async (file: File) => { |
| 127 | const picture = await square(file); |
| 128 | if (!picture) return setPictureError("That file isn't an image this browser can open. Pick a PNG, JPEG, or WebP."); |
| 129 | await busy("picture", async () => { |
| 130 | await parseResponse(api.account.picture.$put({ form: { picture } })); |
| 131 | await refetchAccount(); |
| 132 | }, setPictureError); |
| 133 | }; |
| 134 | |
| 135 | const removePicture = () => busy("picture", async () => { |
| 136 | await parseResponse(api.account.picture.$delete()); |
| 137 | await refetchAccount(); |
| 138 | }, setPictureError); |
| 139 | |
| 140 | return ( |
| 141 | <section class="card"> |
| 142 | <div class="picture-row"> |
| 143 | <Avatar picture={props.user.picture} name={displayName(props.user)} /> |
| 144 | <label class="button small" aria-busy={pending() === "picture"}> |
| 145 | {props.user.picture ? "replace picture" : "add picture"} |
| 146 | <input type="file" accept="image/*" class="sr-only" disabled={pending() === "picture"} |
| 147 | onChange={(event) => { |
| 148 | const file = event.currentTarget.files?.[0]; |
| 149 | event.currentTarget.value = ""; |
| 150 | if (file) upload(file); |
| 151 | }} /> |
| 152 | </label> |
| 153 | <Show when={props.user.picture}> |
| 154 | <button class="button small" disabled={pending() === "picture"} onClick={removePicture}>remove</button> |
| 155 | </Show> |
| 156 | </div> |
| 157 | <Show when={pictureError()}><p class="error" role="alert">{pictureError()}</p></Show> |
| 158 | |
| 159 | <form class="account-fields" onSubmit={save} onInput={() => setDirty(Object.keys(changes()).length > 0)} |
| 160 | onKeyDown={(event) => event.key === "Escape" && dirty() && reset()}> |
| 161 | <span class="label">username</span> |
| 162 | <span class="username">{props.user.username}</span> |
| 163 | <label class="label" for="first-name">first name</label> |
| 164 | <input id="first-name" ref={inputs.firstName} class="search" value={props.user.firstName ?? ""} autocomplete="given-name" |
| 165 | readOnly={pending() === "save"} /> |
| 166 | <label class="label" for="last-name">last name</label> |
| 167 | <input id="last-name" ref={inputs.lastName} class="search" value={props.user.lastName ?? ""} autocomplete="family-name" |
| 168 | readOnly={pending() === "save"} /> |
| 169 | <label class="label" for="profile-email">email</label> |
| 170 | <input id="profile-email" ref={inputs.email} class="search" type="email" value={props.user.email ?? ""} autocomplete="email" readOnly={pending() === "save"} /> |
| 171 | <Show when={error()}><span /><p class="error" role="alert">{error()}</p></Show> |
| 172 | <span /> |
| 173 | <Reveal when={dirty()}> |
| 174 | <div class="actions"> |
| 175 | <button type="button" class="button small" disabled={pending() === "save"} onClick={reset}> |
| 176 | reset<kbd aria-hidden="true">esc</kbd> |
| 177 | </button> |
| 178 | <button type="submit" class="button small primary" aria-disabled={pending() === "save"} |
| 179 | aria-busy={pending() === "save"}> |
| 180 | save<kbd aria-hidden="true">enter</kbd> |
| 181 | </button> |
| 182 | </div> |
| 183 | </Reveal> |
| 184 | </form> |
| 185 | </section> |
| 186 | ); |
| 187 | } |
| 188 | |
| 189 | function SignIn(props: { user: Self }) { |
| 190 | const password = () => props.user.credentials.find((credential) => credential.type === "password"); |
| 191 | const passkeys = () => props.user.credentials.filter((credential) => credential.type.startsWith("webauthn")); |
| 192 | const [busy, setBusy] = createSignal(false); |
| 193 | const [error, setError] = createSignal(""); |
| 194 | const run = async (work: () => Promise<unknown>) => { |
| 195 | setBusy(true); setError(""); |
| 196 | try { await work(); await refetchAccount(); } catch(failure) { setError(authReason(failure)); } finally { setBusy(false); } |
| 197 | }; |
| 198 | const change = () => showConfirmDialog({ |
| 199 | title: password() ? "Change password" : "Set password", confirmLabel: "save password", |
| 200 | body: <> |
| 201 | <Show when={password()}><label class="field">current password<input name="current" class="search" type="password" required autocomplete="current-password" /></label></Show> |
| 202 | <label class="field">new password<input name="password" class="search" type="password" required minLength={8} maxLength={1024} autocomplete="new-password" /></label> |
| 203 | </>, |
| 204 | onConfirm: async (form) => { |
| 205 | try { |
| 206 | const {csrf} = await authRequest<{csrf:string}>("status"); |
| 207 | await authRequest("password/change", {csrf, current: String(form.get("current") ?? ""), password: String(form.get("password") ?? "")}); |
| 208 | await refetchAccount(); toast("Saved your password"); |
| 209 | } catch(failure) { setError(authReason(failure)); throw failure; } |
| 210 | }, |
| 211 | }); |
| 212 | return <section class="card"> |
| 213 | <h2 class="card-title">sign-in</h2> |
| 214 | <p class="muted">These sign-in methods work with Snowglobe and Files.</p> |
| 215 | <div class="credentials"> |
| 216 | <span class="label">password</span><span>{password() ? "set" : "none"}</span> |
| 217 | <button class="button small" onClick={change}>{password() ? "change" : "set password"}</button> |
| 218 | <span class="label">passkeys</span><span>{passkeys().length ? "" : "none"}</span> |
| 219 | <button class="button small" disabled={busy()} onClick={() => run(() => addPasskey("passkey"))}>add passkey</button> |
| 220 | <For each={passkeys()}>{(key) => <> |
| 221 | <span /><span>{key.userLabel ?? "unnamed"} <span class="muted"><Ago t={key.createdDate / 1000} /></span></span> |
| 222 | <button class="button small" disabled={busy() || props.user.credentials.length <= 1} onClick={() => run(async () => { |
| 223 | await parseResponse(api.account.credentials[":id"].$delete({param:{id:key.id}})); |
| 224 | })}>remove</button> |
| 225 | </>}</For> |
| 226 | </div> |
| 227 | <Show when={error()}><p class="error" role="alert">{error()}</p></Show> |
| 228 | </section>; |
| 229 | } |