Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/admin-verbs.md
Original file line number Diff line number Diff line change
@@ -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 <id>` (with confirmation), `credentials <id>` (from the admin
user detail endpoint), and `prepare-device-replacement <id>` 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`.
23 changes: 23 additions & 0 deletions src/commands/adminShared.ts
Original file line number Diff line number Diff line change
@@ -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);
}
27 changes: 27 additions & 0 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ USAGE
seamless sessions [list]
seamless sessions revoke <id | --all>
seamless config <get|set|roles|diff|apply>
seamless users <list|delete|credentials|prepare-device-replacement>
seamless org <list|create|get|update>
seamless org members <list|add|update|remove>
seamless --help
seamless --version

Expand Down Expand Up @@ -107,6 +110,30 @@ COMMANDS
config apply <file> [--dry-run]
• Apply a local JSON config file after a confirmation prompt

users <list|delete|credentials|prepare-device-replacement>
Admin user management (requires an admin role).

users list [--limit <n>] [--offset <n>] [--json]
• List users
users delete <id>
• Delete a user (asks for confirmation)
users credentials <id> [--json]
• Show a user's registered credentials
users prepare-device-replacement <id> [--keep-sessions] [--keep-passkeys] [--keep-totp]
• Admin-assisted account recovery (needs an elevated session)

org <list|create|get|update>, org members <list|add|update|remove>
Admin organization management (requires an admin role).

org list [--json]
org create <name> [--slug <slug>]
org get <id> [--json]
org update <id> [--name <name>] [--slug <slug>]
org members list <orgId> [--json]
org members add <orgId> (--user <id> | --email <email>) [--roles a,b] [--scopes a,b]
org members update <orgId> <userId> [--roles a,b] [--scopes a,b]
org members remove <orgId> <userId>

check
Validate project setup, Docker, and running services

Expand Down
297 changes: 297 additions & 0 deletions src/commands/org.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <list|create|get|update|members>");
process.exit(1);
}
} catch (err) {
reportAdminError(err);
}
}

async function runOrgMembers(args: string[]): Promise<void> {
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 <list|add|update|remove>");
process.exit(1);
}
} catch (err) {
reportAdminError(err);
}
}

async function orgList(client: AuthClient, rest: string[]): Promise<void> {
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<void> {
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 <name> [--slug <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<void> {
const json = rest.includes("--json");
const id = rest.find((a) => !a.startsWith("--"));
if (!id) {
console.error(kleur.red("Usage: seamless org get <id>"));
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<void> {
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 <id> [--name <name>] [--slug <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<void> {
const json = rest.includes("--json");
const orgId = rest.find((a) => !a.startsWith("--"));
if (!orgId) {
console.error(kleur.red("Usage: seamless org members list <orgId>"));
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<void> {
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 <orgId> (--user <id> | --email <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<void> {
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 <orgId> <userId> [--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<void> {
const positional = rest.filter((a) => !a.startsWith("--"));
const [orgId, userId] = positional;
if (!orgId || !userId) {
console.error(
kleur.red("Usage: seamless org members remove <orgId> <userId>"),
);
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;
}
Loading
Loading