1import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router";
2import ArrowLeft from "lucide-solid/icons/arrow-left";
3import Search from "lucide-solid/icons/search";
4import UserPlus from "lucide-solid/icons/user-plus";
5import X from "lucide-solid/icons/x";
6import { type Accessor, createSignal, For, type Resource, Show } from "solid-js";
7import { Dynamic } from "solid-js/web";
8import { parseResponse } from "hono/client";
9import { canOpen, sectionsOf, type ServiceSummary } from "../types/model.ts";
10import type { Group } from "../types/users.ts";
11import { api, query, reason } from "../api.ts";
12import { Ago } from "../components/Ago.tsx";
13import { Checkbox } from "../components/Checkbox.tsx";
14import { AppIcon } from "../components/AppIcon.tsx";
15import { Copy } from "../components/Copy.tsx";
16import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx";
17import { ListPage } from "../components/ListPage.tsx";
18import { OpenApp } from "../components/OpenApp.tsx";
19import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
20import { Reveal } from "../components/Reveal.tsx";
21import { PAGES } from "../components/Sidebar.tsx";
22import { TabBar } from "../components/TabBar.tsx";
23import { toast } from "../components/Toast.tsx";
24import { ago, count, date, datetime, plural } from "../format.ts";
25import "./Users.css";
26
27const STEPS: [string, string][] = [
28 ["VERIFY_EMAIL", "verify email"],
29 ["UPDATE_PASSWORD", "new password"],
30 ["UPDATE_PROFILE", "check profile"],
31 ["CONFIGURE_TOTP", "add authenticator"],
32 ["webauthn-register-passwordless", "add passkey"],
33];
34
35const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const;
36
37type Params = { q?: string; group?: string; state?: keyof typeof STATES };
38
39const load = () => Promise.all([parseResponse(api.users.$get()), parseResponse(api.services.$get())])
40 .then(([{ users, groups }, services]) => ({ users, groups, services }));
41type Data = Awaited<ReturnType<typeof load>>;
42export const directory = query("users", load);
43const credentialsOf = query("credentials", (id: string) => parseResponse(api.users[":id"].credentials.$get({ param: { id } })));
44type User = Data["users"][number];
45
46const fullName = (user: User) => [user.firstName, user.lastName].filter(Boolean).join(" ");
47const names = (user: User) => user.groups.map((group) => group.name);
48const inState = (user: User, state: keyof typeof STATES) =>
49 state === "all" || (state === "disabled" ? !user.enabled : user.enabled && user.requiredActions.length > 0);
50
51/** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */
52function reach(groups: string[], services: ServiceSummary[]) {
53 const apps = services.filter((app) => app.url);
54 const open = {
55 apps: apps.filter((app) => canOpen(groups, app.access)),
56 pages: PAGES.filter((page) => sectionsOf(groups).includes(page.section)),
57 };
58 return { ...open, everything: open.apps.length === apps.length && open.pages.length === PAGES.length };
59}
60
61/** What joining `group` opens beyond what every signed-in user already can. */
62function adds(group: string, services: ServiceSummary[]) {
63 const base = reach([], services);
64 const own = reach([group], services);
65 return {
66 everything: own.everything,
67 apps: own.apps.filter((app) => !base.apps.includes(app)),
68 pages: own.pages.filter((page) => !base.pages.includes(page)),
69 };
70}
71
72/** "30 members · opens Jellyfin, Navidrome" */
73function groupTip(group: string, d: Data) {
74 const more = adds(group, d.services);
75 const members = d.users.filter((user) => names(user).includes(group)).length;
76 const metrics = sectionsOf([group]).includes("metrics") ? ["machine metrics"] : [];
77 const opens = more.everything ? "everything"
78 : [...more.apps.map((app) => app.name), ...more.pages.map((page) => page.label), ...metrics].join(", ");
79 return `${plural(members, "member")} · opens ${opens}`;
80}
81
82/** The app behind a Keycloak `clientId`, which is its service id. */
83const appName = (clientId: string, services: ServiceSummary[]) =>
84 services.find((service) => service.id === clientId)?.name ?? clientId;
85
86/** "active 3h ago in Jellyfin, Shale", from their open sessions. */
87function seen(user: User, services: ServiceSummary[]) {
88 if (!user.sessions.length) return "not signed in";
89 const last = Math.max(...user.sessions.map((session) => session.lastAccess));
90 const used = [...new Set(user.sessions.flatMap((session) => Object.values(session.clients)))].map((id) => appName(id, services));
91 return `active ${ago(last / 1000)} in ${used.join(", ")}`;
92}
93
94/** Tailscale hands out 100.64.0.0/10; the rest of the private ranges are the home network. */
95function network(ip: string) {
96 const [a, b] = ip.split(".").map(Number) as [number, number];
97 if (a === 100 && b >= 64 && b < 128) return "via Tailscale";
98 if (a === 10 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168)) return "local network";
99}
100
101/** Keycloak's admin console at `path` inside the realm. */
102function KeycloakLink(props: { services: ServiceSummary[] | undefined; path: string }) {
103 return (
104 <Show when={props.services?.find((service) => service.id === "keycloak" && service.url)}>
105 {(keycloak) => <OpenApp app={keycloak()} href={`${keycloak().url}/admin/master/console/#/master/${props.path}`} />}
106 </Show>
107 );
108}
109
110/** Where the list was scrolled when a user page opened, so going back lands in the same place. */
111let listScroll = 0;
112/** Whether the open user page was reached from the list, so "back" can return to it with its filters. */
113let fromList = false;
114
115export function Users() {
116 const params = useParams<{ name?: string }>();
117 const [data, { refetch }] = directory.use();
118 const ready = lastGood(data);
119 return (
120 <Show when={params.name} keyed fallback={<List data={data} ready={ready} refetch={refetch} />}>
121 {(name) => <Person name={name} data={data} refetch={refetch} />}
122 </Show>
123 );
124}
125
126function StateTag(props: { user: User }) {
127 return (
128 <Show when={props.user.enabled} fallback={<span class="chip">disabled</span>}>
129 <Show when={props.user.requiredActions.length}>
130 <span class="chip warn" tabindex="0"
131 data-tip={props.user.requiredActions.map((action) => STEPS.find(([step]) => step === action)?.[1] ?? action).join(", ")}>
132 setup pending
133 </span>
134 </Show>
135 </Show>
136 );
137}
138
139/** Apps and dashboard pages; `used` links each to its page and tips an app with when it was last used. */
140function Grants(props: { apps: ServiceSummary[]; pages: typeof PAGES; used?: Map<string, number> }) {
141 const tag = () => (props.used ? A : "span");
142 return (
143 <span class="grants">
144 <For each={props.apps}>
145 {(app) => (
146 <Dynamic component={tag()} class="grant" href={`/services/${app.id}`}
147 data-tip={props.used?.has(app.id) ? `used ${ago(props.used.get(app.id)! / 1000)}` : undefined}>
148 <AppIcon id={app.id} name={app.name} icon={app.icon} />{app.name}
149 </Dynamic>
150 )}
151 </For>
152 <For each={props.pages}>
153 {(page) => (
154 <Dynamic component={tag()} class="grant dash" href={page.href} data-tip="dashboard page">
155 <page.icon class="icon" />{page.label}
156 </Dynamic>
157 )}
158 </For>
159 </span>
160 );
161}
162
163function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>; refetch: () => void }) {
164 const [params, setParams] = useSearchParams<Params>();
165 const navigate = useNavigate();
166 const state = () => params.state ?? "all";
167 const ready = props.ready;
168 fromList = false;
169 const pick = (group?: string) => setParams({ group: group === params.group ? undefined : group });
170 const href = (user: User) => `/users/${user.username}`;
171 const open = (user: User) => {
172 fromList = true;
173 navigate(href(user));
174 };
175 const shown = (users: User[]) => {
176 const q = params.q?.trim().toLowerCase() ?? "";
177 return users
178 .filter((user) => !q || [user.username, fullName(user), user.email, ...names(user)].some((v) => v?.toLowerCase().includes(q)))
179 .filter((user) => !params.group || names(user).includes(params.group))
180 .filter((user) => inState(user, state()))
181 .toSorted((a, b) => a.username.localeCompare(b.username));
182 };
183 const create = (groups: Group[]) => showConfirmDialog({
184 title: "New user",
185 confirmLabel: "create user",
186 body: () => {
187 const [invite, setInvite] = createSignal(true);
188 return (
189 <>
190 <label class="field">username<input name="username" class="search" required autocomplete="off" spellcheck={false} autofocus /></label>
191 <label class="field">email<input name="email" type="email" class="search" autocomplete="off" spellcheck={false} /></label>
192 <div class="row">
193 <label class="field">first name<input name="firstName" class="search" autocomplete="off" /></label>
194 <label class="field">last name<input name="lastName" class="search" autocomplete="off" /></label>
195 </div>
196 <div class="field" role="group" aria-label="Groups">
197 groups
198 <span class="toggles">
199 <For each={groups}>
200 {(g) => <label class="chip toggle"><input type="checkbox" name="groups" value={g.id} />{g.name}</label>}
201 </For>
202 </span>
203 </div>
204 <div class="field">
205 first sign-in
206 <TabBar label="First sign-in">
207 <button type="button" aria-pressed={invite()} onClick={() => setInvite(true)}>email an invite</button>
208 <button type="button" aria-pressed={!invite()} onClick={() => setInvite(false)}>set a password</button>
209 </TabBar>
210 </div>
211 <Show when={!invite()}>
212 <label class="field">temporary password
213 <input name="password" class="search" required minLength={8} autocomplete="off" spellcheck={false} />
214 <span class="hint">They pick their own at first sign-in</span>
215 </label>
216 </Show>
217 </>
218 );
219 },
220 onConfirm: async (form) => {
221 const text = (name: string) => String(form.get(name) ?? "");
222 const { id } = await parseResponse(api.users.$post({
223 json: {
224 profile: { username: text("username"), email: text("email"), firstName: text("firstName"), lastName: text("lastName") },
225 groups: form.getAll("groups").map(String),
226 setup: form.has("password") ? { kind: "password", password: text("password") } : { kind: "email" },
227 },
228 }));
229 await props.refetch();
230 const user = ready()?.users.find((user) => user.id === id);
231 if (user) open(user);
232 },
233 });
234
235 return (
236 <ListPage id="users" flush head={
237 <div class="page-head">
238 <h1>users</h1>
239 <KeycloakLink services={ready()?.services} path="users" />
240 <span class="spacer" />
241 <button class="button primary" disabled={!ready()} onClick={() => create(ready()!.groups)}>
242 <UserPlus size={14} />new user
243 </button>
244 </div>
245 } summary={
246 <Show when={ready()} fallback={props.data.loading && (
247 <div class="card access" inert>
248 <For each={Array(8)}>
249 {(_, i) => (
250 <div class="access-row">
251 <span class="skeleton" style={{ height: "12px" }} />
252 <span />
253 <span class="skeleton" style={{ height: "12px", width: `${20 + ((i() * 37) % 50)}%` }} />
254 </div>
255 )}
256 </For>
257 </div>
258 )}>
259 {(d) => {
260 const everyone = () => reach([], d().services);
261 const rows = () => d().groups.map((group) => ({
262 group, ...adds(group.name, d().services),
263 members: d().users.filter((user) => names(user).includes(group.name)).map((user) => user.username).sort(),
264 }));
265 const preview = (members: string[]) =>
266 members.length > 6 ? `${members.slice(0, 5).join(", ")} +${members.length - 5} more` : members.join(", ");
267 return (
268 <div class="card access" role="group" aria-label="Filter by group">
269 <button class="access-row" aria-pressed={!params.group} onClick={() => pick()}>
270 <span class="who">everyone</span>
271 <span class="num">{count(d().users.length)}</span>
272 <Grants apps={everyone().apps} pages={everyone().pages} />
273 </button>
274 <For each={rows()}>
275 {(row) => (
276 <button class="access-row" aria-pressed={params.group === row.group.name} onClick={() => pick(row.group.name)}
277 data-tip={row.members.length ? preview(row.members) : undefined}>
278 <span class="who">{row.group.name}</span>
279 <span class="num">{count(row.members.length)}</span>
280 <Show when={!row.everything} fallback={<span class="grants muted">everything</span>}>
281 <Grants apps={row.apps} pages={row.pages} />
282 </Show>
283 </button>
284 )}
285 </For>
286 </div>
287 );
288 }}
289 </Show>
290 }>
291 <Loaded data={props.data} what="users" retry={props.refetch} skeleton={
292 <>
293 <div class="users-toolbar"><span class="skeleton" style={{ width: "260px", height: "30px" }} /></div>
294 <div class="edge"><SkeletonRows count={8} /></div>
295 </>
296 }>
297 {(d) => (
298 <>
299 <div class="users-toolbar">
300 <label class="search-box">
301 <Search size={14} aria-hidden="true" />
302 <input type="search" placeholder="mika, Tanaka, media…" aria-label="Search users" value={params.q ?? ""}
303 onInput={(event) => setParams({ q: event.currentTarget.value || undefined }, { replace: true })} />
304 </label>
305 <Reveal when={!!params.group} axis="x">
306 <button class="chip filter" aria-label={`Show every group, not just ${params.group}`} data-tip="show every group"
307 onClick={() => pick()}>
308 {params.group}<X size={12} />
309 </button>
310 </Reveal>
311 <span class="spacer" />
312 <TabBar label="Filter by state">
313 <For each={Object.entries(STATES) as [keyof typeof STATES, string][]}>
314 {([key, label]) => (
315 <button aria-pressed={state() === key} onClick={() => setParams({ state: key === "all" ? undefined : key })}>
316 {label}
317 <Show when={key !== "all"}>
318 <span class="count">{count(d().users.filter((user) => inState(user, key)).length)}</span>
319 </Show>
320 </button>
321 )}
322 </For>
323 </TabBar>
324 </div>
325 <div class="fill users-table" ref={(el) => requestAnimationFrame(() => (el.scrollTop = listScroll))}
326 onScroll={(event) => (listScroll = event.currentTarget.scrollTop)}>
327 <Show when={shown(d().users).length} fallback={
328 <div class="empty">
329 No users match.{" "}
330 <button class="button small" onClick={() => setParams({ q: undefined, group: undefined, state: undefined })}>
331 clear filters
332 </button>
333 </div>
334 }>
335 <table class="data">
336 <thead><tr><th>username</th><th>name</th><th>email</th><th>groups</th></tr></thead>
337 <tbody>
338 <For each={shown(d().users)}>
339 {(user) => (
340 <tr classList={{ disabled: !user.enabled }}
341 onClick={(event) => !(event.target as Element).closest("a, button") && open(user)}>
342 <td class="username">
343 <span class="inline">
344 <a class="name" href={href(user)} onClick={() => (fromList = true)} data-tip={seen(user, d().services)}>
345 {user.username}
346 </a>
347 <StateTag user={user} />
348 </span>
349 </td>
350 <td>{fullName(user) || "–"}</td>
351 <td class="email">
352 <Show when={user.email} fallback="–">{(email) => <Copy value={email()} />}</Show>
353 </td>
354 <td class="groups">
355 <div class="chips">
356 <For each={user.groups}>
357 {(g) => (
358 <button class="chip" aria-pressed={params.group === g.name} data-tip={groupTip(g.name, d())}
359 onClick={() => pick(g.name)}>
360 {g.name}
361 </button>
362 )}
363 </For>
364 </div>
365 </td>
366 </tr>
367 )}
368 </For>
369 </tbody>
370 </table>
371 </Show>
372 </div>
373 </>
374 )}
375 </Loaded>
376 </ListPage>
377 );
378}
379
380/** Back to the list, through history when it's the page behind this one, so its filters and scroll come back. */
381function useBack() {
382 const navigate = useNavigate();
383 return () => (fromList ? history.back() : navigate("/users"));
384}
385
386function Person(props: { name: string; data: Resource<Data>; refetch: () => unknown }) {
387 const back = useBack();
388 return (
389 <Loaded data={props.data} what="users" retry={props.refetch} skeleton={
390 <div class="page user-page">
391 <div class="page-head"><span class="skeleton" style={{ width: "180px", height: "26px" }} /></div>
392 <div class="user-grid">
393 <For each={[0, 1]}>
394 {() => (
395 <div class="column">
396 <div class="skeleton" style={{ height: "180px" }} />
397 <div class="skeleton" style={{ height: "140px" }} />
398 </div>
399 )}
400 </For>
401 </div>
402 </div>
403 }>
404 {(d) => (
405 <Show when={d().users.find((user) => user.username === props.name)} fallback={
406 <div class="page user-page">
407 <div class="empty">
408 No user named {props.name}.{" "}
409 <button class="button small" onClick={back}>back to users</button>
410 </div>
411 </div>
412 }>
413 {(user) => <Profile user={user()} data={d()} refetch={props.refetch} />}
414 </Show>
415 )}
416 </Loaded>
417 );
418}
419
420function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
421 const navigate = useNavigate();
422 const back = useBack();
423 const param = () => ({ id: props.user.id });
424 const [credentials, credentialActions] = credentialsOf.use(() => props.user.id);
425 const [busy, setBusy] = createSignal<string>();
426
427 const patch = async (json: Parameters<typeof api.users[":id"]["$patch"]>[0]["json"]) => {
428 await parseResponse(api.users[":id"].$patch({ param: param(), json }));
429 await props.refetch();
430 };
431 /** Runs a quick change, marking `key` busy and toasting the reason if it fails. */
432 const run = async (key: string, change: () => Promise<unknown>) => {
433 setBusy(key);
434 try {
435 await change();
436 } catch (failure) {
437 toast(reason(failure));
438 } finally {
439 setBusy();
440 }
441 };
442 const member = (group: Group) => props.user.groups.some((g) => g.id === group.id);
443 const toggleGroup = (group: Group) => run(group.id, async () => {
444 const route = api.users[":id"].groups[":group"];
445 const args = { param: { ...param(), group: group.id } };
446 await parseResponse(member(group) ? route.$delete(args) : route.$put(args));
447 await props.refetch();
448 });
449 const setEnabled = (enabled: boolean) => run("enabled", async () => {
450 await patch({ enabled });
451 if (!enabled) toast(`${props.user.username} can't sign in now`, { label: "undo", run: () => patch({ enabled: true }) });
452 });
453 const toggleStep = (step: string) => run(step, () => patch({
454 requiredActions: props.user.requiredActions.includes(step)
455 ? props.user.requiredActions.filter((a) => a !== step)
456 : [...props.user.requiredActions, step],
457 }));
458 const emailSteps = () => run("email", async () => {
459 await parseResponse(api.users[":id"]["actions-email"].$post({ param: param() }));
460 toast(`Emailed ${props.user.email} a link`);
461 });
462
463 const setPassword = () => showTextDialog({
464 title: `Set ${props.user.username}'s password`,
465 label: "new password, at least 8 characters",
466 body: <Checkbox name="temporary" checked>ask for a new one at next sign-in</Checkbox>,
467 confirmLabel: "set password",
468 validateInput: (value) => value.length >= 8,
469 onConfirm: async (password, form) => {
470 await parseResponse(api.users[":id"].password.$put({ param: param(), json: { password, temporary: form.has("temporary") } }));
471 await Promise.all([props.refetch(), credentialActions.refetch()]);
472 },
473 });
474 const signOut = (sessions: number) => showConfirmDialog({
475 title: `Sign ${props.user.username} out?`,
476 description: `${sessions === 1 ? "Ends their one session" : `Ends all ${sessions} sessions`}. They can sign in again.`,
477 confirmLabel: "sign out",
478 onConfirm: async () => {
479 await parseResponse(api.users[":id"].logout.$post({ param: param() }));
480 await props.refetch();
481 },
482 });
483 const remove = () => {
484 const name = props.user.username;
485 showTextDialog({
486 title: `Delete ${name}?`,
487 description: `${name} is signed out and loses access to everything. This can't be undone.`,
488 label: `type ${name} to confirm`,
489 validateInput: (value) => value === name,
490 confirmLabel: "delete user",
491 destructive: true,
492 onConfirm: async () => {
493 await parseResponse(api.users[":id"].$delete({ param: param() }));
494 back();
495 await props.refetch();
496 toast(`Deleted ${name}`);
497 },
498 });
499 };
500
501 const access = () => reach(props.user.enabled ? names(props.user) : [], props.data.services);
502 /** Last use of each app, by the Keycloak `clientId` its sessions went through. */
503 const used = () => {
504 const last = new Map<string, number>();
505 for (const session of props.user.sessions) {
506 for (const id of Object.values(session.clients)) last.set(id, Math.max(last.get(id) ?? 0, session.lastAccess));
507 }
508 return last;
509 };
510
511 return (
512 <div class="page user-page">
513 <div class="page-head">
514 <button class="button icon-only" aria-label="Back to users" data-tip="users" onClick={back}>
515 <ArrowLeft size={16} />
516 </button>
517 <h1>{props.user.username}</h1>
518 <span class="sub">{fullName(props.user)}</span>
519 <StateTag user={props.user} />
520 <KeycloakLink services={props.data.services} path={`users/${props.user.id}/settings`} />
521 <span class="spacer" />
522 <button class="button danger" onClick={remove}>delete user</button>
523 </div>
524
525 <div class="user-grid">
526 <div class="column">
527 <section class="card">
528 <h2 class="card-title">groups</h2>
529 <div class="toggles" role="group" aria-label="Groups">
530 <For each={props.data.groups}>
531 {(group) => (
532 <button class="chip toggle" aria-pressed={member(group)} disabled={busy() === group.id}
533 data-tip={groupTip(group.name, props.data)} onClick={() => toggleGroup(group)}>
534 {group.name}
535 </button>
536 )}
537 </For>
538 </div>
539 <h2 class="card-title opens">
540 can open
541 </h2>
542 <Show when={props.user.enabled} fallback={<p class="muted">nothing while disabled</p>}>
543 <Show when={!access().everything} fallback={<p class="muted">everything</p>}>
544 <Grants apps={access().apps} pages={access().pages} used={used()} />
545 </Show>
546 </Show>
547 </section>
548 <section class="card">
549 <h2 class="card-title">
550 sign-in
551 <span class="spacer" />
552 <button class="button small" onClick={setPassword}>set password</button>
553 </h2>
554 <Loaded data={credentials} what="sign-in methods" retry={credentialActions.refetch}
555 skeleton={<div class="skeleton" style={{ height: "60px" }} />}>
556 {(list) => {
557 const password = () => list().find((c) => c.type === "password");
558 const passkeys = () => list().filter((c) => c.type.startsWith("webauthn"));
559 const otp = () => list().find((c) => c.type === "otp");
560 return (
561 <dl class="kv">
562 <dt>password</dt>
563 <dd>{password() ? `set ${date(password()!.createdDate / 1000)}` : "none"}</dd>
564 <dt>passkeys</dt>
565 <dd class="passkeys">
566 <For each={passkeys()} fallback="none">
567 {(c) => <span tabindex="0" data-tip={`added ${date(c.createdDate / 1000)}`}>{c.userLabel ?? "unnamed"}</span>}
568 </For>
569 </dd>
570 <dt>authenticator</dt>
571 <dd>{otp() ? `added ${date(otp()!.createdDate / 1000)}` : "none"}</dd>
572 </dl>
573 );
574 }}
575 </Loaded>
576 <h2 class="card-title opens">
577 next sign-in
578 <span class="spacer" />
579 <button class="button small" disabled={!props.user.email || !props.user.requiredActions.length || busy() === "email"}
580 aria-busy={busy() === "email"}
581 data-tip={!props.user.email ? "Add an email address first" : !props.user.requiredActions.length
582 ? "Pick a step first" : `Send ${props.user.email} a link to do these steps now`}
583 onClick={emailSteps}>
584 send email
585 </button>
586 </h2>
587 <div class="toggles" role="group" aria-label="Steps at next sign-in">
588 <For each={STEPS}>
589 {([step, label]) => (
590 <button class="chip toggle" aria-pressed={props.user.requiredActions.includes(step)} disabled={busy() === step}
591 onClick={() => toggleStep(step)}>
592 {label}
593 </button>
594 )}
595 </For>
596 </div>
597 </section>
598 </div>
599
600 <div class="column">
601 <section class="card">
602 <h2 class="card-title">profile</h2>
603 <div class="fields">
604 <Field label="username" value={props.user.username} onSave={async (username) => {
605 await parseResponse(api.users[":id"].$patch({ param: param(), json: { username } }));
606 await props.refetch();
607 navigate(`/users/${username.trim().toLowerCase()}`, { replace: true });
608 }} />
609 <Field label="email" type="email" value={props.user.email} onSave={(email) => patch({ email })} />
610 <span />
611 <Checkbox checked={props.user.emailVerified} disabled={busy() === "verified"}
612 onChange={(emailVerified) => run("verified", () => patch({ emailVerified }))}>
613 verified
614 </Checkbox>
615 <Field label="first name" value={props.user.firstName} onSave={(firstName) => patch({ firstName })} />
616 <Field label="last name" value={props.user.lastName} onSave={(lastName) => patch({ lastName })} />
617 <span class="label">sign-in</span>
618 <Checkbox checked={props.user.enabled} disabled={busy() === "enabled"} onChange={setEnabled}>allowed</Checkbox>
619 <span class="label">created</span>
620 <span><Ago t={props.user.createdTimestamp / 1000} /></span>
621 </div>
622 </section>
623
624 <section class="card">
625 <h2 class="card-title">
626 sessions
627 <span class="spacer" />
628 <button class="button small" disabled={!props.user.sessions.length}
629 onClick={() => signOut(props.user.sessions.length)}>
630 sign out
631 </button>
632 </h2>
633 <Show when={props.user.sessions.length} fallback={<p class="muted">Not signed in anywhere</p>}>
634 <table class="data">
635 <tbody>
636 <For each={props.user.sessions}>
637 {(session) => (
638 <tr>
639 <td class="mono"><span tabindex="0" data-tip={network(session.ipAddress)}>{session.ipAddress}</span></td>
640 <td>{Object.values(session.clients).map((id) => appName(id, props.data.services)).join(", ")}</td>
641 <td class="num">
642 <time tabindex="0" datetime={new Date(session.lastAccess).toISOString()}
643 data-tip={`signed in ${datetime(session.start / 1000)}`}>
644 {ago(session.lastAccess / 1000)}
645 </time>
646 </td>
647 </tr>
648 )}
649 </For>
650 </tbody>
651 </table>
652 </Show>
653 </section>
654 </div>
655 </div>
656 </div>
657 );
658}
659
660/** A profile value that is always an input: saves on Enter or blur, Esc puts the old value back. */
661function Field(props: { label: string; value: string | null; type?: string; onSave: (value: string) => Promise<unknown> }) {
662 const [error, setError] = createSignal("");
663 const [pending, setPending] = createSignal(false);
664 const id = `field-${props.label.replace(/\W+/g, "-")}`;
665 return (
666 <>
667 <label class="label" for={id}>{props.label}</label>
668 <span class="field">
669 <input id={id} class="inline" type={props.type ?? "text"} value={props.value ?? ""} placeholder="–" autocomplete="off"
670 spellcheck={false} readOnly={pending()} aria-busy={pending()} aria-invalid={!!error()}
671 aria-describedby={error() ? `${id}-error` : undefined}
672 onChange={async (event) => {
673 const input = event.currentTarget;
674 if (input.value === (props.value ?? "")) return setError("");
675 setPending(true);
676 try {
677 await props.onSave(input.value);
678 setError("");
679 } catch (failure) {
680 setError(reason(failure));
681 } finally {
682 setPending(false);
683 }
684 }}
685 onKeyDown={(event) => {
686 if (event.key === "Enter") event.currentTarget.blur();
687 if (event.key === "Escape") {
688 event.currentTarget.value = props.value ?? "";
689 setError("");
690 event.currentTarget.blur();
691 }
692 }} />
693 <Show when={error()}><span id={`${id}-error`} class="field-error" role="alert">{error()}</span></Show>
694 </span>
695 </>
696 );
697}