254 lines
7.0 KiB
TypeScript
254 lines
7.0 KiB
TypeScript
import { stdin, stdout } from "node:process";
|
|
import { createInterface } from "node:readline/promises";
|
|
import { defineCommand } from "citty";
|
|
import { apiRequest, type ApiRequestInit } from "../lib/api";
|
|
import { toTable } from "../lib/format";
|
|
import type {
|
|
CreateUserRequest,
|
|
ListUsersResponse,
|
|
UpdateUserRequest,
|
|
User,
|
|
UserResponse,
|
|
UserRole,
|
|
} from "../shared/api";
|
|
|
|
export type UsersApiRequest = <T>(
|
|
path: string,
|
|
init?: ApiRequestInit,
|
|
) => Promise<T>;
|
|
|
|
function requireUsername(value: unknown): string {
|
|
const username = String(value ?? "").trim();
|
|
if (!username) throw new Error("Username is required");
|
|
return username;
|
|
}
|
|
|
|
function parseRoles(value: unknown): UserRole[] {
|
|
const roles = String(value ?? "")
|
|
.split(",")
|
|
.map((role) => role.trim())
|
|
.filter(Boolean);
|
|
if (roles.length === 0) {
|
|
throw new Error(
|
|
"At least one role is required (for example: --roles admin)",
|
|
);
|
|
}
|
|
const invalid = roles.find(
|
|
(role) => !["viewer", "operator", "admin"].includes(role),
|
|
);
|
|
if (invalid) throw new Error(`Unknown role: ${invalid}`);
|
|
return [...new Set(roles)];
|
|
}
|
|
|
|
function renderUsers(users: User[]): string {
|
|
if (users.length === 0) return "No users";
|
|
return toTable(
|
|
users.map((user) => ({
|
|
username: user.username,
|
|
roles: user.roles.join(","),
|
|
disabled: user.disabled ? "yes" : "no",
|
|
updated: user.updatedAt,
|
|
})),
|
|
);
|
|
}
|
|
|
|
async function promptPassword(label = "Password: "): Promise<string> {
|
|
if (!stdin.isTTY || !stdin.setRawMode) {
|
|
throw new Error("Password input requires an interactive terminal");
|
|
}
|
|
|
|
stdout.write(label);
|
|
stdin.setRawMode(true);
|
|
stdin.resume();
|
|
return new Promise((resolve, reject) => {
|
|
let password = "";
|
|
const cleanup = () => {
|
|
stdin.off("data", onData);
|
|
stdin.setRawMode(false);
|
|
stdin.pause();
|
|
stdout.write("\n");
|
|
};
|
|
const finish = (error?: Error) => {
|
|
cleanup();
|
|
if (error) reject(error);
|
|
else if (!password) reject(new Error("Password is required"));
|
|
else resolve(password);
|
|
};
|
|
const onData = (chunk: Buffer | string) => {
|
|
for (const value of chunk.toString()) {
|
|
if (value === "\u0003" || value === "\u0004") {
|
|
finish(new Error("Password input cancelled"));
|
|
return;
|
|
}
|
|
if (value === "\r" || value === "\n") {
|
|
finish();
|
|
return;
|
|
}
|
|
if (value === "\u007f" || value === "\b")
|
|
password = password.slice(0, -1);
|
|
else password += value;
|
|
}
|
|
};
|
|
stdin.on("data", onData);
|
|
});
|
|
}
|
|
|
|
async function confirmDeletion(username: string): Promise<boolean> {
|
|
if (!stdin.isTTY) {
|
|
throw new Error("Confirmation requires an interactive terminal; use --yes");
|
|
}
|
|
const readline = createInterface({ input: stdin, output: stdout });
|
|
try {
|
|
const answer = await readline.question(`Delete user '${username}'? [y/N] `);
|
|
return answer.trim().toLowerCase() === "y";
|
|
} finally {
|
|
readline.close();
|
|
}
|
|
}
|
|
|
|
export async function listUsers(
|
|
request: UsersApiRequest = apiRequest,
|
|
): Promise<string> {
|
|
const response = await request<ListUsersResponse>("/users");
|
|
return renderUsers(response.items);
|
|
}
|
|
|
|
export async function addUser(
|
|
username: string,
|
|
password: string,
|
|
roles: UserRole[],
|
|
request: UsersApiRequest = apiRequest,
|
|
): Promise<string> {
|
|
const body: CreateUserRequest = { username, password, roles };
|
|
const user = await request<UserResponse>("/users", {
|
|
method: "POST",
|
|
json: body,
|
|
});
|
|
return renderUsers([user]);
|
|
}
|
|
|
|
export async function updateUser(
|
|
username: string,
|
|
update: UpdateUserRequest,
|
|
request: UsersApiRequest = apiRequest,
|
|
): Promise<string> {
|
|
if (Object.keys(update).length === 0)
|
|
throw new Error("No user updates specified");
|
|
const user = await request<UserResponse>(
|
|
`/users/${encodeURIComponent(username)}`,
|
|
{ method: "PATCH", json: update },
|
|
);
|
|
return renderUsers([user]);
|
|
}
|
|
|
|
export function setUserDisabled(
|
|
username: string,
|
|
disabled: boolean,
|
|
request: UsersApiRequest = apiRequest,
|
|
): Promise<string> {
|
|
return updateUser(username, { disabled }, request);
|
|
}
|
|
|
|
export async function deleteUser(
|
|
username: string,
|
|
confirmed: boolean,
|
|
request: UsersApiRequest = apiRequest,
|
|
): Promise<string> {
|
|
if (!confirmed) return "Deletion cancelled";
|
|
await request<void>(`/users/${encodeURIComponent(username)}`, {
|
|
method: "DELETE",
|
|
});
|
|
return `Deleted user ${username}`;
|
|
}
|
|
|
|
export async function revokeUserSessions(
|
|
username: string,
|
|
request: UsersApiRequest = apiRequest,
|
|
): Promise<string> {
|
|
const result = await request<{ username: string; revoked: number }>(
|
|
`/users/${encodeURIComponent(username)}/sessions/revoke`,
|
|
{ method: "POST" },
|
|
);
|
|
return `Revoked ${result.revoked} session${result.revoked === 1 ? "" : "s"} for ${result.username}`;
|
|
}
|
|
|
|
const list = defineCommand({
|
|
meta: { name: "ls", description: "List users" },
|
|
async run() {
|
|
console.log(await listUsers());
|
|
},
|
|
});
|
|
|
|
const add = defineCommand({
|
|
meta: { name: "add", description: "Add a user" },
|
|
args: {
|
|
roles: { type: "string", description: "Comma-separated roles" },
|
|
},
|
|
async run({ args }) {
|
|
const username = requireUsername(args._[0]);
|
|
console.log(
|
|
await addUser(username, await promptPassword(), parseRoles(args.roles)),
|
|
);
|
|
},
|
|
});
|
|
|
|
const update = defineCommand({
|
|
meta: { name: "update", description: "Update a user's roles or password" },
|
|
args: {
|
|
roles: { type: "string", description: "Comma-separated roles" },
|
|
password: { type: "boolean", description: "Prompt for a new password" },
|
|
},
|
|
async run({ args }) {
|
|
const username = requireUsername(args._[0]);
|
|
const body: UpdateUserRequest = {};
|
|
if (args.roles !== undefined) body.roles = parseRoles(args.roles);
|
|
if (args.password) body.password = await promptPassword("New password: ");
|
|
console.log(await updateUser(username, body));
|
|
},
|
|
});
|
|
|
|
function disabledCommand(name: "disable" | "enable", disabled: boolean) {
|
|
return defineCommand({
|
|
meta: { name, description: `${disabled ? "Disable" : "Enable"} a user` },
|
|
async run({ args }) {
|
|
console.log(await setUserDisabled(requireUsername(args._[0]), disabled));
|
|
},
|
|
});
|
|
}
|
|
|
|
const remove = defineCommand({
|
|
meta: { name: "delete", description: "Delete a user" },
|
|
args: {
|
|
yes: {
|
|
type: "boolean",
|
|
alias: "y",
|
|
description: "Delete without confirmation",
|
|
},
|
|
},
|
|
async run({ args }) {
|
|
const username = requireUsername(args._[0]);
|
|
const confirmed = Boolean(args.yes) || (await confirmDeletion(username));
|
|
console.log(await deleteUser(username, confirmed));
|
|
},
|
|
});
|
|
|
|
const revoke = defineCommand({
|
|
meta: { name: "revoke", description: "Revoke all sessions for a user" },
|
|
async run({ args }) {
|
|
console.log(await revokeUserSessions(requireUsername(args._[0])));
|
|
},
|
|
});
|
|
|
|
export const users = defineCommand({
|
|
meta: { name: "users", description: "Administer users" },
|
|
subCommands: {
|
|
add,
|
|
delete: remove,
|
|
disable: disabledCommand("disable", true),
|
|
enable: disabledCommand("enable", false),
|
|
ls: list,
|
|
revoke,
|
|
update,
|
|
},
|
|
});
|