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.
408 lines
15 KiB
JavaScript
408 lines
15 KiB
JavaScript
/**
|
|
* @file me.js
|
|
* @description `/api/me` — the signed-in account's own UI preferences
|
|
* (`user_prefs`) and its first-run setup (docs/plans/onboarding), so they
|
|
* follow it to any browser, differ between the accounts of one dashboard, and
|
|
* the setup wizard shows once per account. Local mode is one account,
|
|
* `local`; server mode keys a row `u<id>` by the login cookie — the request
|
|
* never names an account. Only the keys in `PREF_KEYS` are kept, each a
|
|
* string of at most 8 KB (the sidebar's workspace groups 64 KB), and a PUT merges into the row (`null` removes a
|
|
* key). Writes are same-origin and touch only the caller's own row.
|
|
* `/instructions` is the account's own agent instructions (issue #67, at most
|
|
* 16 000 characters, blank clears), put before the workspace's in every pane
|
|
* of a workspace the account owns (`launchExtras` in lib/lane-sources.js);
|
|
* `tools` names the AI tools whose panes receive them.
|
|
* `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; the new one
|
|
* must pass every rule of `auth.passwordProblems` and differ from the old —
|
|
* EWEAKPASSWORD with the failed `rules` — and it clears the "pick your own
|
|
* password" flag an admin's create/reset sets);
|
|
* `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.
|
|
* `/avatar` (server mode) is the account's own picture (server/lib/avatars.js):
|
|
* GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes
|
|
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` stores an https link
|
|
* instead, which `GET /avatar` redirects to (server/lib/avatar-url.js) and
|
|
* DELETE removes it — all writes are account writes like the profile's. The sidebar shows it beside the name.
|
|
* `/pool-accounts` (server mode) is the account pool as this account may use
|
|
* it (server/lib/account-pins.js `myAccounts`): GET lists, per tool, the
|
|
* accounts its group allows — never a token — with the admin's pin and its
|
|
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
|
|
* while an admin pins it (`ELOCKED`) or for an account it may not use
|
|
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
|
|
* `/quota` is the account's own spend quota now (server/lib/user-quota.js —
|
|
* `null` without one, and in local mode), for the status bar.
|
|
*
|
|
* 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>
|
|
*/
|
|
|
|
const express = require("express");
|
|
const { Router } = express;
|
|
const { db } = require("../db");
|
|
const auth = require("../lib/auth");
|
|
const { sameOriginGuard } = require("../lib/same-origin");
|
|
|
|
const router = Router();
|
|
|
|
/** The localStorage keys an account's preferences are made of. */
|
|
const PREF_KEYS = [
|
|
"theme",
|
|
"agenthub.theme.dark",
|
|
"agenthub.theme.light",
|
|
"i18nextLng",
|
|
"agenthub.terminal.font",
|
|
"agenthub.terminal.fontSize",
|
|
"agenthub.ui.fontSize",
|
|
"agenthub.pet",
|
|
"agenthub.pet.size",
|
|
"agenthub.pet.names",
|
|
"agent-dashboard-sagi-enabled",
|
|
"agent-dashboard-sagi-muted",
|
|
"agent-monitor-notifications",
|
|
"agenthub.defaultTool",
|
|
"agenthub.permissionMode",
|
|
"agenthub.codex.sandbox",
|
|
"agenthub.codex.approval",
|
|
"agenthub.codex.bypass",
|
|
"agenthub.tour",
|
|
"agenthub.chatUi",
|
|
"agenthub.workspace.allTabs",
|
|
"agenthub.markdown.view",
|
|
"agenthub.workspace.groups",
|
|
"agenthub.changes.innerRepos",
|
|
];
|
|
const MAX_VALUE = 8 * 1024;
|
|
// The sidebar's workspace groups (lane id → group, JSON) outgrow 8 KB long
|
|
// before any other key would.
|
|
const MAX_FOR = { "agenthub.workspace.groups": 64 * 1024 };
|
|
|
|
/** `u<id>` for a server-mode account, `local` in local mode, null when nobody is signed in. */
|
|
function userKey(req) {
|
|
if (!auth.isServerMode()) return "local";
|
|
const user = req.user || auth.userFromRequest(req);
|
|
return user ? `u${user.id}` : null;
|
|
}
|
|
|
|
const getRow = db.prepare("SELECT prefs, onboarded_at FROM user_prefs WHERE user_key = ?");
|
|
const upsert = db.prepare(
|
|
`INSERT INTO user_prefs (user_key, prefs) VALUES (?, ?)
|
|
ON CONFLICT(user_key) DO UPDATE SET prefs = excluded.prefs,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
|
|
);
|
|
const markOnboarded = db.prepare(
|
|
`INSERT INTO user_prefs (user_key, onboarded_at) VALUES (?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
ON CONFLICT(user_key) DO UPDATE SET onboarded_at = COALESCE(onboarded_at, excluded.onboarded_at)`
|
|
);
|
|
|
|
function read(key) {
|
|
const row = getRow.get(key);
|
|
let prefs = {};
|
|
try {
|
|
prefs = row ? JSON.parse(row.prefs) : {};
|
|
} catch {
|
|
/* a damaged row reads as empty */
|
|
}
|
|
return { prefs, onboardedAt: row?.onboarded_at || null };
|
|
}
|
|
|
|
const unauth = (res) =>
|
|
res.status(401).json({ error: { code: "EAUTH", message: "sign in first" } });
|
|
|
|
router.get("/prefs", (req, res) => {
|
|
const key = userKey(req);
|
|
if (!key) return unauth(res);
|
|
res.json(read(key));
|
|
});
|
|
|
|
router.put("/prefs", sameOriginGuard, (req, res) => {
|
|
const key = userKey(req);
|
|
if (!key) return unauth(res);
|
|
const patch = req.body?.prefs;
|
|
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
return res.status(400).json({ error: { code: "EBADPREFS", message: "prefs: an object" } });
|
|
}
|
|
const { prefs } = read(key);
|
|
for (const [k, v] of Object.entries(patch)) {
|
|
if (!PREF_KEYS.includes(k)) {
|
|
return res.status(400).json({ error: { code: "EBADPREFS", message: `unknown key ${k}` } });
|
|
}
|
|
if (v === null) delete prefs[k];
|
|
else if (typeof v === "string" && v.length <= (MAX_FOR[k] ?? MAX_VALUE)) prefs[k] = v;
|
|
else
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "EBADPREFS", message: `${k}: a short string` } });
|
|
}
|
|
upsert.run(key, JSON.stringify(prefs));
|
|
if ("theme" in patch) followClaudeTheme(req, patch.theme);
|
|
res.json(read(key));
|
|
});
|
|
|
|
const sources = require("../lib/lane-sources");
|
|
const instructionsView = (key) => ({
|
|
instructions: sources.getUserInstructions(key) || "",
|
|
max: sources.MAX_INSTRUCTIONS,
|
|
// The tools a pane of which actually receives them (see launchExtras).
|
|
tools: [...sources.SUPPORTED_TOOLS],
|
|
});
|
|
|
|
router.get("/instructions", (req, res) => {
|
|
const key = userKey(req);
|
|
if (!key) return unauth(res);
|
|
res.json(instructionsView(key));
|
|
});
|
|
|
|
router.put("/instructions", sameOriginGuard, (req, res) => {
|
|
const key = userKey(req);
|
|
if (!key) return unauth(res);
|
|
try {
|
|
sources.setUserInstructions(key, req.body?.instructions ?? null);
|
|
} catch (err) {
|
|
const status = err.code === "ETOOBIG" ? 413 : 400; // as PUT /api/lanes/:id/instructions
|
|
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
res.json(instructionsView(key));
|
|
});
|
|
|
|
// The account's own spend quota now (server/lib/user-quota.js): `{quota}` —
|
|
// limit, period, spend, whether it is used up and when it opens again — or
|
|
// `{quota: null}` with no limit, and always in local mode.
|
|
router.get("/quota", (req, res) => {
|
|
const key = userKey(req);
|
|
if (!key) return unauth(res);
|
|
const id = auth.isServerMode() ? (req.user || auth.userFromRequest(req))?.id : null;
|
|
res.json({ quota: id == null ? null : require("../lib/user-quota").statusFor(id) });
|
|
});
|
|
|
|
router.post("/onboarded", sameOriginGuard, (req, res) => {
|
|
const key = userKey(req);
|
|
if (!key) return unauth(res);
|
|
markOnboarded.run(key);
|
|
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,
|
|
ENAMETAKEN: 409,
|
|
EBADPASSWORD: 400,
|
|
EWEAKPASSWORD: 400,
|
|
EBADIMAGE: 400,
|
|
EBADURL: 400,
|
|
EPRIVATEURL: 400,
|
|
EFETCH: 502,
|
|
ETOOBIG: 413,
|
|
EREAUTH: 403,
|
|
ETOOMANY: 429,
|
|
EBADTOOL: 400,
|
|
ENOUSER: 404,
|
|
ENOACCOUNT: 404,
|
|
ELOCKED: 409,
|
|
};
|
|
|
|
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 } });
|
|
// EWEAKPASSWORD names the rules the password failed.
|
|
res.status(status).json({
|
|
error: { code: err.code, message: err.message, ...(err.rules && { rules: err.rules }) },
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
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.assertNameFree(user.id, fields);
|
|
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);
|
|
// The account's own choice: every rule, and not the one it has now. This
|
|
// also clears a "pick your own password" flag an admin's reset left.
|
|
auth.validateOwnPassword(body.newPassword, user.username);
|
|
await auth.assertNotCurrent(user.id, body.newPassword);
|
|
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)` };
|
|
})
|
|
);
|
|
|
|
const avatars = require("../lib/avatars");
|
|
const { sendAvatarLink } = require("../lib/avatar-redirect");
|
|
|
|
router.get("/avatar", (req, res) => {
|
|
if (!auth.isServerMode()) return res.status(404).json({ error: { code: "ENOAVATAR" } });
|
|
const user = req.user || auth.userFromRequest(req);
|
|
if (!user) return unauth(res);
|
|
if (sendAvatarLink(res, user.id)) return;
|
|
const file = avatars.avatarFile(user.id);
|
|
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'");
|
|
// The client versions the URL with `avatarAt`, so a new picture is a new URL.
|
|
res.set("Cache-Control", "private, max-age=86400");
|
|
require("../lib/send-file").sendFile(res, file.path);
|
|
});
|
|
|
|
router.put(
|
|
"/avatar",
|
|
sameOriginGuard,
|
|
express.raw({ type: () => true, limit: avatars.MAX_AVATAR_BYTES }),
|
|
accountWrite(async (req, user) => {
|
|
avatars.setAvatar(user.id, req.body);
|
|
return { out: accountView(user.id), action: "own avatar set" };
|
|
})
|
|
);
|
|
|
|
// A picture by link: stored as it is, never downloaded — GET /avatar redirects
|
|
// the browser to it (server/lib/avatar-url.js).
|
|
router.post(
|
|
"/avatar/url",
|
|
sameOriginGuard,
|
|
accountWrite(async (_req, user, body) => {
|
|
avatars.setAvatarLink(user.id, body.url);
|
|
return { out: accountView(user.id), action: "own avatar set from a link" };
|
|
})
|
|
);
|
|
|
|
router.delete(
|
|
"/avatar",
|
|
sameOriginGuard,
|
|
accountWrite(async (_req, user) => {
|
|
avatars.removeAvatar(user.id);
|
|
return { out: accountView(user.id), action: "own avatar removed" };
|
|
})
|
|
);
|
|
|
|
// ── The account pool: the user's own choice of account per tool ───────────
|
|
|
|
const pins = require("../lib/account-pins");
|
|
|
|
/** Every tool with an account this user may use: those accounts, the admin's pin, their choice. */
|
|
router.get("/pool-accounts", (req, res) => {
|
|
if (!auth.isServerMode()) return res.json({ tools: [] });
|
|
const user = req.user || auth.userFromRequest(req);
|
|
if (!user) return unauth(res);
|
|
try {
|
|
res.json({ tools: pins.myAccounts(user.id) });
|
|
} catch (e) {
|
|
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* `{tool, account}` — run this user's new sessions of `tool` on `account`
|
|
* (`null`: the balancer picks). A Claude login goes into their home at once,
|
|
* like an admin's switch (`switched`); anything else at the next session.
|
|
*/
|
|
router.put(
|
|
"/pool-accounts",
|
|
sameOriginGuard,
|
|
accountWrite(async (_req, user, body) => {
|
|
const choice = pins.setChoice(user.id, body.tool, body.account ?? null);
|
|
let switched = null;
|
|
if (choice) {
|
|
try {
|
|
switched = await pins.applyPin(user.id, body.tool);
|
|
} catch (e) {
|
|
console.warn(`[me] switch ${user.username} → ${choice}: ${e.message}`);
|
|
switched = "next-session";
|
|
}
|
|
}
|
|
return {
|
|
out: { tool: body.tool, choice, switched },
|
|
action: choice
|
|
? `own ${body.tool} pool account ${choice}`
|
|
: `own ${body.tool} pool account: automatic`,
|
|
};
|
|
})
|
|
);
|
|
|
|
// An avatar past its limit reaches here as express.raw's 413.
|
|
router.use((e, _req, res, next) => {
|
|
if (e && e.type === "entity.too.large")
|
|
return res.status(413).json({ error: { code: "ETOOBIG", message: "the image is too large" } });
|
|
return next(e);
|
|
});
|
|
|
|
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
|
|
function followClaudeTheme(req, mode) {
|
|
if (mode !== "dark" && mode !== "light") return;
|
|
try {
|
|
const runAs = require("../lib/run-as");
|
|
const acct = runAs.accountForUser(req.user?.username);
|
|
if (acct) require("../lib/member-claude").setTheme(acct, mode);
|
|
} catch (err) {
|
|
console.warn("[me] Claude theme not written:", err.message);
|
|
}
|
|
}
|
|
|
|
module.exports = router;
|
|
module.exports.PREF_KEYS = PREF_KEYS;
|