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