167 lines
7.2 KiB
TypeScript
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();
|
|
});
|
|
}
|