| 1 | import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router"; |
| 2 | import ArrowLeft from "lucide-solid/icons/arrow-left"; |
| 3 | import Search from "lucide-solid/icons/search"; |
| 4 | import UserPlus from "lucide-solid/icons/user-plus"; |
| 5 | import X from "lucide-solid/icons/x"; |
| 6 | import { type Accessor, createEffect, createSignal, For, type Resource, Show } from "solid-js"; |
| 7 | import { Dynamic } from "solid-js/web"; |
| 8 | import { parseResponse } from "hono/client"; |
| 9 | import { canOpen, sectionsOf, type ServiceSummary } from "../types/model.ts"; |
| 10 | import type { Group } from "../types/users.ts"; |
| 11 | import { api, query, reason } from "../api.ts"; |
| 12 | import { Ago } from "../components/Ago.tsx"; |
| 13 | import { Checkbox } from "../components/Checkbox.tsx"; |
| 14 | import { AppIcon } from "../components/AppIcon.tsx"; |
| 15 | import { Copy } from "../components/Copy.tsx"; |
| 16 | import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; |
| 17 | import { ListPage } from "../components/ListPage.tsx"; |
| 18 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; |
| 19 | import { Reveal } from "../components/Reveal.tsx"; |
| 20 | import { PAGES } from "../components/Sidebar.tsx"; |
| 21 | import { TabBar } from "../components/TabBar.tsx"; |
| 22 | import { toast } from "../components/Toast.tsx"; |
| 23 | import { ago, count, date, datetime, plural } from "../format.ts"; |
| 24 | import "./Users.css"; |
| 25 | |
| 26 | const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]]; |
| 27 | |
| 28 | const STATES = { all: "users", guests: "guests", disabled: "disabled", pending: "setup pending" } as const; |
| 29 | |
| 30 | type Params = { q?: string; group?: string; state?: keyof typeof STATES }; |
| 31 | |
| 32 | const load = () => Promise.all([parseResponse(api.users.$get()), parseResponse(api.services.$get())]) |
| 33 | .then(([{ users, groups }, services]) => ({ users, groups, services })); |
| 34 | type Data = Awaited<ReturnType<typeof load>>; |
| 35 | export const directory = query("users", load); |
| 36 | const credentialsOf = query("credentials", (id: string) => parseResponse(api.users[":id"].credentials.$get({ param: { id } }))); |
| 37 | type User = Data["users"][number]; |
| 38 | |
| 39 | const fullName = (user: User) => [user.firstName, user.lastName].filter(Boolean).join(" "); |
| 40 | const names = (user: User) => user.groups.map((group) => group.name); |
| 41 | const 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. */ |
| 46 | function 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. */ |
| 56 | function 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" */ |
| 67 | function 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 | |
| 77 | const 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. */ |
| 81 | function 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. */ |
| 89 | function 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. */ |
| 96 | let listScroll = 0; |
| 97 | /** Whether the open user page was reached from the list, so "back" can return to it with its filters. */ |
| 98 | let fromList = false; |
| 99 | |
| 100 | export 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 | |
| 111 | function 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. */ |
| 128 | function 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 | |
| 151 | function 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. */ |
| 426 | function useBack() { |
| 427 | const navigate = useNavigate(); |
| 428 | return () => (fromList ? history.back() : navigate("/users")); |
| 429 | } |
| 430 | |
| 431 | function 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 | |
| 465 | function 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. */ |
| 684 | function 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 | } |