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