From d09317c7b9734286c81a4122277b8373bb5726b3 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 13 Jul 2026 23:04:49 -0400 Subject: [PATCH] feat(admin): add users and organizations admin verbs Add terminal access to the instance admin endpoints. seamless users covers list (with client-side --limit/--offset paging and --json), delete (with confirmation, via the DELETE /admin/users body userId), credentials (from the GET /admin/users/:id detail response), and prepare-device-replacement for admin-assisted recovery. seamless org covers list, create, get, and update, and seamless org members covers list, add (by --user id or --email, with --roles/--scopes), update, and remove (with confirmation). Every command surfaces a 403 as a clear permission error, and device replacement explains the step-up requirement when the session is not elevated. The network layer lives in core/admin.ts and is unit tested against an injected client; commands/users.ts and commands/org.ts are the front ends. Accepts --profile and honors SEAMLESS_PROFILE. Closes #46 --- .changeset/admin-verbs.md | 14 ++ src/commands/adminShared.ts | 23 +++ src/commands/help.ts | 27 ++++ src/commands/org.ts | 297 ++++++++++++++++++++++++++++++++++++ src/commands/users.ts | 205 +++++++++++++++++++++++++ src/core/admin.test.ts | 188 +++++++++++++++++++++++ src/core/admin.ts | 270 ++++++++++++++++++++++++++++++++ src/index.ts | 12 ++ 8 files changed, 1036 insertions(+) create mode 100644 .changeset/admin-verbs.md create mode 100644 src/commands/adminShared.ts create mode 100644 src/commands/org.ts create mode 100644 src/commands/users.ts create mode 100644 src/core/admin.test.ts create mode 100644 src/core/admin.ts diff --git a/.changeset/admin-verbs.md b/.changeset/admin-verbs.md new file mode 100644 index 0000000..2f07f8a --- /dev/null +++ b/.changeset/admin-verbs.md @@ -0,0 +1,14 @@ +--- +"seamless-cli": minor +--- + +Add admin verbs for users and organizations (requires an admin role). +`seamless users` covers `list` (with client-side `--limit`/`--offset` paging and +`--json`), `delete ` (with confirmation), `credentials ` (from the admin +user detail endpoint), and `prepare-device-replacement ` for admin-assisted +recovery. `seamless org` covers `list`, `create`, `get`, and `update`, and +`seamless org members` covers `list`, `add` (by `--user` id or `--email`, with +`--roles`/`--scopes`), `update`, and `remove` (with confirmation). Every command +surfaces a 403 as a clear permission error, and device replacement explains the +step-up requirement when the CLI session is not elevated. Accepts `--profile` +and honors `SEAMLESS_PROFILE`. diff --git a/src/commands/adminShared.ts b/src/commands/adminShared.ts new file mode 100644 index 0000000..1cb09e2 --- /dev/null +++ b/src/commands/adminShared.ts @@ -0,0 +1,23 @@ +import kleur from "kleur"; +import { ReauthRequiredError } from "../core/authClient.js"; +import { AdminApiError, PermissionError } from "../core/admin.js"; + +export function reportAdminError(err: unknown): never { + if (err instanceof ReauthRequiredError) { + console.log(kleur.yellow(err.message)); + process.exit(1); + } + if (err instanceof PermissionError || err instanceof AdminApiError) { + console.error(kleur.red(err.message)); + process.exit(1); + } + throw err; +} + +export function parseList(value?: string): string[] | undefined { + if (value === undefined) return undefined; + return value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} diff --git a/src/commands/help.ts b/src/commands/help.ts index 89bf0dc..4dfb978 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -21,6 +21,9 @@ USAGE seamless sessions [list] seamless sessions revoke seamless config + seamless users + seamless org + seamless org members seamless --help seamless --version @@ -107,6 +110,30 @@ COMMANDS config apply [--dry-run] • Apply a local JSON config file after a confirmation prompt + users + Admin user management (requires an admin role). + + users list [--limit ] [--offset ] [--json] + • List users + users delete + • Delete a user (asks for confirmation) + users credentials [--json] + • Show a user's registered credentials + users prepare-device-replacement [--keep-sessions] [--keep-passkeys] [--keep-totp] + • Admin-assisted account recovery (needs an elevated session) + + org , org members + Admin organization management (requires an admin role). + + org list [--json] + org create [--slug ] + org get [--json] + org update [--name ] [--slug ] + org members list [--json] + org members add (--user | --email ) [--roles a,b] [--scopes a,b] + org members update [--roles a,b] [--scopes a,b] + org members remove + check Validate project setup, Docker, and running services diff --git a/src/commands/org.ts b/src/commands/org.ts new file mode 100644 index 0000000..a886406 --- /dev/null +++ b/src/commands/org.ts @@ -0,0 +1,297 @@ +import { confirm, isCancel } from "@clack/prompts"; +import kleur from "kleur"; +import { extractFlag } from "../core/args.js"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + addMember, + createOrg, + getOrg, + listMembers, + listOrgs, + removeMember, + updateMember, + updateOrg, + type Json, +} from "../core/admin.js"; +import { parseList, reportAdminError } from "./adminShared.js"; + +export async function runOrg(args: string[]): Promise { + if (args[0] === "members") { + await runOrgMembers(args.slice(1)); + return; + } + + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "list": + await orgList(client, rest); + return; + case "create": + await orgCreate(client, rest); + return; + case "get": + await orgGet(client, rest); + return; + case "update": + await orgUpdate(client, rest); + return; + default: + console.error(kleur.red(`Unknown org subcommand: ${sub ?? "(none)"}`)); + console.log("Usage: seamless org "); + process.exit(1); + } + } catch (err) { + reportAdminError(err); + } +} + +async function runOrgMembers(args: string[]): Promise { + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "list": + await membersList(client, rest); + return; + case "add": + await membersAdd(client, rest); + return; + case "update": + await membersUpdate(client, rest); + return; + case "remove": + await membersRemove(client, rest); + return; + default: + console.error(kleur.red(`Unknown org members subcommand: ${sub ?? "(none)"}`)); + console.log("Usage: seamless org members "); + process.exit(1); + } + } catch (err) { + reportAdminError(err); + } +} + +async function orgList(client: AuthClient, rest: string[]): Promise { + const json = rest.includes("--json"); + const { organizations, total } = await listOrgs(client); + + if (json) { + console.log(JSON.stringify(organizations, null, 2)); + return; + } + if (organizations.length === 0) { + console.log(kleur.dim("No organizations.")); + return; + } + for (const org of organizations) { + printOrgRow(org); + } + console.log(kleur.dim(`${total} organization${total === 1 ? "" : "s"}.`)); +} + +async function orgCreate(client: AuthClient, rest: string[]): Promise { + const slugFlag = extractFlag(rest, "slug"); + const nameFlag = extractFlag(slugFlag.rest, "name"); + const name = nameFlag.value ?? nameFlag.rest.find((a) => !a.startsWith("--")); + if (!name) { + console.error( + kleur.red("Usage: seamless org create [--slug ]"), + ); + process.exit(1); + } + + const body: Json = { name }; + if (slugFlag.value) body.slug = slugFlag.value; + + const org = await createOrg(client, body); + console.log(kleur.green(`Created organization ${str(org, "id") ?? ""}.`)); + printOrg(org); +} + +async function orgGet(client: AuthClient, rest: string[]): Promise { + const json = rest.includes("--json"); + const id = rest.find((a) => !a.startsWith("--")); + if (!id) { + console.error(kleur.red("Usage: seamless org get ")); + process.exit(1); + } + const org = await getOrg(client, id); + if (json) { + console.log(JSON.stringify(org, null, 2)); + return; + } + printOrg(org); +} + +async function orgUpdate(client: AuthClient, rest: string[]): Promise { + const nameFlag = extractFlag(rest, "name"); + const slugFlag = extractFlag(nameFlag.rest, "slug"); + const id = slugFlag.rest.find((a) => !a.startsWith("--")); + if (!id) { + console.error( + kleur.red("Usage: seamless org update [--name ] [--slug ]"), + ); + process.exit(1); + } + + const body: Json = {}; + if (nameFlag.value) body.name = nameFlag.value; + if (slugFlag.value) body.slug = slugFlag.value; + if (Object.keys(body).length === 0) { + console.error(kleur.red("Nothing to update. Pass --name and/or --slug.")); + process.exit(1); + } + + const org = await updateOrg(client, id, body); + console.log(kleur.green(`Updated organization ${id}.`)); + printOrg(org); +} + +async function membersList(client: AuthClient, rest: string[]): Promise { + const json = rest.includes("--json"); + const orgId = rest.find((a) => !a.startsWith("--")); + if (!orgId) { + console.error(kleur.red("Usage: seamless org members list ")); + process.exit(1); + } + + const { members, total } = await listMembers(client, orgId); + if (json) { + console.log(JSON.stringify(members, null, 2)); + return; + } + if (members.length === 0) { + console.log(kleur.dim("No members.")); + return; + } + for (const member of members) { + printMemberRow(member); + } + console.log(kleur.dim(`${total} member${total === 1 ? "" : "s"}.`)); +} + +async function membersAdd(client: AuthClient, rest: string[]): Promise { + const userFlag = extractFlag(rest, "user"); + const emailFlag = extractFlag(userFlag.rest, "email"); + const rolesFlag = extractFlag(emailFlag.rest, "roles"); + const scopesFlag = extractFlag(rolesFlag.rest, "scopes"); + const orgId = scopesFlag.rest.find((a) => !a.startsWith("--")); + + if (!orgId || (!userFlag.value && !emailFlag.value)) { + console.error( + kleur.red( + "Usage: seamless org members add (--user | --email ) [--roles a,b] [--scopes a,b]", + ), + ); + process.exit(1); + } + + const body: Json = {}; + if (userFlag.value) body.userId = userFlag.value; + if (emailFlag.value) body.email = emailFlag.value; + const roles = parseList(rolesFlag.value); + const scopes = parseList(scopesFlag.value); + if (roles) body.roles = roles; + if (scopes) body.scopes = scopes; + + const membership = await addMember(client, orgId, body); + console.log(kleur.green("Added member.")); + printMemberRow(membership); +} + +async function membersUpdate(client: AuthClient, rest: string[]): Promise { + const rolesFlag = extractFlag(rest, "roles"); + const scopesFlag = extractFlag(rolesFlag.rest, "scopes"); + const positional = scopesFlag.rest.filter((a) => !a.startsWith("--")); + const [orgId, userId] = positional; + + if (!orgId || !userId) { + console.error( + kleur.red( + "Usage: seamless org members update [--roles a,b] [--scopes a,b]", + ), + ); + process.exit(1); + } + + const body: Json = {}; + const roles = parseList(rolesFlag.value); + const scopes = parseList(scopesFlag.value); + if (roles) body.roles = roles; + if (scopes) body.scopes = scopes; + if (Object.keys(body).length === 0) { + console.error(kleur.red("Nothing to update. Pass --roles and/or --scopes.")); + process.exit(1); + } + + const membership = await updateMember(client, orgId, userId, body); + console.log(kleur.green("Updated member.")); + printMemberRow(membership); +} + +async function membersRemove(client: AuthClient, rest: string[]): Promise { + const positional = rest.filter((a) => !a.startsWith("--")); + const [orgId, userId] = positional; + if (!orgId || !userId) { + console.error( + kleur.red("Usage: seamless org members remove "), + ); + process.exit(1); + } + + const proceed = await confirm({ + message: `Remove user ${userId} from organization ${orgId}?`, + initialValue: false, + }); + if (isCancel(proceed) || !proceed) { + console.log("Cancelled."); + return; + } + + await removeMember(client, orgId, userId); + console.log(kleur.green(`Removed user ${userId} from ${orgId}.`)); +} + +function printOrgRow(org: Json): void { + const id = str(org, "id") ?? "(no id)"; + const name = str(org, "name") ?? "(no name)"; + const slug = str(org, "slug"); + console.log( + kleur.bold(name) + kleur.dim(` ${id}`) + (slug ? kleur.dim(` (${slug})`) : ""), + ); +} + +function printOrg(org: Json): void { + const line = (label: string, value: string) => + console.log(kleur.dim(`${label}:`.padEnd(8)) + value); + line("Id", str(org, "id") ?? "(unknown)"); + line("Name", str(org, "name") ?? "(unknown)"); + line("Slug", str(org, "slug") ?? "(none)"); +} + +function printMemberRow(member: Json): void { + const userId = str(member, "userId") ?? "(no user)"; + const roles = Array.isArray(member.roles) + ? (member.roles as unknown[]).filter((r): r is string => typeof r === "string") + : []; + const scopes = Array.isArray(member.scopes) + ? (member.scopes as unknown[]).filter((s): s is string => typeof s === "string") + : []; + console.log( + kleur.bold(userId) + + (roles.length ? kleur.dim(` roles: ${roles.join(", ")}`) : "") + + (scopes.length ? kleur.dim(` scopes: ${scopes.join(", ")}`) : ""), + ); +} + +function str(record: Json, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value ? value : undefined; +} diff --git a/src/commands/users.ts b/src/commands/users.ts new file mode 100644 index 0000000..d8c3da6 --- /dev/null +++ b/src/commands/users.ts @@ -0,0 +1,205 @@ +import { confirm, isCancel } from "@clack/prompts"; +import kleur from "kleur"; +import { extractFlag } from "../core/args.js"; +import { createAuthClient, type AuthClient } from "../core/authClient.js"; +import { + deleteUser, + getUserDetail, + listUsers, + prepareDeviceReplacement, + type Json, +} from "../core/admin.js"; +import { reportAdminError } from "./adminShared.js"; + +export async function runUsers(args: string[]): Promise { + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "list": + await usersList(client, rest); + return; + case "delete": + await usersDelete(client, rest); + return; + case "credentials": + await usersCredentials(client, rest); + return; + case "prepare-device-replacement": + await usersPrepareDeviceReplacement(client, rest); + return; + default: + console.error(kleur.red(`Unknown users subcommand: ${sub ?? "(none)"}`)); + console.log( + "Usage: seamless users ", + ); + process.exit(1); + } + } catch (err) { + reportAdminError(err); + } +} + +async function usersList(client: AuthClient, rest: string[]): Promise { + const json = rest.includes("--json"); + const limitFlag = extractFlag(rest, "limit"); + const offsetFlag = extractFlag(limitFlag.rest, "offset"); + const limit = Number(limitFlag.value ?? "50"); + const offset = Number(offsetFlag.value ?? "0"); + + const { users, total } = await listUsers(client); + + if (json) { + console.log(JSON.stringify(users, null, 2)); + return; + } + + const page = users.slice(offset, offset + limit); + if (page.length === 0) { + console.log(kleur.dim("No users.")); + return; + } + + for (const user of page) { + printUserRow(user); + } + console.log( + kleur.dim( + `Showing ${offset + 1}-${offset + page.length} of ${total} user${ + total === 1 ? "" : "s" + }.`, + ), + ); +} + +async function usersDelete(client: AuthClient, rest: string[]): Promise { + const id = rest.find((arg) => !arg.startsWith("--")); + if (!id) { + console.error(kleur.red("Usage: seamless users delete ")); + process.exit(1); + } + + const proceed = await confirm({ + message: `Permanently delete user ${id}? This cannot be undone.`, + initialValue: false, + }); + if (isCancel(proceed) || !proceed) { + console.log("Cancelled."); + return; + } + + await deleteUser(client, id); + console.log(kleur.green(`Deleted user ${id}.`)); +} + +async function usersCredentials( + client: AuthClient, + rest: string[], +): Promise { + const json = rest.includes("--json"); + const id = rest.find((arg) => !arg.startsWith("--")); + if (!id) { + console.error(kleur.red("Usage: seamless users credentials ")); + process.exit(1); + } + + const { credentials } = await getUserDetail(client, id); + + if (json) { + console.log(JSON.stringify(credentials, null, 2)); + return; + } + + console.log( + `${kleur.bold(String(credentials.length))} credential${ + credentials.length === 1 ? "" : "s" + } for user ${id}`, + ); + for (const credential of credentials) { + const name = + str(credential, "deviceName") ?? + str(credential, "name") ?? + str(credential, "type") ?? + "credential"; + const id = str(credential, "id") ?? str(credential, "credentialId") ?? ""; + const created = str(credential, "createdAt"); + console.log( + " " + + kleur.bold(name) + + (id ? kleur.dim(` ${id}`) : "") + + (created ? kleur.dim(` added ${created}`) : ""), + ); + } +} + +async function usersPrepareDeviceReplacement( + client: AuthClient, + rest: string[], +): Promise { + const id = rest.find((arg) => !arg.startsWith("--")); + if (!id) { + console.error( + kleur.red("Usage: seamless users prepare-device-replacement "), + ); + process.exit(1); + } + + const opts = { + revokeSessions: !rest.includes("--keep-sessions"), + removePasskeys: !rest.includes("--keep-passkeys"), + disableTotp: !rest.includes("--keep-totp"), + }; + + const actions = [ + opts.revokeSessions ? "revoke all sessions" : null, + opts.removePasskeys ? "remove passkeys" : null, + opts.disableTotp ? "disable TOTP" : null, + ].filter(Boolean); + + const proceed = await confirm({ + message: `Prepare device replacement for ${id}? This will ${actions.join(", ")}.`, + initialValue: false, + }); + if (isCancel(proceed) || !proceed) { + console.log("Cancelled."); + return; + } + + const result = await prepareDeviceReplacement(client, id, opts); + console.log(kleur.green(`Prepared device replacement for ${id}.`)); + console.log( + kleur.dim( + `Revoked sessions: ${num(result, "revokedSessions")}, removed credentials: ${num( + result, + "removedCredentials", + )}, disabled TOTP: ${num(result, "disabledTotpCredentials")}`, + ), + ); +} + +function printUserRow(user: Json): void { + const id = str(user, "id") ?? "(no id)"; + const email = str(user, "email") ?? "(no email)"; + const roles = Array.isArray(user.roles) + ? (user.roles as unknown[]).filter((r): r is string => typeof r === "string") + : []; + const revoked = user.revoked === true ? kleur.red(" revoked") : ""; + console.log( + kleur.bold(email) + + kleur.dim(` ${id}`) + + (roles.length ? kleur.dim(` [${roles.join(", ")}]`) : "") + + revoked, + ); +} + +function str(record: Json, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value ? value : undefined; +} + +function num(record: Json, key: string): number { + const value = record[key]; + return typeof value === "number" ? value : 0; +} diff --git a/src/core/admin.test.ts b/src/core/admin.test.ts new file mode 100644 index 0000000..c194923 --- /dev/null +++ b/src/core/admin.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import type { AuthClient } from "./authClient.js"; +import type { ApiResponse } from "./http.js"; +import { + addMember, + AdminApiError, + createOrg, + deleteUser, + getUserDetail, + listMembers, + listOrgs, + listUsers, + PermissionError, + prepareDeviceReplacement, + removeMember, + updateMember, + updateOrg, +} from "./admin.js"; + +function response(status: number, data: T | null): ApiResponse { + return { ok: status >= 200 && status < 300, status, data, headers: new Headers() }; +} + +interface Recorded { + method: string; + path: string; + body?: unknown; +} + +function fakeClient( + handler: (rec: Recorded) => ApiResponse, +): { client: AuthClient; calls: Recorded[] } { + const calls: Recorded[] = []; + const record = (method: string, path: string, init?: RequestInit) => { + const rec: Recorded = { + method, + path, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }; + calls.push(rec); + return handler(rec); + }; + return { + calls, + client: { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: async (path) => record("GET", path) as never, + post: async (path) => record("POST", path) as never, + request: async (path, init) => + record((init?.method ?? "GET").toUpperCase(), path, init) as never, + }, + }; +} + +describe("users", () => { + it("lists users", async () => { + const { client } = fakeClient(({ method, path }) => { + expect(`${method} ${path}`).toBe("GET /admin/users"); + return response(200, { users: [{ id: "u1" }], total: 1 }); + }); + expect(await listUsers(client)).toEqual({ users: [{ id: "u1" }], total: 1 }); + }); + + it("deletes a user via the body userId", async () => { + const { client, calls } = fakeClient(() => response(200, { message: "ok" })); + await deleteUser(client, "u1"); + expect(calls[0]).toEqual({ + method: "DELETE", + path: "/admin/users", + body: { userId: "u1" }, + }); + }); + + it("maps a 404 delete to a clear error", async () => { + const { client } = fakeClient(() => response(404, { error: "User not found." })); + await expect(deleteUser(client, "missing")).rejects.toThrow(/No user found/); + }); + + it("reads credentials from the user detail endpoint", async () => { + const { client } = fakeClient(({ path }) => { + expect(path).toBe("/admin/users/u1"); + return response(200, { + user: { id: "u1" }, + credentials: [{ id: "c1" }, { id: "c2" }], + sessions: [], + events: [], + }); + }); + const detail = await getUserDetail(client, "u1"); + expect(detail.credentials).toHaveLength(2); + }); + + it("explains the step-up requirement for device replacement", async () => { + const { client, calls } = fakeClient(() => response(401, { error: "step up" })); + await expect( + prepareDeviceReplacement(client, "u1", { + revokeSessions: true, + removePasskeys: true, + disableTotp: true, + }), + ).rejects.toThrow(/step-up/i); + expect(calls[0].path).toBe("/admin/users/u1/recovery/device-replacement"); + expect(calls[0].body).toEqual({ + revokeSessions: true, + removePasskeys: true, + disableTotp: true, + }); + }); + + it("maps 403 to a PermissionError", async () => { + const { client } = fakeClient(() => response(403, { error: "Forbidden" })); + await expect(listUsers(client)).rejects.toBeInstanceOf(PermissionError); + }); +}); + +describe("organizations", () => { + it("lists organizations", async () => { + const { client } = fakeClient(({ path }) => { + expect(path).toBe("/admin/organizations"); + return response(200, { organizations: [{ id: "o1" }], total: 1 }); + }); + expect(await listOrgs(client)).toEqual({ + organizations: [{ id: "o1" }], + total: 1, + }); + }); + + it("creates an org and unwraps the envelope", async () => { + const { client, calls } = fakeClient(() => + response(201, { organization: { id: "o1", name: "Acme" } }), + ); + const org = await createOrg(client, { name: "Acme" }); + expect(org).toEqual({ id: "o1", name: "Acme" }); + expect(calls[0]).toEqual({ + method: "POST", + path: "/admin/organizations", + body: { name: "Acme" }, + }); + }); + + it("updates an org", async () => { + const { client, calls } = fakeClient(() => + response(200, { organization: { id: "o1", name: "New" } }), + ); + await updateOrg(client, "o1", { name: "New" }); + expect(calls[0]).toMatchObject({ + method: "PATCH", + path: "/admin/organizations/o1", + body: { name: "New" }, + }); + }); + + it("lists members and adds one by email", async () => { + const { client } = fakeClient(({ method, path }) => { + if (method === "GET") { + expect(path).toBe("/admin/organizations/o1/members"); + return response(200, { members: [{ userId: "u1" }], total: 1 }); + } + return response(201, { membership: { userId: "u2", roles: ["member"] } }); + }); + + expect((await listMembers(client, "o1")).total).toBe(1); + const membership = await addMember(client, "o1", { email: "x@example.com" }); + expect(membership).toEqual({ userId: "u2", roles: ["member"] }); + }); + + it("updates and removes a member with encoded paths", async () => { + const { client, calls } = fakeClient(({ method }) => + method === "PATCH" + ? response(200, { membership: { userId: "u1", roles: ["admin"] } }) + : response(200, { message: "ok" }), + ); + + await updateMember(client, "o1", "u1", { roles: ["admin"] }); + await removeMember(client, "o1", "u1"); + expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([ + "PATCH /admin/organizations/o1/members/u1", + "DELETE /admin/organizations/o1/members/u1", + ]); + }); + + it("maps a 404 org to a clear error", async () => { + const { client } = fakeClient(() => response(404, { error: "not found" })); + await expect(updateOrg(client, "missing", { name: "x" })).rejects.toBeInstanceOf( + AdminApiError, + ); + }); +}); diff --git a/src/core/admin.ts b/src/core/admin.ts new file mode 100644 index 0000000..65f8833 --- /dev/null +++ b/src/core/admin.ts @@ -0,0 +1,270 @@ +import type { AuthClient } from "./authClient.js"; +import type { ApiResponse } from "./http.js"; +import { PermissionError } from "./systemConfig.js"; + +export { PermissionError }; + +export class AdminApiError extends Error { + constructor(message: string) { + super(message); + this.name = "AdminApiError"; + } +} + +export type Json = Record; + +function arr(value: unknown): Json[] { + return Array.isArray(value) ? (value as Json[]) : []; +} + +async function call( + client: AuthClient, + method: string, + path: string, + body?: unknown, +): Promise> { + let res: ApiResponse; + if (method === "GET") { + res = await client.get(path); + } else { + const init: RequestInit = { method }; + if (body !== undefined) { + init.headers = { "Content-Type": "application/json" }; + init.body = JSON.stringify(body); + } + res = await client.request(path, init); + } + if (res.status === 403) throw new PermissionError(); + return res; +} + +// Users + +export interface UserList { + users: Json[]; + total: number; +} + +export async function listUsers(client: AuthClient): Promise { + const res = await call<{ users?: unknown; total?: number }>( + client, + "GET", + "/admin/users", + ); + if (!res.ok) throw new AdminApiError(`Could not list users (${res.status}).`); + return { users: arr(res.data?.users), total: res.data?.total ?? 0 }; +} + +export async function deleteUser(client: AuthClient, id: string): Promise { + const res = await call(client, "DELETE", "/admin/users", { userId: id }); + if (res.status === 404) throw new AdminApiError(`No user found with id ${id}.`); + if (!res.ok) throw new AdminApiError(`Could not delete user (${res.status}).`); +} + +export interface UserDetail { + user: Json | null; + sessions: Json[]; + credentials: Json[]; + events: Json[]; +} + +export async function getUserDetail( + client: AuthClient, + id: string, +): Promise { + const res = await call( + client, + "GET", + `/admin/users/${encodeURIComponent(id)}`, + ); + if (res.status === 404) throw new AdminApiError(`No user found with id ${id}.`); + if (!res.ok) throw new AdminApiError(`Could not load user (${res.status}).`); + const data = res.data ?? {}; + return { + user: (data.user as Json) ?? null, + sessions: arr(data.sessions), + credentials: arr(data.credentials), + events: arr(data.events), + }; +} + +export interface DeviceReplacementOptions { + revokeSessions: boolean; + removePasskeys: boolean; + disableTotp: boolean; +} + +export async function prepareDeviceReplacement( + client: AuthClient, + id: string, + opts: DeviceReplacementOptions, +): Promise { + const res = await client.request( + `/admin/users/${encodeURIComponent(id)}/recovery/device-replacement`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts), + }, + ); + if (res.status === 404) throw new AdminApiError(`No user found with id ${id}.`); + if (res.status === 401 || res.status === 403) { + throw new PermissionError( + "Admin-assisted device replacement requires an elevated step-up admin session, which the CLI cannot establish yet. Use the dashboard for this action.", + ); + } + if (!res.ok) { + throw new AdminApiError( + `Could not prepare device replacement (${res.status}).`, + ); + } + return res.data ?? {}; +} + +// Organizations + +export interface OrgList { + organizations: Json[]; + total: number; +} + +export async function listOrgs(client: AuthClient): Promise { + const res = await call<{ organizations?: unknown; total?: number }>( + client, + "GET", + "/admin/organizations", + ); + if (!res.ok) throw new AdminApiError(`Could not list organizations (${res.status}).`); + return { + organizations: arr(res.data?.organizations), + total: res.data?.total ?? 0, + }; +} + +function orgEnvelope(res: ApiResponse<{ organization?: Json }>): Json { + if (!res.ok || !res.data?.organization) { + throw new AdminApiError(`Request failed (${res.status}).`); + } + return res.data.organization; +} + +export async function createOrg( + client: AuthClient, + body: Json, +): Promise { + const res = await call<{ organization?: Json }>( + client, + "POST", + "/admin/organizations", + body, + ); + return orgEnvelope(res); +} + +export async function getOrg(client: AuthClient, id: string): Promise { + const res = await call<{ organization?: Json }>( + client, + "GET", + `/admin/organizations/${encodeURIComponent(id)}`, + ); + if (res.status === 404) { + throw new AdminApiError(`No organization found with id ${id}.`); + } + return orgEnvelope(res); +} + +export async function updateOrg( + client: AuthClient, + id: string, + body: Json, +): Promise { + const res = await call<{ organization?: Json }>( + client, + "PATCH", + `/admin/organizations/${encodeURIComponent(id)}`, + body, + ); + if (res.status === 404) { + throw new AdminApiError(`No organization found with id ${id}.`); + } + return orgEnvelope(res); +} + +export interface MemberList { + members: Json[]; + total: number; +} + +export async function listMembers( + client: AuthClient, + orgId: string, +): Promise { + const res = await call<{ members?: unknown; total?: number }>( + client, + "GET", + `/admin/organizations/${encodeURIComponent(orgId)}/members`, + ); + if (res.status === 404) { + throw new AdminApiError(`No organization found with id ${orgId}.`); + } + if (!res.ok) throw new AdminApiError(`Could not list members (${res.status}).`); + return { members: arr(res.data?.members), total: res.data?.total ?? 0 }; +} + +function membershipEnvelope(res: ApiResponse<{ membership?: Json }>): Json { + if (!res.ok || !res.data?.membership) { + throw new AdminApiError(`Request failed (${res.status}).`); + } + return res.data.membership; +} + +export async function addMember( + client: AuthClient, + orgId: string, + body: Json, +): Promise { + const res = await call<{ membership?: Json }>( + client, + "POST", + `/admin/organizations/${encodeURIComponent(orgId)}/members`, + body, + ); + if (res.status === 404) { + throw new AdminApiError(`No organization found with id ${orgId}.`); + } + return membershipEnvelope(res); +} + +export async function updateMember( + client: AuthClient, + orgId: string, + userId: string, + body: Json, +): Promise { + const res = await call<{ membership?: Json }>( + client, + "PATCH", + `/admin/organizations/${encodeURIComponent(orgId)}/members/${encodeURIComponent(userId)}`, + body, + ); + if (res.status === 404) { + throw new AdminApiError(`No such organization or member.`); + } + return membershipEnvelope(res); +} + +export async function removeMember( + client: AuthClient, + orgId: string, + userId: string, +): Promise { + const res = await call( + client, + "DELETE", + `/admin/organizations/${encodeURIComponent(orgId)}/members/${encodeURIComponent(userId)}`, + ); + if (res.status === 404) { + throw new AdminApiError(`No such organization or member.`); + } + if (!res.ok) throw new AdminApiError(`Could not remove member (${res.status}).`); +} diff --git a/src/index.ts b/src/index.ts index 437b1ba..c8c9a17 100755 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ import { runWhoami } from "./commands/whoami.js"; import { runLogout } from "./commands/logout.js"; import { runSessions } from "./commands/sessions.js"; import { runConfig } from "./commands/config.js"; +import { runUsers } from "./commands/users.js"; +import { runOrg } from "./commands/org.js"; export const VERSION = pkg.version; const args = process.argv.slice(2); @@ -90,6 +92,16 @@ async function main() { return; } + if (command === "users") { + await runUsers(args.slice(1)); + return; + } + + if (command === "org") { + await runOrg(args.slice(1)); + return; + } + await runCLI(command); }