63eaa739d4
A member kept renaming themselves to a colleague's display name, plus "(you)", so the CPU/RAM card and every owner list showed two of that person. A display name is now refused (409 ENAMETAKEN) when, with marks, case, spaces, punctuation, bracketed parts and look-alike Cyrillic/Greek letters ignored, it equals another account's display name or username — in Settings → Account and on the Users page alike. Keeping the name one already has stays allowed, so an older clash does not block an admin's other edits.
770 lines
29 KiB
JavaScript
770 lines
29 KiB
JavaScript
/**
|
|
* @file users.js
|
|
* @description `/api/users` — server mode's per-user view for an admin or
|
|
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
|
|
* `GET /` lists every account with its workspaces, sessions and $ spent today
|
|
* (the viewer's local day, `?tz_offset=` = the browser's getTimezoneOffset())
|
|
* and in the last 7 and 30 days (`hourlyTokenRows` + `calculateCost`, sessions owned
|
|
* through their workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
|
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
|
|
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
|
|
*
|
|
* `GET /tmux` (S3) is each member's own tmux server — its version beside the
|
|
* installed one, its sessions, whether an agent of theirs works — and an
|
|
* admin restarts one (`POST /:id/tmux-restart`) or every idle outdated one
|
|
* (`POST /tmux-restart-idle`), with their password again and audited
|
|
* (server/lib/member-tmux.js); a restart ends that member's panes.
|
|
*
|
|
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
|
|
* `GET /:id/details` everything the Administration page's user detail shows —
|
|
* the account and its logins, its workspaces (sessions, last activity, live
|
|
* panes), the panes running now, its newest sessions, spend per local day /
|
|
* AI tool / model over 30 days, the audit entries naming it, and per pool
|
|
* tool the accounts it may run on (pin, group, quota, its own 5 h / 7 d share
|
|
* of each — `accountUsage` with `ownerId`) and the ones it actually ran on;
|
|
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
|
|
* owner groups — staff only like the rest of `/users`.
|
|
*
|
|
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
|
|
* user form: display name, email, role, disabled, a new password, workspaces
|
|
* given and taken back, sign out everywhere — every field validated before any
|
|
* is applied), `POST /:id/password` (reset) and `POST /:id/workspaces` (give a
|
|
* workspace) — are an admin's only (`/users` is in ADMIN_WRITE_PREFIXES), each
|
|
* needs the admin's own password again (`currentPassword`, `confirmPassword`),
|
|
* an admin never demotes or disables themselves (ESELF), every change is
|
|
* audited, and in local mode — no login to check — they all refuse. A
|
|
* password an admin gives another account (create, reset) may be anything
|
|
* non-empty and makes that account pick its own at its next login
|
|
* (`must_change_password`); an admin's own new password must pass every
|
|
* rule, like Settings → Account's (`validateOwnPassword`, EWEAKPASSWORD).
|
|
*
|
|
* Spend quotas (server/lib/user-quota.js): `GET /` also gives each account
|
|
* its own setting (`quotaSetting`) and the quota binding it now with its spend
|
|
* (`quota`), plus the `quotaDefault`; `PUT /quota-default` sets or clears the
|
|
* default and `PATCH /:id` `{quota}` a user's own (`default`, `unlimited` or
|
|
* `custom`) — account changes like the rest, and each re-sweeps at once.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const { db, stmts } = require("../db");
|
|
const auth = require("../lib/auth");
|
|
const { listUsers } = auth;
|
|
const { listAudit, recordAccountChange } = require("../lib/audit");
|
|
|
|
const router = Router();
|
|
|
|
// Which account each session belongs to — through its workspace, or a
|
|
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
|
|
const OWNED_SESSIONS = `
|
|
SELECT s.id AS session_id, l.owner_id AS uid
|
|
FROM sessions s JOIN lanes l ON l.id = s.lane_id
|
|
WHERE l.owner_id IS NOT NULL
|
|
UNION
|
|
SELECT s.id, l.owner_id
|
|
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
|
WHERE l.owner_id IS NOT NULL`;
|
|
|
|
/**
|
|
* Map(user id → {cost, tokens}) per window, each window a UTC hour
|
|
* (`YYYY-MM-DDTHH`) it starts at. Spend is the pricing routes' own booking
|
|
* (`hourlyTokenRows`: a session's hours, the rest of its total at its start
|
|
* hour — so Codex/omp/Antigravity and sub-agent usage count too) priced by
|
|
* `calculateCost`, never a second formula.
|
|
*/
|
|
function usageSince(windows, rules) {
|
|
const { calculateCost, hourlyTokenRows } = require("./pricing");
|
|
const oldest = windows.reduce((a, b) => (b < a ? b : a));
|
|
const owner = new Map();
|
|
for (const r of db.prepare(OWNED_SESSIONS).all()) {
|
|
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
|
|
}
|
|
// A session that spent in the window was touched in it (budgets.js does the same).
|
|
const rows = hourlyTokenRows(
|
|
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`,
|
|
[oldest]
|
|
);
|
|
return windows.map((since) => {
|
|
const byUser = new Map();
|
|
for (const r of rows) {
|
|
const uid = owner.get(r.session_id);
|
|
if (uid == null || r.date < since) continue;
|
|
if (!byUser.has(uid)) byUser.set(uid, []);
|
|
byUser.get(uid).push(r);
|
|
}
|
|
const out = new Map();
|
|
for (const [uid, list] of byUser) {
|
|
const tokens = list.reduce(
|
|
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
|
0
|
|
);
|
|
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens: Math.round(tokens) });
|
|
}
|
|
return out;
|
|
});
|
|
}
|
|
|
|
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
|
|
|
/**
|
|
* The UTC hour the viewer's local day began in — `offset` is the browser's
|
|
* getTimezoneOffset() (minutes local time is BEHIND UTC; UTC when missing).
|
|
* Buckets are whole UTC hours, so a half-hour zone's day starts up to 30 min early.
|
|
*/
|
|
function todayHour(offset) {
|
|
const off = Number.isFinite(offset) && Math.abs(offset) <= 840 ? offset : 0;
|
|
const local = new Date(Date.now() - off * 60e3);
|
|
local.setUTCHours(0, 0, 0, 0);
|
|
return new Date(local.getTime() + off * 60e3).toISOString().slice(0, 13);
|
|
}
|
|
|
|
/** Map(key → n) from rows of `{k, n}`. */
|
|
const counts = (rows) => new Map(rows.map((r) => [r.k, r.n]));
|
|
|
|
router.get("/", (req, res) => {
|
|
const rules = stmts.listPricing.all();
|
|
// A fixed number of grouped queries, however many accounts there are.
|
|
const logins = auth.allLoginStats();
|
|
const workspaces = counts(
|
|
db.prepare("SELECT owner_id AS k, COUNT(*) AS n FROM lanes GROUP BY owner_id").all()
|
|
);
|
|
const sessions = counts(
|
|
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
|
|
);
|
|
const [today, last7d, last30d] = usageSince(
|
|
[todayHour(parseInt(req.query.tz_offset, 10)), hourAgo(7), hourAgo(30)],
|
|
rules
|
|
);
|
|
const none = { cost: 0, tokens: 0 };
|
|
const quota = require("../lib/user-quota");
|
|
const quotas = quota.statusAll();
|
|
const users = listUsers().map((u) => ({
|
|
...u,
|
|
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
|
|
workspaces: workspaces.get(u.id) || 0,
|
|
sessions: sessions.get(u.id) || 0,
|
|
today: today.get(u.id) || none,
|
|
last7d: last7d.get(u.id) || none,
|
|
last30d: last30d.get(u.id) || none,
|
|
// Spend quota (user-quota.js): this user's own setting, and the quota that
|
|
// binds them now with its spend (null: no limit).
|
|
quotaSetting: quota.getOverride(u.id),
|
|
quota: quotas.get(u.id) || null,
|
|
}));
|
|
res.json({ users, quotaDefault: quota.getDefault() });
|
|
});
|
|
|
|
router.get("/storage", (req, res) => {
|
|
const { storageFor } = require("../lib/user-storage");
|
|
res.json(storageFor(listUsers(), { refresh: req.query.refresh === "1" }));
|
|
});
|
|
|
|
router.get("/audit", (req, res) => {
|
|
res.json({ entries: listAudit(req.query.limit) });
|
|
});
|
|
|
|
/** S3: each member's tmux server — its version beside the installed one (member-tmux.js). */
|
|
router.get("/tmux", (_req, res) => {
|
|
res.json(require("../lib/member-tmux").overview(listUsers()));
|
|
});
|
|
|
|
const STATUS = {
|
|
EBADUSER: 400,
|
|
EBADEMAIL: 400,
|
|
ENAMETAKEN: 409,
|
|
ENOTOWNED: 409,
|
|
EBADPASSWORD: 400,
|
|
EWEAKPASSWORD: 400,
|
|
EBADROLE: 400,
|
|
EBADFIELD: 400,
|
|
EBADQUOTA: 400,
|
|
EREAUTH: 403,
|
|
ELOCALMODE: 403,
|
|
ENOUSER: 404,
|
|
ENOLANE: 404,
|
|
EEXISTS: 409,
|
|
ESELF: 409,
|
|
EOWNERFOLDER: 409, // S3: a workspace in one member's home, assigned to another
|
|
ELASTADMIN: 409,
|
|
ETOOMANY: 429,
|
|
// S3: the Linux account could not be created/locked (the helper said why).
|
|
EPROVISION: 502,
|
|
ENOS3: 409,
|
|
// A member's tmux server that would not stop: root has to kill it.
|
|
ETMUXSTUCK: 409,
|
|
};
|
|
|
|
/**
|
|
* One account change: server mode only, the admin's password confirmed, the
|
|
* change made, then audited. `fn(target)` returns the audit text.
|
|
*/
|
|
function accountChange(handler) {
|
|
return async (req, res) => {
|
|
try {
|
|
if (!auth.isServerMode() || !req.user) {
|
|
throw Object.assign(new Error("accounts are managed in server mode only"), {
|
|
code: "ELOCALMODE",
|
|
});
|
|
}
|
|
const body = req.body && typeof req.body === "object" ? req.body : {};
|
|
await auth.confirmPassword(req.user, body.currentPassword);
|
|
const out = await handler(req, body);
|
|
recordAccountChange(req.user.id, out.user.id, req.method, out.action);
|
|
res
|
|
.status(out.status || 200)
|
|
.json({ user: out.user, ...("quotaDefault" in out && { quotaDefault: out.quotaDefault }) });
|
|
} catch (err) {
|
|
const status = STATUS[err.code];
|
|
if (!status) return res.status(500).json({ error: { message: err.message } });
|
|
res.status(status).json({
|
|
error: { code: err.code, message: err.message, ...(err.rules && { rules: err.rules }) },
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
/** The account `:id` names (404 ENOUSER otherwise). */
|
|
function target(req) {
|
|
const u = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(req.params.id));
|
|
if (!u) throw Object.assign(new Error("no such user"), { code: "ENOUSER" });
|
|
return u;
|
|
}
|
|
|
|
router.get("/:id", (req, res) => {
|
|
const u = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(req.params.id));
|
|
if (!u) return res.status(404).json({ error: { code: "ENOUSER", message: "no such user" } });
|
|
res.json({
|
|
user: { ...auth.publicUser(u), ...auth.loginStats(u.id) },
|
|
workspaces: auth.ownedWorkspaces(u.id),
|
|
});
|
|
});
|
|
|
|
const DAY_MS = 864e5;
|
|
const tokensOf = (r) =>
|
|
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
|
|
|
|
/** `{cost, tokens}` of token rows, priced by `calculateCost`. */
|
|
function priced(rows, rules) {
|
|
const { calculateCost } = require("./pricing");
|
|
return {
|
|
cost: rows.length ? calculateCost(rows, rules).total_cost : 0,
|
|
tokens: Math.round(rows.reduce((n, r) => n + tokensOf(r), 0)),
|
|
};
|
|
}
|
|
|
|
/** Rows grouped by `keyOf`, each group priced: `[{key, cost, tokens, sessions}]`, $ first. */
|
|
function breakdown(rows, keyOf, rules) {
|
|
const groups = new Map();
|
|
for (const r of rows) {
|
|
const k = keyOf(r) || "unknown";
|
|
if (!groups.has(k)) groups.set(k, []);
|
|
groups.get(k).push(r);
|
|
}
|
|
return [...groups]
|
|
.map(([key, list]) => ({
|
|
key,
|
|
...priced(list, rules),
|
|
sessions: new Set(list.map((r) => r.session_id)).size,
|
|
}))
|
|
.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
|
|
}
|
|
|
|
/** Per pool tool: what `uid` may run on, and their own share of each account. */
|
|
async function poolFor(uid) {
|
|
const pins = require("../lib/account-pins");
|
|
const groups = require("../lib/pool-groups");
|
|
const { accountUsage } = require("../lib/pool-usage");
|
|
const pools = {
|
|
claude: require("../lib/claude-pool"),
|
|
codex: require("../lib/codex-pool"),
|
|
cursor: require("../lib/cursor-pool"),
|
|
};
|
|
await pools.claude.refreshQuotas().catch(() => undefined);
|
|
const group = db
|
|
.prepare(
|
|
`SELECT g.id, g.name FROM pool_group_users u JOIN pool_groups g ON g.id = u.group_id
|
|
WHERE u.user_id = ?`
|
|
)
|
|
.get(uid);
|
|
const since7d = new Date(Date.now() - 7 * DAY_MS).toISOString();
|
|
const used = db
|
|
.prepare(
|
|
`SELECT r.tool, r.account, COUNT(*) AS runs, MAX(r.started_at) AS lastAt,
|
|
SUM(r.status IN ('running', 'spawning')) AS live
|
|
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
|
WHERE l.owner_id = ? AND r.account IS NOT NULL AND r.started_at >= ?
|
|
GROUP BY r.tool, r.account ORDER BY lastAt DESC`
|
|
)
|
|
.all(uid, since7d);
|
|
const out = {};
|
|
for (const tool of pins.TOOLS) {
|
|
const pool = pools[tool];
|
|
if (!pool) continue;
|
|
let status = [];
|
|
try {
|
|
status = pool.poolStatus();
|
|
} catch {
|
|
status = [];
|
|
}
|
|
const ok = groups.allowed(uid, tool);
|
|
const pin = pins.pinFor(uid, tool);
|
|
const choice = pins.choiceFor(uid, tool);
|
|
const ranOn = used.filter((u) => u.tool === tool);
|
|
const wholeSession = tool !== "claude";
|
|
out[tool] = {
|
|
pin,
|
|
// The user's own pick (Settings → AI tools → Pool account), below a pin.
|
|
choice,
|
|
group: group ? { id: group.id, name: group.name } : null,
|
|
accounts: status
|
|
.filter((a) => ok(a.name) || ranOn.some((u) => u.account === a.name))
|
|
.map((a) => {
|
|
const mine = accountUsage(tool, a.name, { wholeSession, ownerId: uid });
|
|
const ran = ranOn.find((u) => u.account === a.name);
|
|
return {
|
|
name: a.name,
|
|
kind: a.kind,
|
|
email: a.email ?? null,
|
|
expired: Boolean(a.expired),
|
|
allowed: ok(a.name),
|
|
pinned: a.name === pin,
|
|
quota: a.quota ?? null,
|
|
quotaPercent: a.quotaPercent,
|
|
full: a.full,
|
|
livePanes: a.livePanes,
|
|
spend5h: a.spend5h,
|
|
spend7d: a.spend7d,
|
|
userSpend5h: mine.spend5h,
|
|
userSpend7d: mine.spend7d,
|
|
userTokens5h: mine.tokens5h,
|
|
userTokens7d: mine.tokens7d,
|
|
userRuns7d: ran?.runs ?? 0,
|
|
userLivePanes: ran?.live ?? 0,
|
|
userLastAt: ran?.lastAt ?? null,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
router.get("/:id/details", async (req, res) => {
|
|
const u = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(req.params.id));
|
|
if (!u) return res.status(404).json({ error: { code: "ENOUSER", message: "no such user" } });
|
|
try {
|
|
const { hourlyTokenRows } = require("./pricing");
|
|
const rules = stmts.listPricing.all();
|
|
const uid = u.id;
|
|
const owned = `SELECT session_id FROM (${OWNED_SESSIONS}) WHERE uid = ?`;
|
|
const off = parseInt(req.query.tz_offset, 10);
|
|
const offMin = Number.isFinite(off) && Math.abs(off) <= 840 ? off : 0;
|
|
// A UTC hour (`YYYY-MM-DDTHH`) → the viewer's local date.
|
|
const localDay = (hour) =>
|
|
new Date(Date.parse(`${hour}:00:00Z`) - offMin * 60e3).toISOString().slice(0, 10);
|
|
|
|
const since30 = hourAgo(30);
|
|
const rows = hourlyTokenRows(
|
|
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (${owned})`,
|
|
[since30, uid]
|
|
).filter((r) => r.date >= since30);
|
|
const today = todayHour(offMin);
|
|
const since7 = hourAgo(7);
|
|
|
|
// One bar per local day, the oldest first, days with no spend included.
|
|
const byDay = new Map();
|
|
for (const r of rows) {
|
|
const d = localDay(r.date);
|
|
if (!byDay.has(d)) byDay.set(d, []);
|
|
byDay.get(d).push(r);
|
|
}
|
|
const daily = [];
|
|
for (let i = 29; i >= 0; i--) {
|
|
const d = new Date(Date.now() - offMin * 60e3 - i * DAY_MS).toISOString().slice(0, 10);
|
|
daily.push({ date: d, ...priced(byDay.get(d) || [], rules) });
|
|
}
|
|
|
|
const workspaces = db
|
|
.prepare(
|
|
`SELECT l.id, l.title, l.cwd, l.tool, l.kind, l.branch, l.status, l.created_at AS createdAt,
|
|
(SELECT COUNT(*) FROM (${OWNED_SESSIONS}) o JOIN sessions s ON s.id = o.session_id
|
|
WHERE o.uid = l.owner_id AND (s.lane_id = l.id OR s.id IN
|
|
(SELECT session_id FROM dashboard_runs WHERE lane_id = l.id))) AS sessions,
|
|
(SELECT MAX(s.updated_at) FROM sessions s WHERE s.lane_id = l.id) AS lastActiveAt,
|
|
(SELECT COUNT(*) FROM dashboard_runs r
|
|
WHERE r.lane_id = l.id AND r.status IN ('running', 'spawning')) AS livePanes
|
|
FROM lanes l WHERE l.owner_id = ? ORDER BY lastActiveAt DESC, l.id`
|
|
)
|
|
.all(uid);
|
|
|
|
const panes = db
|
|
.prepare(
|
|
`SELECT r.id, r.lane_id AS laneId, l.title AS laneTitle, l.cwd AS laneCwd, r.tool,
|
|
r.model, r.account, r.proxy, r.session_id AS sessionId, r.started_at AS startedAt,
|
|
r.status
|
|
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
|
WHERE l.owner_id = ? AND r.status IN ('running', 'spawning')
|
|
ORDER BY r.started_at DESC`
|
|
)
|
|
.all(uid);
|
|
|
|
const recent = db
|
|
.prepare(
|
|
`SELECT s.id, s.name, s.tool, s.model, s.status, s.cwd, s.lane_id, s.started_at,
|
|
s.ended_at, s.updated_at AS last_activity, s.awaiting_input_since, s.awaiting_reason
|
|
FROM sessions s WHERE s.id IN (${owned})
|
|
ORDER BY s.updated_at DESC LIMIT 15`
|
|
)
|
|
.all(uid);
|
|
if (recent.length) {
|
|
const cost = new Map(
|
|
breakdown(
|
|
hourlyTokenRows(
|
|
`WHERE s.id IN (${recent.map(() => "?").join(",")})`,
|
|
recent.map((r) => r.id)
|
|
),
|
|
(r) => r.session_id,
|
|
rules
|
|
).map((b) => [b.key, b])
|
|
);
|
|
for (const r of recent) {
|
|
r.cost = cost.get(r.id)?.cost ?? 0;
|
|
r.tokens = cost.get(r.id)?.tokens ?? 0;
|
|
}
|
|
}
|
|
|
|
const counts = db
|
|
.prepare(
|
|
`SELECT COUNT(*) AS total, SUM(s.status = 'active') AS active,
|
|
MIN(s.started_at) AS firstAt, MAX(s.updated_at) AS lastAt
|
|
FROM sessions s WHERE s.id IN (${owned})`
|
|
)
|
|
.get(uid);
|
|
|
|
const audit = db
|
|
.prepare(
|
|
`SELECT a.id, a.at, a.method, a.path, a.session_id,
|
|
r.username AS reader, o.username AS owner,
|
|
r.display_name AS reader_display, o.display_name AS owner_display
|
|
FROM audit_log a
|
|
LEFT JOIN users r ON r.id = a.user_id
|
|
LEFT JOIN users o ON o.id = a.owner_id
|
|
WHERE a.user_id = ? OR a.owner_id = ?
|
|
ORDER BY a.id DESC LIMIT 30`
|
|
)
|
|
.all(uid, uid);
|
|
|
|
res.json({
|
|
user: { ...auth.publicUser(u), ...auth.loginStats(uid) },
|
|
sessions: {
|
|
total: counts.total || 0,
|
|
active: counts.active || 0,
|
|
firstAt: counts.firstAt || null,
|
|
lastAt: counts.lastAt || null,
|
|
},
|
|
usage: {
|
|
today: priced(
|
|
rows.filter((r) => r.date >= today),
|
|
rules
|
|
),
|
|
last7d: priced(
|
|
rows.filter((r) => r.date >= since7),
|
|
rules
|
|
),
|
|
last30d: priced(rows, rules),
|
|
daily,
|
|
byTool: breakdown(rows, (r) => r.tool || "claude", rules),
|
|
byModel: breakdown(rows, (r) => r.model, rules),
|
|
},
|
|
workspaces,
|
|
panes,
|
|
recentSessions: recent,
|
|
pool: await poolFor(uid),
|
|
audit,
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ error: { code: e.code || "EDETAILS", message: e.message } });
|
|
}
|
|
});
|
|
|
|
router.get("/:id/avatar", (req, res) => {
|
|
const id = Number(req.params.id);
|
|
if (
|
|
Number.isSafeInteger(id) &&
|
|
id > 0 &&
|
|
require("../lib/avatar-redirect").sendAvatarLink(res, id)
|
|
)
|
|
return;
|
|
const file = Number.isSafeInteger(id) && id > 0 ? require("../lib/avatars").avatarFile(id) : null;
|
|
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
|
|
res.set("Content-Type", file.type);
|
|
res.set("X-Content-Type-Options", "nosniff");
|
|
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
|
|
// Versioned by the client with the owner's `avatarAt`.
|
|
res.set("Cache-Control", "private, max-age=86400");
|
|
require("../lib/send-file").sendFile(res, file.path);
|
|
});
|
|
|
|
/** A list of workspace ids from the body, or [] when absent. */
|
|
function laneIds(value, field) {
|
|
if (value === undefined) return [];
|
|
if (!Array.isArray(value) || value.some((v) => !Number.isInteger(v) || v <= 0)) {
|
|
throw Object.assign(new Error(`${field}: a list of workspace ids`), { code: "EBADFIELD" });
|
|
}
|
|
return [...new Set(value)];
|
|
}
|
|
|
|
router.post(
|
|
"/",
|
|
accountChange(async (_req, body) => {
|
|
const role = body.role === undefined ? "developer" : body.role;
|
|
// The admin's password is a starting one: the user picks their own first.
|
|
const user = await auth.createUser(body.username, body.password, role, { mustChange: true });
|
|
return { status: 201, user, action: `create ${user.username} (${user.role})` };
|
|
})
|
|
);
|
|
|
|
/**
|
|
* The default spend quota every user without one of their own gets
|
|
* (server/lib/user-quota.js): `{quota: {limit_usd, period, tz_offset}}`, or
|
|
* `{quota: null}` for none. The admin's password again, audited.
|
|
*/
|
|
router.put(
|
|
"/quota-default",
|
|
accountChange(async (req, body) => {
|
|
const quota = require("../lib/user-quota");
|
|
if (body.quota === undefined) {
|
|
throw Object.assign(new Error("quota: an object or null"), { code: "EBADQUOTA" });
|
|
}
|
|
const saved = quota.setDefault(body.quota);
|
|
quotaSweep();
|
|
return {
|
|
user: auth.publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.user.id)),
|
|
action: `default spend quota: ${saved ? `$${saved.limit_usd}/${saved.period}` : "none"}`,
|
|
quotaDefault: saved,
|
|
};
|
|
})
|
|
);
|
|
|
|
/** Apply a quota change now rather than at the next minute's sweep. */
|
|
function quotaSweep() {
|
|
try {
|
|
require("../lib/user-quota").sweep();
|
|
} catch (err) {
|
|
console.warn("user quota sweep failed:", err.message);
|
|
}
|
|
}
|
|
|
|
router.patch(
|
|
"/:id",
|
|
accountChange(async (req, body) => {
|
|
const u = target(req);
|
|
const self = u.id === req.user.id;
|
|
const ESELF = (msg) => Object.assign(new Error(msg), { code: "ESELF" });
|
|
|
|
// 1. Validate everything — nothing is written until all of it passes.
|
|
const profile = auth.validateProfile(body);
|
|
auth.assertNameFree(u.id, profile);
|
|
const roleChange = body.role !== undefined && body.role !== u.role;
|
|
if (roleChange) {
|
|
if (self) throw ESELF("you cannot change your own role");
|
|
if (!auth.ROLES.includes(body.role)) {
|
|
throw Object.assign(new Error(`role: one of ${auth.ROLES.join(", ")}`), {
|
|
code: "EBADROLE",
|
|
});
|
|
}
|
|
}
|
|
if (body.disabled !== undefined) {
|
|
if (typeof body.disabled !== "boolean") {
|
|
throw Object.assign(new Error("disabled: true or false"), { code: "EBADFIELD" });
|
|
}
|
|
if (self && body.disabled) throw ESELF("you cannot disable yourself");
|
|
}
|
|
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
|
|
if (body.password !== undefined) {
|
|
// Your own password: every rule. Someone else's: a starting one they replace.
|
|
if (self) {
|
|
auth.validateOwnPassword(body.password, u.username);
|
|
await auth.assertNotCurrent(u.id, body.password);
|
|
} else auth.validatePassword(body.password);
|
|
}
|
|
if (body.signOut !== undefined && body.signOut !== true) {
|
|
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
|
|
}
|
|
const quotaLib = require("../lib/user-quota");
|
|
const quota = body.quota === undefined ? null : quotaLib.validateUserQuota(body.quota);
|
|
const add = laneIds(body.addWorkspaces, "addWorkspaces");
|
|
const remove = laneIds(body.removeWorkspaces, "removeWorkspaces");
|
|
const runAs = require("../lib/run-as");
|
|
for (const id of [...add, ...remove]) {
|
|
const lane = db.prepare("SELECT owner_id, cwd FROM lanes WHERE id = ?").get(id);
|
|
if (!lane) throw Object.assign(new Error(`no workspace ${id}`), { code: "ENOLANE" });
|
|
if (remove.includes(id) && lane.owner_id !== u.id) {
|
|
throw Object.assign(new Error(`workspace ${id} is not ${u.username}'s`), {
|
|
code: "ENOTOWNED",
|
|
});
|
|
}
|
|
// S3: a workspace in a member's home is theirs alone (EOWNERFOLDER).
|
|
if (add.includes(id)) runAs.assertOwnerFits(lane.cwd, u.id);
|
|
}
|
|
|
|
// 2. Apply. Disable last: it may stop panes and lock a Linux account
|
|
// (EPROVISION), and the admin should learn that after the rest held.
|
|
const done = [];
|
|
if (Object.keys(profile).length) {
|
|
auth.setProfile(u.id, profile);
|
|
if ("display_name" in profile) done.push("display name");
|
|
if ("email" in profile) done.push("email");
|
|
}
|
|
if (roleChange) {
|
|
auth.setRole(u.username, body.role);
|
|
done.push(`role ${body.role}`);
|
|
}
|
|
for (const id of add) auth.assignWorkspace(id, u.username);
|
|
for (const id of remove) auth.unassignWorkspace(id, u.username);
|
|
if (add.length) done.push(`workspace ${add.join(", ")}`);
|
|
if (remove.length) done.push(`removed workspace ${remove.join(", ")}`);
|
|
// An admin changing their own password or signing themselves out keeps
|
|
// the login they are doing it from.
|
|
const keepToken = self ? auth.cookieToken(req) : undefined;
|
|
if (body.password !== undefined) {
|
|
await auth.setPassword(u.username, body.password, { keepToken, mustChange: !self });
|
|
done.push("password reset");
|
|
}
|
|
if (body.signOut) {
|
|
auth.endSessions(u.id, keepToken);
|
|
done.push("signed out everywhere");
|
|
}
|
|
if (quota) {
|
|
quotaLib.setUserQuota(u.id, quota);
|
|
done.push(`spend quota ${quotaLib.describe(quota)}`);
|
|
quotaSweep();
|
|
}
|
|
if (disabledChange) {
|
|
auth.setDisabled(u.username, body.disabled);
|
|
done.push(body.disabled ? "disable" : "enable");
|
|
}
|
|
return {
|
|
user: { ...auth.publicUser(target(req)), ...auth.loginStats(u.id) },
|
|
action: `${u.username}: ${done.join(", ") || "no change"}`,
|
|
};
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Delete a user for good (auth.deleteUser): their logins, panes, workspaces'
|
|
* records and — under S3 — their Linux account and whole home. Never
|
|
* yourself (ESELF) or the last admin (ELASTADMIN); the admin's password
|
|
* again, like every account change, and written to the audit log.
|
|
*/
|
|
router.delete(
|
|
"/:id",
|
|
accountChange(async (req) => {
|
|
const u = target(req);
|
|
if (u.id === req.user.id) {
|
|
throw Object.assign(new Error("you cannot delete yourself"), { code: "ESELF" });
|
|
}
|
|
const gone = auth.deleteUser(u.username);
|
|
return {
|
|
user: auth.publicUser(u),
|
|
action: `delete ${u.username} (${gone.workspaces} workspace(s), home removed)`,
|
|
};
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Restart one member's tmux server (every pane of theirs ends, so the next
|
|
* start runs the installed tmux) — the admin's password again, audited.
|
|
*/
|
|
router.post(
|
|
"/:id/tmux-restart",
|
|
accountChange(async (req) => {
|
|
const u = target(req);
|
|
const r = require("../lib/member-tmux").restart(u);
|
|
return {
|
|
user: auth.publicUser(u),
|
|
action: r.restarted
|
|
? `${u.username}: tmux restarted (was ${r.before.version || "?"}, ${r.before.sessions ?? "?"} session(s))`
|
|
: `${u.username}: tmux restart — no server was running`,
|
|
};
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Restart the tmux server of every member running an older tmux than the
|
|
* installed one while no agent of theirs works. One password, one audit
|
|
* entry per member restarted.
|
|
*/
|
|
router.post("/tmux-restart-idle", async (req, res) => {
|
|
try {
|
|
if (!auth.isServerMode() || !req.user) {
|
|
throw Object.assign(new Error("accounts are managed in server mode only"), {
|
|
code: "ELOCALMODE",
|
|
});
|
|
}
|
|
const body = req.body && typeof req.body === "object" ? req.body : {};
|
|
await auth.confirmPassword(req.user, body.currentPassword);
|
|
const mt = require("../lib/member-tmux");
|
|
const all = db.prepare("SELECT * FROM users ORDER BY id").all();
|
|
const rows = mt.overview(all.map(auth.publicUser)).users;
|
|
const restarted = [];
|
|
const skipped = [];
|
|
const failed = [];
|
|
for (const row of rows) {
|
|
if (!row.outdated) continue;
|
|
if (row.busy > 0) {
|
|
skipped.push(row.username);
|
|
continue;
|
|
}
|
|
const u = all.find((x) => x.id === row.id);
|
|
try {
|
|
const r = mt.restart(u);
|
|
if (!r.restarted) continue;
|
|
restarted.push(row.username);
|
|
recordAccountChange(
|
|
req.user.id,
|
|
u.id,
|
|
"POST",
|
|
`${u.username}: tmux restarted (idle, was ${row.version})`
|
|
);
|
|
} catch (e) {
|
|
failed.push({ username: row.username, message: e.message });
|
|
}
|
|
}
|
|
res.json({ restarted, skipped, failed });
|
|
} catch (err) {
|
|
const status = STATUS[err.code];
|
|
if (!status) return res.status(500).json({ error: { message: err.message } });
|
|
res.status(status).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
});
|
|
|
|
router.post(
|
|
"/:id/password",
|
|
accountChange(async (req, body) => {
|
|
const u = target(req);
|
|
const self = u.id === req.user.id;
|
|
if (self) {
|
|
auth.validateOwnPassword(body.password, u.username);
|
|
await auth.assertNotCurrent(u.id, body.password);
|
|
}
|
|
await auth.setPassword(u.username, body.password, {
|
|
keepToken: self ? auth.cookieToken(req) : undefined,
|
|
mustChange: !self,
|
|
});
|
|
return { user: auth.publicUser(u), action: `${u.username}: password reset` };
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
"/:id/workspaces",
|
|
accountChange(async (req, body) => {
|
|
const u = target(req);
|
|
auth.assignWorkspace(body.laneId, u.username);
|
|
return { user: auth.publicUser(u), action: `${u.username}: workspace ${Number(body.laneId)}` };
|
|
})
|
|
);
|
|
|
|
module.exports = router;
|
|
// Which account each session belongs to, for the spend quotas (server/lib/user-quota.js).
|
|
module.exports.OWNED_SESSIONS = OWNED_SESSIONS;
|