maestro/src/bridge/admin-api.ts
oss-sync 454d6f957b
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (caeb95c)
2026-06-09 12:59:57 +00:00

167 lines
7.2 KiB
TypeScript

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();
});
}