1import { useNavigate, useSearchParams } from "@solidjs/router";
2import { createResource, createSignal, For, onMount, Show } from "solid-js";
3import { parseResponse } from "hono/client";
4import type { User } from "../types/users.ts";
5import { api, queries, reason } from "../api.ts";
6import { Ago } from "../components/Ago.tsx";
7import { lastGood, Loaded } from "../components/Loaded.tsx";
8import { OpenApp } from "../components/OpenApp.tsx";
9import { Reveal } from "../components/Reveal.tsx";
10import { toast } from "../components/Toast.tsx";
11import "./Account.css";
12
13/** The signed-in user's Keycloak record, shared with the sidebar corner. */
14export const [account, { refetch: refetchAccount }] = createResource(() => parseResponse(api.account.$get()));
15
16export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) =>
17 [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username;
18
19export 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
27const PICTURE_SIZE = 256;
28const FIELDS = ["firstName", "lastName"] as const;
29
30const 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. */
38async 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
50export 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
87type Self = NonNullable<typeof account.latest>;
88
89function 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
206function 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}