import { all, first, run } from "./db"; import { accountDb } from "./scoped-db"; import { badRequest, conflict, notFoundError } from "./errors"; import { revokeDeployTokensCreatedBy } from "./access-revocation"; import { backfillDone } from "./backfill"; import { DEPLOY_TOKEN_INDEX } from "./deploy-token-index"; import { userChanged } from "./user-row"; export type SiteAdmin = { id: string; name: string }; export async function listSiteAdmins(): Promise { return all( "SELECT a.id, a.name FROM users u JOIN accounts a ON a.id = u.account_id WHERE u.is_admin = 1 ORDER BY a.name", ); } /** * Grants or removes site admin. Demotion also revokes deploy tokens the user minted on repositories they only * administered as a site admin (`revokeDeployTokensCreatedBy` keeps the ones they still administer another way), * which is why admins must be changed here rather than in the database. The last admin can't be removed. */ export async function setSiteAdmin(name: string, admin: boolean): Promise { const user = await first<{ id: string; is_admin: number; email_verified_at: number | null }>( "SELECT a.id, u.is_admin, u.email_verified_at FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.name = ?", name, ); if (!user) throw notFoundError("User not found"); if (admin) { if (user.email_verified_at === null) throw badRequest("Only users with a verified email can be site admins"); await run("UPDATE users SET is_admin = 1 WHERE account_id = ?", user.id); } else { const demoted = await run( "UPDATE users SET is_admin = 0 WHERE account_id = ?1 AND EXISTS (SELECT 1 FROM users WHERE is_admin = 1 AND account_id != ?1)", user.id, ); if (demoted.meta.changes === 0 && user.is_admin === 1) throw conflict("The last site admin can't be removed"); } await userChanged(user.id); if (!admin && user.is_admin === 1) await revokeDeployTokensCreatedBy(user.id, await mintedRepoIds(user.id)); return user.id; } /** Repositories that can hold a deploy token the user created. */ async function mintedRepoIds(userId: string): Promise { if (!(await backfillDone(DEPLOY_TOKEN_INDEX))) { return (await all<{ id: string }>("SELECT id FROM repos")).map((row) => row.id); } const rows = await accountDb(userId).all<{ repo_id: string }>("SELECT DISTINCT repo_id FROM deploy_token_index"); return rows.map((row) => row.repo_id); }