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