import { type Application, type Request, type Response, type RequestHandler } from 'express'; import type { Repository } from '../db/repository.js'; import { requireAdmin } from './auth.js'; import { deleteUserFolder } from '../user-folder/paths.js'; const passthrough: RequestHandler = (_req, _res, next) => next(); export function mountAdminApi( app: Application, repo: Repository, authActive = true, userFolderRoot = './data/users', ): void { const guard = authActive ? requireAdmin : passthrough; app.get('/api/admin/users', guard, (_req: Request, res: Response) => { const users = authActive ? repo.listUsers() : []; const enriched = users.map(u => ({ ...u, orgs: repo.listUserGiteaOrgs(u.id), hasLocalPassword: repo.hasLocalCredential(u.id), })); res.json(enriched); }); // Create a local (email + password) account. Admin-created accounts are // pre-approved (status=active). Rejects an email that already exists. app.post('/api/admin/users', guard, (req: Request, res: Response) => { if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } const { email, password, role } = (req.body ?? {}) as { email?: string; password?: string; role?: string }; if (typeof email !== 'string' || !email.trim() || typeof password !== 'string' || password.length < 8) { res.status(400).json({ error: 'email and a password of at least 8 characters are required' }); return; } const wantRole = role === 'admin' ? 'admin' : 'user'; try { const user = repo.createLocalUser({ email: email.trim(), password, role: wantRole, status: 'active' }); res.status(201).json(user); } catch { res.status(409).json({ error: 'a user with that email already exists' }); } }); // Reset (or set) a user's local password. Invalidates their sessions so the // new password must be used. app.post('/api/admin/users/:id/password', guard, (req: Request, res: Response) => { if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } const { id } = req.params; const { password } = (req.body ?? {}) as { password?: string }; if (typeof password !== 'string' || password.length < 8) { res.status(400).json({ error: 'password must be at least 8 characters' }); return; } if (!repo.getUserById(id)) { res.status(404).json({ error: 'User not found' }); return; } repo.setLocalPassword(id, password); repo.deleteSessionsByUserId(id); res.status(204).end(); }); app.patch('/api/admin/users/:id', guard, (req: Request, res: Response) => { if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } const { id } = req.params; const { status, role } = req.body; const user = repo.getUserById(id); if (!user) { res.status(404).json({ error: 'User not found' }); return; } repo.updateUser(id, { status, role }); // Invalidate sessions on status/role change if (status === 'disabled' || status === 'pending' || role) { repo.deleteSessionsByUserId(id); } const updated = repo.getUserById(id); res.json(updated); }); app.delete('/api/admin/users/:id', guard, (req: Request, res: Response) => { if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } const { id } = req.params; // The shared `local` system/admin user is the no-auth fallback owner and // owns all single-user-mode data — never deletable. if (id === 'local') { res.status(400).json({ error: 'the local/system user cannot be deleted' }); return; } const user = repo.getUserById(id); if (!user) { res.status(404).json({ error: 'User not found' }); return; } repo.deleteSessionsByUserId(id); repo.deleteUser(id); // Remove the on-disk user folder too — the DB cascade only handles rows. // Without this the user's scripts/macros/recordings/notes orphaned on disk. deleteUserFolder(userFolderRoot, id); res.status(204).end(); }); // ── Local organizations (admin) ─────────────────────────────────────── // A provider-agnostic 'org' visibility scope for local accounts. See // docs/superpowers/plans/2026-06-09-local-orgs.md. app.get('/api/admin/orgs', guard, (_req: Request, res: Response) => { if (!authActive) { res.json([]); return; } const orgs = repo.listLocalOrgs().map(o => ({ ...o, members: repo.listOrgMembers(o.id) })); res.json(orgs); }); app.post('/api/admin/orgs', guard, (req: Request, res: Response) => { if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } const { name } = (req.body ?? {}) as { name?: string }; if (typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'name is required' }); return; } const createdBy = (req.user as { id?: string } | undefined)?.id ?? null; res.status(201).json(repo.createLocalOrg(name.trim(), createdBy)); }); app.patch('/api/admin/orgs/:id', guard, (req: Request, res: Response) => { if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } const { id } = req.params; const { name } = (req.body ?? {}) as { name?: string }; if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; } if (typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'name is required' }); return; } repo.renameLocalOrg(id, name.trim()); res.json(repo.getLocalOrg(id)); }); app.delete('/api/admin/orgs/:id', guard, (req: Request, res: Response) => { if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } const { id } = req.params; if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; } // repo downgrades org-scoped tasks/schedules/jobs/notes to private first. repo.deleteLocalOrg(id); res.status(204).end(); }); app.post('/api/admin/orgs/:id/members', guard, (req: Request, res: Response) => { if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } const { id } = req.params; const { userId, role } = (req.body ?? {}) as { userId?: string; role?: string }; if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; } if (typeof userId !== 'string' || !repo.getUserById(userId)) { res.status(400).json({ error: 'a valid userId is required' }); return; } repo.addOrgMember(id, userId, role === 'owner' ? 'owner' : 'member'); // The added user's session orgIds change — drop their sessions so the next // request re-derives membership (and 'org'-visible resources appear). repo.deleteSessionsByUserId(userId); res.status(204).end(); }); app.delete('/api/admin/orgs/:id/members/:userId', guard, (req: Request, res: Response) => { if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; } const { id, userId } = req.params; if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; } repo.removeOrgMember(id, userId); repo.deleteSessionsByUserId(userId); res.status(204).end(); }); }