1import { useSearchParams } from "@solidjs/router";
2import { createResource, createSignal, For, Show } from "solid-js";
3import { parseResponse } from "hono/client";
4import type { User, CommitIdentity as Identity } from "../types/users.ts";
5import { api, reason } from "../api.ts";
6import { Ago } from "../components/Ago.tsx";
7import { lastGood, Loaded } from "../components/Loaded.tsx";
8import { showConfirmDialog } from "../components/Dialog.tsx";
9import { addPasskey, authReason, authRequest } from "../auth.ts";
10import { Reveal } from "../components/Reveal.tsx";
11import { toast } from "../components/Toast.tsx";
12import "./Account.css";
13
14/** Shared with the sidebar corner. */
15export const [account, { refetch: refetchAccount }] = createResource(() => window.location.pathname !== "/sign-in", () => parseResponse(api.account.$get()));
16
17export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) =>
18 [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username;
19
20export 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
28const PICTURE_SIZE = 256;
29const 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. */
32async 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
44export 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 <CommitIdentity user={user()} identity={user().commitIdentity} refetch={refetchAccount} />
77 </div>
78 )}
79 </Loaded>
80 </div>
81 );
82}
83
84type Self = NonNullable<typeof account.latest>;
85
86function Profile(props: { user: Self }) {
87 const [dirty, setDirty] = createSignal(false);
88 const [pending, setPending] = createSignal<"save" | "picture">();
89 const [error, setError] = createSignal("");
90 const [pictureError, setPictureError] = createSignal("");
91 const inputs = {} as Record<(typeof FIELDS)[number], HTMLInputElement>;
92
93 const changes = () => Object.fromEntries(FIELDS.flatMap((name) => {
94 const value = inputs[name].value.trim();
95 return value === (props.user[name] ?? "") ? [] : [[name, value]];
96 }));
97 const reset = () => {
98 for (const name of FIELDS) inputs[name].value = props.user[name] ?? "";
99 setDirty(false);
100 setError("");
101 };
102
103 const busy = async (key: "save" | "picture", work: () => Promise<unknown>, fail = setError) => {
104 setPending(key);
105 fail("");
106 try {
107 await work();
108 } catch (failure) {
109 fail(reason(failure));
110 } finally {
111 setPending();
112 }
113 };
114
115 const save = (event: SubmitEvent) => {
116 event.preventDefault();
117 if (pending() === "save") return;
118 const json = changes();
119 if (!Object.keys(json).length) return reset();
120 return busy("save", async () => {
121 await parseResponse(api.account.$patch({ json }));
122 await refetchAccount();
123 reset();
124 });
125 };
126
127 const upload = async (file: File) => {
128 const picture = await square(file);
129 if (!picture) return setPictureError("That file isn't an image this browser can open. Pick a PNG, JPEG, or WebP.");
130 await busy("picture", async () => {
131 await parseResponse(api.account.picture.$put({ form: { picture } }));
132 await refetchAccount();
133 }, setPictureError);
134 };
135
136 const removePicture = () => busy("picture", async () => {
137 await parseResponse(api.account.picture.$delete());
138 await refetchAccount();
139 }, setPictureError);
140
141 return (
142 <section class="card">
143 <div class="picture-row">
144 <Avatar picture={props.user.picture} name={displayName(props.user)} />
145 <label class="button small" aria-busy={pending() === "picture"}>
146 {props.user.picture ? "replace picture" : "add picture"}
147 <input type="file" accept="image/*" class="sr-only" disabled={pending() === "picture"}
148 onChange={(event) => {
149 const file = event.currentTarget.files?.[0];
150 event.currentTarget.value = "";
151 if (file) upload(file);
152 }} />
153 </label>
154 <Show when={props.user.picture}>
155 <button class="button small" disabled={pending() === "picture"} onClick={removePicture}>remove</button>
156 </Show>
157 </div>
158 <Show when={pictureError()}><p class="error" role="alert">{pictureError()}</p></Show>
159
160 <form class="account-fields" onSubmit={save} onInput={() => setDirty(Object.keys(changes()).length > 0)}
161 onKeyDown={(event) => event.key === "Escape" && dirty() && reset()}>
162 <span class="label">username</span>
163 <span class="username">{props.user.username}</span>
164 <label class="label" for="first-name">first name</label>
165 <input id="first-name" ref={inputs.firstName} class="search" value={props.user.firstName ?? ""} autocomplete="given-name"
166 readOnly={pending() === "save"} />
167 <label class="label" for="last-name">last name</label>
168 <input id="last-name" ref={inputs.lastName} class="search" value={props.user.lastName ?? ""} autocomplete="family-name"
169 readOnly={pending() === "save"} />
170 <label class="label" for="profile-email">email</label>
171 <input id="profile-email" ref={inputs.email} class="search" type="email" value={props.user.email ?? ""} autocomplete="email" readOnly={pending() === "save"} />
172 <Show when={error()}><span /><p class="error" role="alert">{error()}</p></Show>
173 <span />
174 <Reveal when={dirty()}>
175 <div class="actions">
176 <button type="button" class="button small" disabled={pending() === "save"} onClick={reset}>
177 reset<kbd aria-hidden="true">esc</kbd>
178 </button>
179 <button type="submit" class="button small primary" aria-disabled={pending() === "save"}
180 aria-busy={pending() === "save"}>
181 save<kbd aria-hidden="true">enter</kbd>
182 </button>
183 </div>
184 </Reveal>
185 </form>
186 </section>
187 );
188}
189
190function SignIn(props: { user: Self }) {
191 const password = () => props.user.credentials.find((credential) => credential.type === "password");
192 const passkeys = () => props.user.credentials.filter((credential) => credential.type.startsWith("webauthn"));
193 const [busy, setBusy] = createSignal(false);
194 const [error, setError] = createSignal("");
195 const run = async (work: () => Promise<unknown>) => {
196 setBusy(true); setError("");
197 try { await work(); await refetchAccount(); } catch(failure) { setError(authReason(failure)); } finally { setBusy(false); }
198 };
199 const change = () => showConfirmDialog({
200 title: password() ? "Change password" : "Set password", confirmLabel: "save password",
201 body: <>
202 <Show when={password()}><label class="field">current password<input name="current" class="search" type="password" required autocomplete="current-password" /></label></Show>
203 <label class="field">new password<input name="password" class="search" type="password" required minLength={8} maxLength={1024} autocomplete="new-password" /></label>
204 </>,
205 onConfirm: async (form) => {
206 try {
207 const {csrf} = await authRequest<{csrf:string}>("status");
208 await authRequest("password/change", {csrf, current: String(form.get("current") ?? ""), password: String(form.get("password") ?? "")});
209 await refetchAccount(); toast("Saved your password");
210 } catch(failure) { setError(authReason(failure)); throw failure; }
211 },
212 });
213 return <section class="card">
214 <h2 class="card-title">sign-in</h2>
215 <p class="muted">These sign-in methods work with Snowglobe and Files.</p>
216 <div class="credentials">
217 <span class="label">password</span><span>{password() ? "set" : "none"}</span>
218 <button class="button small" onClick={change}>{password() ? "change" : "set password"}</button>
219 <span class="label">passkeys</span><span>{passkeys().length ? "" : "none"}</span>
220 <button class="button small" disabled={busy()} onClick={() => run(() => addPasskey("passkey"))}>add passkey</button>
221 <For each={passkeys()}>{(key) => <>
222 <span /><span>{key.userLabel ?? "unnamed"} <span class="muted"><Ago t={key.createdDate / 1000} /></span></span>
223 <button class="button small" disabled={busy() || props.user.credentials.length <= 1} onClick={() => run(async () => {
224 await parseResponse(api.account.credentials[":id"].$delete({param:{id:key.id}}));
225 })}>remove</button>
226 </>}</For>
227 </div>
228 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
229 </section>;
230}
231
232export function CommitIdentity(props: { user: User; identity: Identity; refetch: () => unknown; admin?: boolean }) {
233 const [busy, setBusy] = createSignal(false);
234 const [error, setError] = createSignal("");
235 const admin = () => props.admin || props.user.groups.some(group => group.name === "infra-admin");
236 const run = async (work: () => Promise<unknown>) => {
237 if (busy()) return;
238 setBusy(true); setError("");
239 try { await work(); await props.refetch(); }
240 catch (failure) { setError(reason(failure)); throw failure; }
241 finally { setBusy(false); }
242 };
243 const addEmail = () => showConfirmDialog({
244 title: "Add commit email", confirmLabel: "add email",
245 description: "An admin must confirm ownership before commits link to your profile.",
246 body: <label class="field">email<input class="search" name="email" type="email" required maxLength={254} autofocus /></label>,
247 onConfirm: async form => {
248 const count = props.identity.emails.length;
249 await run(() => parseResponse(api.account["commit-emails"].$post({json:{email:String(form.get("email"))}})));
250 toast(props.identity.emails.length === count ? "This email is already added" : "Added commit email");
251 },
252 });
253 const addKey = () => showConfirmDialog({
254 title: "Add signing key", confirmLabel: "add key",
255 description: "An admin must confirm key ownership before signatures link to your profile.",
256 body: <>
257 <label class="field">label<input class="search" name="label" maxLength={128} autofocus /></label>
258 <label class="field">SSH public key<textarea class="search" name="publicKey" required maxLength={16384} rows={4} spellcheck={false} /></label>
259 </>,
260 onConfirm: async form => {
261 const count = props.identity.signingKeys.length;
262 await run(() => parseResponse(api.account["signing-keys"].$post({json:{label:String(form.get("label")),publicKey:String(form.get("publicKey"))}})));
263 toast(props.identity.signingKeys.length === count ? "This key is already added; its label stays the same" : "Added signing key");
264 },
265 });
266 return <section class="card commit-identity">
267 <h2 class="card-title">commit identity</h2>
268 <p class="muted">Emails link commit authors to your Shale profile. Signing keys identify signers.</p>
269 <div class="credentials">
270 <span class="label">emails</span><span /><Show when={!props.admin} fallback={<span />}><button class="button small" disabled={busy()} onClick={addEmail}>add email</button></Show>
271 <Show when={props.user.email && props.user.emailVerified}><span /><span class="commit-email">{props.user.email} <span class="muted">primary</span></span><span /></Show>
272 <For each={props.identity.emails}>{email => <>
273 <span /><span class="commit-email">{email.email} <span class="muted">{email.verified ? "confirmed" : "awaiting confirmation"}</span></span>
274 <div class="actions">
275 <Show when={!email.verified && admin()}><button class="button small" aria-label={`Confirm email ${email.email}`} disabled={busy()} onClick={() => showConfirmDialog({
276 title: "Confirm email ownership", confirmLabel: "confirm ownership",
277 description: <>Confirm that {email.email} belongs to this account. Its commits will link to this account's Shale profile.</>,
278 onConfirm: () => run(() => parseResponse(props.admin
279 ? api.users[":id"]["commit-emails"][":email"].$put({param:{id:props.user.id,email:email.email}})
280 : api.account["commit-emails"][":email"].$put({param:{email:email.email}}))),
281 })}>confirm</button></Show>
282 <Show when={!props.admin}><button class="button small" disabled={busy()} aria-label={`Remove email ${email.email}`} onClick={() => showConfirmDialog({
283 title: "Remove commit email", confirmLabel: "remove email", destructive:true,
284 description: <>Commits using {email.email} will lose this profile link.</>,
285 onConfirm: () => run(() => parseResponse(api.account["commit-emails"][":email"].$delete({param:{email:email.email}}))),
286 })}>remove</button></Show>
287 </div>
288 </>}</For>
289 <Show when={!props.identity.emails.length && !(props.user.email && props.user.emailVerified)}><span /><span class="muted">No confirmed emails</span><span /></Show>
290 <span class="label">signing keys</span><span /><Show when={!props.admin} fallback={<span />}><button class="button small" disabled={busy()} onClick={addKey}>add key</button></Show>
291 <For each={props.identity.signingKeys}>{key => <>
292 <span /><span class="commit-key">{key.label || "unnamed key"} <span class="muted">{key.verified ? "confirmed" : "awaiting confirmation"}</span><code class="muted">{key.fingerprint}</code></span>
293 <div class="actions">
294 <Show when={!key.verified && admin()}><button class="button small" aria-label={`Confirm signing key ${key.label || key.fingerprint}`} disabled={busy()} onClick={() => showConfirmDialog({
295 title: "Confirm key ownership", confirmLabel: "confirm ownership",
296 description: <>Confirm that {key.label || key.fingerprint} belongs to this account. Its signatures will link to this account's Shale profile.</>,
297 onConfirm: () => run(() => parseResponse(props.admin
298 ? api.users[":id"]["signing-keys"][":fingerprint"].$put({param:{id:props.user.id,fingerprint:key.fingerprint}})
299 : api.account["signing-keys"][":fingerprint"].$put({param:{fingerprint:key.fingerprint}}))),
300 })}>confirm</button></Show>
301 <Show when={!props.admin}><button class="button small" disabled={busy()} aria-label={`Remove signing key ${key.label || key.fingerprint}`} onClick={() => showConfirmDialog({
302 title:"Remove signing key", confirmLabel:"remove key", destructive:true,
303 description: <>Signatures using {key.label || key.fingerprint} will lose this profile link.</>,
304 onConfirm: () => run(() => parseResponse(api.account["signing-keys"][":fingerprint"].$delete({param:{fingerprint:key.fingerprint}}))),
305 })}>remove</button></Show>
306 </div>
307 </>}</For>
308 <Show when={!props.identity.signingKeys.length}><span /><span class="muted">No signing keys</span><span /></Show>
309 </div>
310 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
311 </section>;
312}