feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,14 @@
|
||||
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
|
||||
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
|
||||
*
|
||||
* `/account` (server mode) is the signed-in account itself for Settings →
|
||||
* Account: GET its profile, workspace/session counts and logins; PATCH its
|
||||
* display name and email; `POST /password` changes its own password (the old
|
||||
* one required, throttled like a step-up; every OTHER login ends);
|
||||
* `POST /sign-out-others` ends every login but this one. These writes take the
|
||||
* account from the login COOKIE only — a pane token (an agent acting as its
|
||||
* owner) may not change the owner's login. Each change is audited.
|
||||
*
|
||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||
* the theme in their own Claude Code settings, written as them.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -142,6 +150,90 @@ router.post("/onboarded", sameOriginGuard, (req, res) => {
|
||||
res.json(read(key));
|
||||
});
|
||||
|
||||
// ── The account itself (server mode) ─────────────────────────────────────────
|
||||
|
||||
const { sessionWhere } = require("../lib/scope");
|
||||
const { recordAccountChange } = require("../lib/audit");
|
||||
|
||||
const ACCOUNT_STATUS = {
|
||||
EBADFIELD: 400,
|
||||
EBADEMAIL: 400,
|
||||
EBADPASSWORD: 400,
|
||||
EREAUTH: 403,
|
||||
ETOOMANY: 429,
|
||||
};
|
||||
|
||||
function accountView(userId) {
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||
const w = sessionWhere({ user: { id: userId, role: "developer" } });
|
||||
return {
|
||||
mode: "server",
|
||||
user: auth.publicUser(row),
|
||||
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(userId).n,
|
||||
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params).n,
|
||||
...auth.loginStats(userId),
|
||||
};
|
||||
}
|
||||
|
||||
/** A write to the account: the cookie's own user, never a pane token's. */
|
||||
function accountWrite(handler) {
|
||||
return async (req, res) => {
|
||||
if (!auth.isServerMode()) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: { code: "ELOCALMODE", message: "no account in local mode" } });
|
||||
}
|
||||
const user = auth.userFromRequest(req);
|
||||
if (!user) return unauth(res);
|
||||
try {
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
const { out, action } = await handler(req, user, body);
|
||||
recordAccountChange(user.id, user.id, req.method, `${user.username}: ${action}`);
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
const status = ACCOUNT_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.get("/account", (req, res) => {
|
||||
if (!auth.isServerMode()) return res.json({ mode: "local" });
|
||||
const user = req.user || auth.userFromRequest(req);
|
||||
if (!user) return unauth(res);
|
||||
res.json(accountView(user.id));
|
||||
});
|
||||
|
||||
router.patch(
|
||||
"/account",
|
||||
sameOriginGuard,
|
||||
accountWrite(async (_req, user, body) => {
|
||||
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
|
||||
auth.setProfile(user.id, fields);
|
||||
return { out: accountView(user.id), action: "own profile" };
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/password",
|
||||
sameOriginGuard,
|
||||
accountWrite(async (req, user, body) => {
|
||||
await auth.confirmPassword(user, body.currentPassword);
|
||||
await auth.setPassword(user.username, body.newPassword, { keepToken: auth.cookieToken(req) });
|
||||
return { out: { ok: true }, action: "own password changed" };
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/sign-out-others",
|
||||
sameOriginGuard,
|
||||
accountWrite(async (req, user) => {
|
||||
const ended = auth.endSessions(user.id, auth.cookieToken(req));
|
||||
return { out: { ended }, action: `signed out ${ended} other login(s)` };
|
||||
})
|
||||
);
|
||||
|
||||
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
|
||||
function followClaudeTheme(req, mode) {
|
||||
if (mode !== "dark" && mode !== "light") return;
|
||||
|
||||
Reference in New Issue
Block a user