1import { useSearchParams } from "@solidjs/router";
2import { createResource, createSignal, For, Show } from "solid-js";
3import { parseResponse } from "hono/client";
4import type { User } 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 </div>
77 )}
78 </Loaded>
79 </div>
80 );
81}
82
83type Self = NonNullable<typeof account.latest>;
84
85function 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
189function 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}