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