diff --git a/CLAUDE.md b/CLAUDE.md index 4b5ae194..e86f54f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.14 (2026-10-21) — Artifacts: Uninstall for an installed skill (moved aside, never deleted) or plugin (`claude plugin uninstall --keep-data`) beside Reinstall._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.15 (2026-10-22) — Oh My Pi login sharing (S3, omp's broker + gateway keyed by pane token); omp `--allow-home`; Markdown opens as Preview by default._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -83,6 +83,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. - **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`; tmux through `/opt/agenthub/libexec/tmux-member` once installed, which runs the member's RUNNING server's own binary so a tmux upgrade never mixes versions); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable/delete — `delete` is an admin's step-up-confirmed Delete user (`DELETE /api/users/:id`, `auth.deleteUser`: Linux account + home FIRST, rows only after it succeeded) and removes only an `agenthub-members` account and exactly `/` as a real directory, `rm -rf --one-file-system`, never through a link; it never adopts a non-member account, and otherwise root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook. - **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one — except an admin's switch of that user (`account-pins.js` → `claude-pool.switchHome`, Claude only), which first copies every home's newer pair back (`refreshLogins`), never writes over the member's own login, and restarts no pane; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it. +- **A shared Oh My Pi login is reached only through omp's gateway, by a pane token, never handed out.** `server/lib/omp-share.js` (S3 only, an admin's switch, off by default) runs `omp auth-broker serve` + `omp auth-gateway serve --clients=` as the host on loopback; both tokens stay in the host's home and the broker's reaches only the gateway's env — never a pane, a route, a log or argv. A member's omp gets the `agenthub` provider in its `models.yml` (written as them) whose `apiKey` is the env var NAME `AGENTHUB_PANE_TOKEN`, and the clients file holds only the SHA-256 of LIVE omp panes' tokens. Do not give a pane the broker or gateway token, `OMP_AUTH_BROKER_*`, or a copy of the host's `agent.db`. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. - Never silently weaken safety controls around destructive actions. diff --git a/bin/agenthub.js b/bin/agenthub.js index 442cf83b..23a48abf 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -4166,14 +4166,25 @@ async function cmdUsers(sub, positional, flags) { case "list": table( ["id", "username", "role", "state"], - auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"]) + auth + .listUsers() + .map((u) => [ + u.id, + u.username, + u.role, + u.disabled ? "disabled" : u.mustChangePassword ? "active (new password due)" : "active", + ]) ); return; case "add": { need(); const role = typeof flags.role === "string" ? flags.role : "developer"; - const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role); - console.log(`${c.green("✔")} ${user.username} added (${user.role})`); + const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role, { + mustChange: true, + }); + console.log( + `${c.green("✔")} ${user.username} added (${user.role}) — they choose their own password at first login` + ); return; } case "passwd": diff --git a/client/src/components/AuthCard.tsx b/client/src/components/AuthCard.tsx new file mode 100644 index 00000000..289be4a1 --- /dev/null +++ b/client/src/components/AuthCard.tsx @@ -0,0 +1,64 @@ +/** + * @file AuthCard.tsx + * @description The card the signed-out screens share (the login page, the + * first-login password change in `FirstPasswordPage.tsx`): a centred form + * with the theme's accent glow and grid behind the brand logo, a title and a + * subtitle. While it shows, the OS's light/dark applies (`applySystemTheme`), + * since no account's theme has been chosen for it. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useLayoutEffect, type FormEvent, type ReactNode } from "react"; +import { BRAND_LOGO } from "../lib/brand"; +import { applySystemTheme } from "../hooks/useTheme"; + +export function AuthCard({ + title, + subtitle, + onSubmit, + children, +}: { + title: string; + subtitle: string; + onSubmit: (e: FormEvent) => void; + children: ReactNode; +}) { + // Follow the OS, live. + useLayoutEffect(() => { + applySystemTheme(); + const mq = window.matchMedia?.("(prefers-color-scheme: dark)"); + mq?.addEventListener?.("change", applySystemTheme); + return () => mq?.removeEventListener?.("change", applySystemTheme); + }, []); + + return ( +
+
+ {/* Accent glow and a grid fading out from the top, drawn from the + theme's own accent so it holds in every theme. */} +
+
+
+ SmartGift +

{title}

+

{subtitle}

+
+ {children} +
+ +
+ ); +} diff --git a/client/src/components/AuthGate.tsx b/client/src/components/AuthGate.tsx index 0f103568..cfe0a302 100644 --- a/client/src/components/AuthGate.tsx +++ b/client/src/components/AuthGate.tsx @@ -11,7 +11,9 @@ * `usePermissions()` says what that user's role may do, so pages hide the rest. * Once an account is known (local mode is one), its own UI preferences are * loaded into this browser (`lib/userPrefs.ts`). - * The login page follows the OS's light/dark (`applySystemTheme`). + * The login page follows the OS's light/dark (`AuthCard`). An account whose + * password an admin gave (`mustChangePassword`) sees only + * `FirstPasswordPage` until it picks its own. * @author Nguyễn Ngọc Trí Vĩ */ @@ -20,15 +22,14 @@ import { useCallback, useContext, useEffect, - useLayoutEffect, useState, type FormEvent, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { Eye, EyeOff } from "lucide-react"; -import { BRAND_LOGO } from "../lib/brand"; -import { applySystemTheme } from "../hooks/useTheme"; +import { AuthCard } from "./AuthCard"; +import { FirstPasswordPage } from "./FirstPasswordPage"; import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api"; import { hydrate } from "../lib/userPrefs"; @@ -165,6 +166,17 @@ export function AuthGate({ children }: { children: ReactNode }) { await api.auth.logout().catch(() => {}); setState({ mode: "server", user: null }); }; + // A password an admin gave: nothing else until the user picks their own + // (the server refuses the rest anyway — EPWCHANGE). + if (state.mode === "server" && state.user?.mustChangePassword) { + return ( + updateUser({ mustChangePassword: false })} + onLogout={() => void logout()} + /> + ); + } return ( {children} ); @@ -178,14 +190,6 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - // Nobody is signed in, so no account's theme applies: follow the OS, live. - useLayoutEffect(() => { - applySystemTheme(); - const mq = window.matchMedia?.("(prefers-color-scheme: dark)"); - mq?.addEventListener?.("change", applySystemTheme); - return () => mq?.removeEventListener?.("change", applySystemTheme); - }, []); - const submit = async (e: FormEvent) => { e.preventDefault(); setBusy(true); @@ -201,86 +205,60 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) { } }; + // Nobody is signed in, so no account's theme applies: AuthCard follows the OS. return ( -
-
- {/* Accent glow and a grid fading out from the top, drawn from the - theme's own accent so it holds in every theme. */} -
+
+ + {error && ( +

+ {error} +

+ )} + {/* Inverted fill (text colour behind the surface colour): black in a + light theme, light in a dark one, like the reference design. */} + +

{t("auth.noAccount")}

+ ); } diff --git a/client/src/components/FirstPasswordPage.tsx b/client/src/components/FirstPasswordPage.tsx new file mode 100644 index 00000000..5d129f53 --- /dev/null +++ b/client/src/components/FirstPasswordPage.tsx @@ -0,0 +1,110 @@ +/** + * @file FirstPasswordPage.tsx + * @description The screen an account signed in with a password an admin GAVE + * (created or reset on the Users page — `mustChangePassword`) sees instead of + * the app: the current password, a new one that must pass every rule (the + * `PasswordRules` checklist ticks them as they are typed) and the same again, + * sent to `POST /api/me/password`. On success the app opens; "Sign out" + * leaves. The server refuses every other request meanwhile (EPWCHANGE). + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { api, type AuthUser } from "../lib/api"; +import { passwordProblems } from "../lib/passwordRules"; +import { AuthCard } from "./AuthCard"; +import { PasswordRules } from "./users/PasswordRules"; + +export function FirstPasswordPage({ + user, + onDone, + onLogout, +}: { + user: AuthUser; + onDone: () => void; + onLogout: () => void; +}) { + const { t } = useTranslation(); + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [again, setAgain] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const mismatch = again.length > 0 && again !== next; + const strong = passwordProblems(next, user.username).length === 0; + const ready = current.length > 0 && strong && again === next && !busy; + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (!ready) return; + setBusy(true); + setError(null); + try { + await api.me.changePassword(current, next); + onDone(); + } catch (err) { + const code = (err as { code?: string }).code; + setError( + code && t(`users:errors.${code}`, { defaultValue: "" }) + ? t(`users:errors.${code}`) + : (err as Error).message + ); + } finally { + setBusy(false); + } + }; + + const field = ( + label: string, + value: string, + set: (v: string) => void, + autoComplete: string, + autoFocus = false + ) => ( + + ); + + return ( + + {field(t("auth.currentPassword"), current, setCurrent, "current-password", true)} + {field(t("auth.newPassword"), next, setNext, "new-password")} + + {field(t("auth.confirmPassword"), again, setAgain, "new-password")} + {(mismatch || error) && ( +

+ {error ?? t("auth.mismatch")} +

+ )} + + +
+ ); +} diff --git a/client/src/components/__tests__/AuthGate.test.tsx b/client/src/components/__tests__/AuthGate.test.tsx index 9b5248fe..5f5fa03c 100644 --- a/client/src/components/__tests__/AuthGate.test.tsx +++ b/client/src/components/__tests__/AuthGate.test.tsx @@ -6,7 +6,9 @@ * `useWorkLanes` mirror the server's roles: an admin changes everything, an * analyst reads everything but works only in their own workspaces, a * developer only ever sees (and so changes) their own. The login page wears the - * OS's light/dark; a sign-in swaps in the account's saved appearance. + * OS's light/dark; a sign-in swaps in the account's saved appearance. A + * password an admin gave shows only the "choose your own password" page, + * whose button waits for every rule, until the change succeeds. * @author Nguyễn Ngọc Trí Vĩ */ @@ -18,6 +20,7 @@ import "../../i18n"; const me = vi.fn(); const login = vi.fn(); const prefs = vi.fn(); +const changePassword = vi.fn(); const LANES = [ { id: 1, owner_id: 5 }, { id: 2, owner_id: 6 }, @@ -27,7 +30,11 @@ vi.mock("../../lib/api", () => ({ api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() }, lanes: { list: () => Promise.resolve({ lanes: LANES }) }, - me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) }, + me: { + prefs: () => prefs(), + savePrefs: () => Promise.resolve({ prefs: {} }), + changePassword: (c: string, n: string) => changePassword(c, n), + }, }, })); @@ -56,10 +63,30 @@ beforeEach(() => { me.mockReset(); login.mockReset(); prefs.mockReset().mockResolvedValue({ prefs: {} }); + changePassword.mockReset().mockResolvedValue({ ok: true }); localStorage.clear(); }); describe("AuthGate", () => { + it("makes an account with an admin-given password choose its own first", async () => { + const ue = userEvent.setup(); + me.mockResolvedValue({ mode: "server", user: { ...user, mustChangePassword: true } }); + render(app); + const save = await screen.findByRole("button", { name: "Save and continue" }); + expect(screen.queryByText("app")).toBeNull(); + await ue.type(screen.getByLabelText("Current password"), "123"); + await ue.type(screen.getByLabelText("New password"), "weakpass"); + await ue.type(screen.getByLabelText("Confirm new password"), "weakpass"); + expect(save).toBeDisabled(); + await ue.clear(screen.getByLabelText("New password")); + await ue.clear(screen.getByLabelText("Confirm new password")); + await ue.type(screen.getByLabelText("New password"), "Green-Tea-42"); + await ue.type(screen.getByLabelText("Confirm new password"), "Green-Tea-42"); + await ue.click(save); + expect(changePassword).toHaveBeenCalledWith("123", "Green-Tea-42"); + expect(await screen.findByText("app")).toBeInTheDocument(); + }); + it("renders the app in local mode", async () => { me.mockResolvedValue({ mode: "local" }); render(app); diff --git a/client/src/components/__tests__/MarkdownPreview.test.tsx b/client/src/components/__tests__/MarkdownPreview.test.tsx index 5e5514d3..0a2af4c2 100644 --- a/client/src/components/__tests__/MarkdownPreview.test.tsx +++ b/client/src/components/__tests__/MarkdownPreview.test.tsx @@ -58,14 +58,25 @@ describe("MarkdownPreview", () => { describe("FileViewer — markdown preview", () => { const data = { text: "# Hello\n", truncated: false } as never; - it("switches a markdown file between raw and preview", async () => { + it("opens a markdown file rendered, and switches it to raw and back", async () => { render(); + expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Raw" })); expect(screen.queryByTestId("markdown-preview")).toBeNull(); expect(screen.getByText("# Hello")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Preview" })); expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Raw" })); - expect(screen.queryByTestId("markdown-preview")).toBeNull(); + }); + + it("opens it as source when Settings → Appearance says Raw", () => { + window.localStorage.setItem("agenthub.markdown.view", "raw"); + try { + render(); + expect(screen.queryByTestId("markdown-preview")).toBeNull(); + expect(screen.getByText("# Hello")).toBeInTheDocument(); + } finally { + window.localStorage.removeItem("agenthub.markdown.view"); + } }); it("offers no preview for another file type", () => { diff --git a/client/src/components/artifacts/ArtifactAddPanels.tsx b/client/src/components/artifacts/ArtifactAddPanels.tsx index f6f7765d..9251e876 100644 --- a/client/src/components/artifacts/ArtifactAddPanels.tsx +++ b/client/src/components/artifacts/ArtifactAddPanels.tsx @@ -20,7 +20,7 @@ import { useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { useTranslation } from "react-i18next"; -import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react"; +import { X } from "lucide-react"; import { api, type MarketplaceInfo } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { toast } from "../../lib/toast"; @@ -374,7 +374,6 @@ export function SkillAddPanel({ dragging ? "border-accent bg-accent/10" : "border-border" }`} > -

{t("skill.dropHint")}

{skills.map((s) => (
  • - {s.name} {t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")} @@ -746,10 +744,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl {info && (
    -

    - - {info.name} -

    +

    {info.name}

    {info.description &&

    {info.description}

    }
    {info.plugins.length === 0 ? ( @@ -949,7 +944,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) { className="btn-primary shrink-0 text-sm" disabled={busy || !file || tooBig} > - {t("file.upload")} + {t("file.upload")}
    diff --git a/client/src/components/config/FileViewer.tsx b/client/src/components/config/FileViewer.tsx index e96f1846..a7b1155c 100644 --- a/client/src/components/config/FileViewer.tsx +++ b/client/src/components/config/FileViewer.tsx @@ -4,7 +4,8 @@ * already-fetched data as props (path/data/error) rather than fetching * itself, so it stays tool-agnostic — the orchestrator (Claude or Codex * config page) owns the fetch. A markdown file gets a Raw / Preview switch - * (`MarkdownPreview`); Raw is the default. + * (`MarkdownPreview`), opening as Settings → Appearance says + * (`lib/markdownView.ts`: Preview by default). * @author Nguyễn Ngọc Trí Vĩ */ @@ -15,6 +16,7 @@ import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react"; import type { CcFileResponse } from "../../lib/api"; import { CopyButton } from "./configPrimitives"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; +import { readMarkdownPreview } from "../../lib/markdownView"; export function FileViewer({ state, @@ -24,7 +26,7 @@ export function FileViewer({ onClose: () => void; }) { const { t } = useTranslation("ccConfig"); - const [preview, setPreview] = useState(false); + const [preview, setPreview] = useState(readMarkdownPreview); const canPreview = isMarkdown(state.path) && !!state.data; const panelRef = useRef(null); useDialog(true, panelRef, onClose); diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx index da915441..d0e92aad 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -106,6 +106,7 @@ import { import { api, gitTarget } from "../../lib/api"; import { usePermissions } from "../AuthGate"; import { pasteToLane } from "../../lib/panePaste"; +import { readMarkdownPreview } from "../../lib/markdownView"; import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm"; import { copyText } from "../../lib/clipboard"; import { isSubmitEnter } from "../../lib/keys"; @@ -873,9 +874,10 @@ export function LaneChangesPanel({ const [layout, setLayout] = useState(wide ? "split" : "unified"); useEffect(() => setLayout(wide ? "split" : "unified"), [wide]); const [whole, setWhole] = useState(false); - // A whole markdown file shown rendered instead of as source; per file. - const [mdPreview, setMdPreview] = useState(false); - useEffect(() => setMdPreview(false), [openFile]); + // A whole markdown file shown rendered instead of as source; per file, each + // one opening as Settings → Appearance says (`lib/markdownView.ts`). + const [mdPreview, setMdPreview] = useState(readMarkdownPreview); + useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]); const [data, setData] = useState(null); // A folder that is not a repo (an empty new workspace, a parent of clones) // has no changes, review or history — only its files, which Files reads @@ -2014,6 +2016,8 @@ export function LaneChangesPanel({ }); const dirty = draft !== null && draft !== text?.lines.join("\n"); + // Rendered only where the toggle is offered: a whole markdown file. + const mdShown = mdPreview && whole && isMarkdown(openFile); dirtyRef.current = dirty; useUnsavedGuard(dirty); const dirtyChangeRef = useRef(onDirtyChange); @@ -3027,7 +3031,7 @@ export function LaneChangesPanel({ range === "commit" && sha ? sha : "work" )} /> - ) : !mdPreview && + ) : !mdShown && (whole || editable) && (draft !== null || (editable && text === null)) ? ( // The editor, once both texts are in: not the diff first. @@ -3083,7 +3087,7 @@ export function LaneChangesPanel({ ) ) : whole ? ( - text && mdPreview ? ( + text && mdShown ? (
    diff --git a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx index 856210e9..caeff0aa 100644 --- a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx +++ b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx @@ -514,7 +514,7 @@ describe("LaneChangesPanel", () => { expect(screen.getByText("Unversioned Files")).toBeInTheDocument(); }); - it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => { + it("opens a whole markdown file rendered, as source after Raw, and rendered again after Preview", async () => { vi.mocked(api.lanes.gitTree).mockResolvedValue({ available: true, path: "", @@ -534,12 +534,13 @@ describe("LaneChangesPanel", () => { render(); const sidebar = await screen.findByTestId("lane-changes-sidebar"); await user.click(await within(sidebar).findByText("README.md")); - await screen.findByTestId("lane-file-editor"); - await user.click(screen.getByTestId("lane-file-preview")); - expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Hello" })).toBeInTheDocument(); expect(screen.queryByTestId("lane-file-editor")).toBeNull(); await user.click(screen.getByTestId("lane-file-preview")); expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one"); + await user.click(screen.getByTestId("lane-file-preview")); + expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); + expect(screen.queryByTestId("lane-file-editor")).toBeNull(); }); it("shows an image file as the picture, with no edit control", async () => { diff --git a/client/src/components/run/ChatFilePreview.tsx b/client/src/components/run/ChatFilePreview.tsx index e254df9d..b554b3d2 100644 --- a/client/src/components/run/ChatFilePreview.tsx +++ b/client/src/components/run/ChatFilePreview.tsx @@ -37,6 +37,7 @@ import { api, gitTarget } from "../../lib/api"; import { copyText } from "../../lib/clipboard"; import { useDialog } from "../../hooks/useDialog"; import { dragSkill } from "../../lib/promptAttach"; +import { readMarkdownPreview } from "../../lib/markdownView"; import { MarkdownPreview } from "../MarkdownPreview"; import { CodeBlock } from "../conversation/CodeBlock"; import { useTheme } from "../../hooks/useTheme"; @@ -227,6 +228,13 @@ function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string type View = "preview" | "code"; +/** A file's first tab: Preview where it renders — Markdown as Settings → Appearance says. */ +function defaultView(path: string): View { + const kind = artifactKind(path); + if (!RENDERED.has(kind)) return "code"; + return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview"; +} + function Body({ path, content, view }: { path: string; content: Content; view: View }) { const { t } = useTranslation("run"); const kind = artifactKind(path); @@ -320,7 +328,7 @@ export function FilePreview({ const { t } = useTranslation("run"); const kind = artifactKind(path); const loaded = useContent(path, scope); - const [view, setView] = useState(RENDERED.has(kind) ? "preview" : "code"); + const [view, setView] = useState(() => defaultView(path)); const [full, setFull] = useState(false); const [sharing, setSharing] = useState(false); const versions = useMemo( @@ -332,7 +340,7 @@ export function FilePreview({ // Another file opens on its own default tab, at its newest version. useEffect(() => { setPicked(null); - setView(RENDERED.has(artifactKind(path)) ? "preview" : "code"); + setView(defaultView(path)); }, [path]); const last = Math.max(versions.length - 1, 0); const at = picked ?? last; diff --git a/client/src/components/settings/AccountSettings.tsx b/client/src/components/settings/AccountSettings.tsx index 08f3178f..2b4e3ad3 100644 --- a/client/src/components/settings/AccountSettings.tsx +++ b/client/src/components/settings/AccountSettings.tsx @@ -3,10 +3,12 @@ * @description Settings → Account (server mode, any signed-in role): the * account's own information (`GET /api/me/account` — username, role, member * since, workspaces, sessions, active sign-ins, last sign-in), its avatar - * (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the - * sidebar shows it at once), its display name + * (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or + * an https link the server downloads, `POST /api/me/avatar/url`; the sidebar + * shows it at once), its display name * and email (`PATCH /api/me/account`), a password change that asks for the - * current one (`POST /api/me/password` — this browser stays signed in, every + * current one and passes every rule of `lib/passwordRules.ts`, ticked as it is + * typed (`POST /api/me/password` — this browser stays signed in, every * other sign-in ends) and "Sign out other devices" (`POST * /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an * admin's to change, on the Users page — never here. @@ -23,7 +25,7 @@ import { type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; -import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react"; +import { Camera, Link2, LogOut, RefreshCw, Trash2 } from "lucide-react"; import { api, type MyAccount } from "../../lib/api"; import { formatDateTime } from "../../lib/format"; import { toast } from "../../lib/toast"; @@ -32,6 +34,8 @@ import { isSubmitEnter } from "../../lib/keys"; import { useAuth } from "../AuthGate"; import { ROLE_BADGE } from "../users/UserEditDialog"; import { UserAvatar } from "../users/UserAvatar"; +import { PasswordRules } from "../users/PasswordRules"; +import { passwordProblems } from "../../lib/passwordRules"; import { SettingsSection } from "./SettingsRow"; type ServerAccount = Extract; @@ -110,18 +114,27 @@ function ProfileCard({ const shown = user.displayName || user.username; const fileRef = useRef(null); const [busy, setBusy] = useState(false); + // null = the link field is closed. + const [link, setLink] = useState(null); const run = async (op: () => Promise, done: string) => { setBusy(true); try { onSaved(await op()); toast.success(done); + return true; } catch (e) { toast.error(errText(t, e)); + return false; } finally { setBusy(false); } }; + const applyLink = async () => { + const url = link?.trim(); + if (!url) return; + if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null); + }; const pick = (e: ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; // the same file can be picked again @@ -150,6 +163,18 @@ function ProfileCard({ > + {user.avatarAt && ( + + )}
    {user.createdAt ? formatDateTime(user.createdAt) : "—"} @@ -283,13 +338,17 @@ function ProfileForm({ function PasswordForm() { const { t } = useTranslation("settings"); + const { state } = useAuth(); + const username = state.mode === "server" ? state.user?.username : undefined; const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [again, setAgain] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const mismatch = again.length > 0 && again !== next; - const ready = current.length > 0 && next.length > 0 && again === next && !busy; + // The account's own password: every rule (server/lib/auth.js decides too). + const strong = passwordProblems(next, username).length === 0; + const ready = current.length > 0 && strong && again === next && !busy; const submit = async () => { if (!ready) return; @@ -331,7 +390,7 @@ function PasswordForm() { aria-label={t("account.currentPassword")} /> - + +
    + +
    {(mismatch || error) && (

    {error ?? t("account.mismatch")} diff --git a/client/src/components/settings/AppearanceSettings.tsx b/client/src/components/settings/AppearanceSettings.tsx index 5a89fc6d..ddb4a8dd 100644 --- a/client/src/components/settings/AppearanceSettings.tsx +++ b/client/src/components/settings/AppearanceSettings.tsx @@ -7,7 +7,8 @@ * `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) — * the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the * session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`, - * `#workspace-tabs`); then the + * `#workspace-tabs`), and whether a Markdown file opens as Preview (the + * default) or Raw (`lib/markdownView.ts`, `#markdown-view`); then the * Dark / Light / System mode switch and one named-theme picker per mode, and the * corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet * pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in @@ -37,6 +38,7 @@ import { type TerminalFont, } from "../../lib/terminalFont"; import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs"; +import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView"; import { TERMINAL_FONT_SIZES, UI_FONT_SIZES, @@ -97,6 +99,7 @@ export function AppearanceSettings() { const [termSize, setTermSize] = useState(readTerminalFontSize); const [uiSize, setUiSize] = useState(readUiFontSize); const [allTabs, setAllTabs] = useState(readAllTabs); + const [mdPreview, setMdPreview] = useState(readMarkdownPreview); const pickTermFont = (font: TerminalFont) => { writeTerminalFont(font); setTermFont(font); @@ -224,6 +227,25 @@ export function AppearanceSettings() { setAllTabs(on); }} /> + { + writeMarkdownPreview(v === "preview"); + setMdPreview(v === "preview"); + }} + options={[ + { value: "preview" as const, label: t("markdownView.preview") }, + { value: "raw" as const, label: t("markdownView.raw") }, + ]} + /> + } + />

    diff --git a/client/src/components/settings/OmpSharingSettings.tsx b/client/src/components/settings/OmpSharingSettings.tsx new file mode 100644 index 00000000..e6362c92 --- /dev/null +++ b/client/src/components/settings/OmpSharingSettings.tsx @@ -0,0 +1,68 @@ +/** + * @file OmpSharingSettings — Settings → AI tools → Oh My Pi login sharing: the + * admin's server-wide switch that lets every member's omp pane run on the + * HOST account's omp login through omp's own auth broker + gateway, without + * any member holding a credential (`GET/PUT /api/ai-tools/omp-sharing`, + * `server/lib/omp-share.js`). Renders nothing outside server mode with + * per-user accounts (S3), or for anyone but an admin. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api, type OmpSharingState } from "../../lib/api"; +import { toast } from "../../lib/toast"; +import { usePermissions } from "../AuthGate"; +import { SettingsSection, SettingsSwitchRow } from "./SettingsRow"; + +export function OmpSharingSettings() { + const { t } = useTranslation("settings"); + const { isAdmin, local } = usePermissions(); + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (local || !isAdmin) return; + api.aiTools + .ompSharing() + .then(setState) + .catch(() => setState(null)); + }, [local, isAdmin]); + + if (local || !isAdmin || !state?.available) return null; + const error = state.broker.error || state.gateway.error; + const status = !state.enabled + ? null + : !state.installed + ? t("ompSharing.notInstalled") + : state.broker.running && state.gateway.running + ? t("ompSharing.running") + : error + ? t("ompSharing.failed", { error }) + : t("ompSharing.starting"); + return ( + + { + setBusy(true); + try { + setState(await api.aiTools.setOmpSharing(on)); + } catch (err) { + toast.error(err); + } finally { + setBusy(false); + } + }} + /> + {status && ( +

    + {status} +

    + )} +
    + ); +} diff --git a/client/src/components/settings/__tests__/AccountSettings.test.tsx b/client/src/components/settings/__tests__/AccountSettings.test.tsx index 6f2c0365..6451a6aa 100644 --- a/client/src/components/settings/__tests__/AccountSettings.test.tsx +++ b/client/src/components/settings/__tests__/AccountSettings.test.tsx @@ -2,8 +2,9 @@ * @file AccountSettings.test.tsx * @description Settings → Account: shows the account's own information, saves * only a changed display name/email, refuses a password change whose two new - * passwords differ, sends the current + new password, asks before signing - * the other devices out, and uploads/removes the avatar. + * passwords differ, keeps a password that misses a rule from being sent, + * sends the current + new password, asks before signing + * the other devices out, and uploads/removes the avatar or sets it from a link. * @author Nguyễn Ngọc Trí Vĩ */ @@ -17,6 +18,7 @@ const me = vi.hoisted(() => ({ changePassword: vi.fn(), signOutOthers: vi.fn(), setAvatar: vi.fn(), + setAvatarFromUrl: vi.fn(), removeAvatar: vi.fn(), avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null), })); @@ -76,6 +78,22 @@ describe("AccountSettings", () => { expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy(); }); + it("sets the avatar from a link and closes the field", async () => { + const ue = userEvent.setup(); + const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } }; + me.setAvatarFromUrl.mockResolvedValue(withAvatar); + render(); + await screen.findByTestId("account-card"); + expect(screen.queryByTestId("account-avatar-url-input")).toBeNull(); + await ue.click(screen.getByTestId("account-avatar-url-toggle")); + await ue.type(screen.getByTestId("account-avatar-url-input"), " https://x.vn/me.png {Enter}"); + await waitFor(() => expect(me.setAvatarFromUrl).toHaveBeenCalledWith("https://x.vn/me.png")); + expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe( + "/api/me/avatar?v=2026-10-21T00:00:00Z" + ); + expect(screen.queryByTestId("account-avatar-url-input")).toBeNull(); + }); + it("shows the account's own information", async () => { render(); const card = await screen.findByTestId("account-card"); @@ -114,9 +132,21 @@ describe("AccountSettings", () => { await ue.type(screen.getByLabelText("Current password"), "old-pass-1"); await ue.type(screen.getByLabelText("New password"), "new-pass-123"); await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123"); + // No upper-case letter: the checklist says so and the button stays off. + expect( + screen + .getByTestId("password-rules") + .querySelector('[data-rule="upper"]') + ?.getAttribute("data-ok") + ).toBe("false"); + expect(screen.getByRole("button", { name: "Change password" })).toBeDisabled(); + await ue.clear(screen.getByLabelText("New password")); + await ue.clear(screen.getByLabelText("Confirm new password")); + await ue.type(screen.getByLabelText("New password"), "New-pass-123"); + await ue.type(screen.getByLabelText("Confirm new password"), "New-pass-123"); await ue.click(screen.getByRole("button", { name: "Change password" })); await waitFor(() => - expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123") + expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "New-pass-123") ); }); diff --git a/client/src/components/settings/__tests__/OmpSharingSettings.test.tsx b/client/src/components/settings/__tests__/OmpSharingSettings.test.tsx new file mode 100644 index 00000000..843ce999 --- /dev/null +++ b/client/src/components/settings/__tests__/OmpSharingSettings.test.tsx @@ -0,0 +1,74 @@ +/** + * @file Tests for OmpSharingSettings — the admin's Oh My Pi login-sharing + * switch in Settings → AI tools: hidden in local mode, for a non-admin and + * outside S3, and a toggle saves through PUT /api/ai-tools/omp-sharing and + * shows the daemons' state. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "../../../i18n"; +import { OmpSharingSettings } from "../OmpSharingSettings"; +import { api, type OmpSharingState } from "../../../lib/api"; + +const perms = vi.hoisted(() => ({ isAdmin: true, local: false })); +vi.mock("../../AuthGate", () => ({ usePermissions: () => perms })); +vi.mock("../../../lib/api", () => ({ + api: { aiTools: { ompSharing: vi.fn(), setOmpSharing: vi.fn() } }, +})); + +const get = vi.mocked(api.aiTools.ompSharing); +const set = vi.mocked(api.aiTools.setOmpSharing); +const off: OmpSharingState = { + available: true, + enabled: false, + installed: true, + broker: { running: false, error: null }, + gateway: { running: false, error: null }, +}; + +describe("OmpSharingSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + perms.isAdmin = true; + perms.local = false; + }); + + it("lets an admin turn sharing on and shows it running", async () => { + get.mockResolvedValue(off); + set.mockResolvedValue({ + ...off, + enabled: true, + broker: { running: true }, + gateway: { running: true, port: 4827 }, + }); + render(); + const sw = await screen.findByRole("switch", { name: "Share this server's Oh My Pi login" }); + fireEvent.click(sw); + await waitFor(() => expect(set).toHaveBeenCalledWith(true)); + expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("Running"); + }); + + it("names a daemon's failure", async () => { + get.mockResolvedValue({ + ...off, + enabled: true, + gateway: { running: false, error: "port 4827 is taken" }, + }); + render(); + expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("port 4827 is taken"); + }); + + it("renders nothing outside S3, for a non-admin or in local mode", async () => { + get.mockResolvedValue({ ...off, available: false }); + const r = render(); + await waitFor(() => expect(get).toHaveBeenCalled()); + expect(r.container).toBeEmptyDOMElement(); + r.unmount(); + perms.isAdmin = false; + const r2 = render(); + expect(r2.container).toBeEmptyDOMElement(); + expect(get).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/users/PasswordRules.tsx b/client/src/components/users/PasswordRules.tsx new file mode 100644 index 00000000..40a636e1 --- /dev/null +++ b/client/src/components/users/PasswordRules.tsx @@ -0,0 +1,39 @@ +/** + * @file PasswordRules.tsx + * @description The checklist under a "new password" field the account picks + * for itself (Settings → Account, the first-login password change, an admin's + * own password on the Users page): each rule of `lib/passwordRules.ts`, ticked + * as the password typed passes it. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useTranslation } from "react-i18next"; +import { Check, Circle } from "lucide-react"; +import { PASSWORD_MIN_LENGTH, PASSWORD_RULES, passwordProblems } from "../../lib/passwordRules"; + +export function PasswordRules({ password, username }: { password: string; username?: string }) { + const { t } = useTranslation("users"); + const failed = new Set(passwordProblems(password, username)); + return ( +
      + {PASSWORD_RULES.map((rule) => { + const ok = password.length > 0 && !failed.has(rule); + return ( +
    • + {ok ? ( + + ) : ( + + )} + {t(`passwordRules.${rule}`, { min: PASSWORD_MIN_LENGTH })} +
    • + ); + })} +
    + ); +} diff --git a/client/src/components/users/UserEditDialog.tsx b/client/src/components/users/UserEditDialog.tsx index fbcb4771..df7e86a0 100644 --- a/client/src/components/users/UserEditDialog.tsx +++ b/client/src/components/users/UserEditDialog.tsx @@ -2,7 +2,9 @@ * @file UserEditDialog.tsx * @description The Users page's ONE account editor (admin only): profile * (display name, email), access (role, active/disabled — neither for your own - * account), a new password (typed or generated, copyable), the workspaces the + * account), a new password (typed or generated, copyable — someone else's + * may be anything and makes them choose their own at next sign-in; your own + * must pass every rule of `lib/passwordRules.ts`), the workspaces the * account owns (remove, add from every workspace), and "sign out everywhere". * Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only * what changed, confirmed with the admin's OWN password (`currentPassword`) — @@ -20,6 +22,8 @@ import type { Lane } from "../../lib/types"; import { fmtCost, formatDateTime } from "../../lib/format"; import { copyText } from "../../lib/clipboard"; import { isSubmitEnter } from "../../lib/keys"; +import { passwordProblems } from "../../lib/passwordRules"; +import { PasswordRules } from "./PasswordRules"; import { useDialog } from "../../hooks/useDialog"; import { Switch } from "../Switch"; import { Checkbox } from "../Checkbox"; @@ -34,11 +38,15 @@ export const ROLE_BADGE: Record = { }; /** A random password of `n` characters, no look-alikes (0/O, 1/l/I). */ -export function generatePassword(n = 16): string { +export function generatePassword(n = 16, username?: string): string { const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%"; const bytes = new Uint32Array(n); - crypto.getRandomValues(bytes); - return Array.from(bytes, (b) => chars[b % chars.length]).join(""); + // Drawn again until it passes every rule, so it also fits an own password. + for (;;) { + crypto.getRandomValues(bytes); + const out = Array.from(bytes, (b) => chars[b % chars.length]).join(""); + if (passwordProblems(out, username).length === 0) return out; + } } type OwnedWorkspace = { id: number; title: string; cwd: string }; @@ -110,8 +118,11 @@ export function UserEditDialog({ if (removed.size) patch.removeWorkspaces = [...removed]; if (signOut) patch.signOut = true; const changed = Object.keys(patch).length > 0; - // No minimum length (server/lib/auth.js) — a blank field keeps the password. - const ready = changed && current.length > 0 && !busy; + // A blank field keeps the password. Someone else's may be anything (they + // choose their own at next sign-in); your own must pass every rule. + const weakOwn = + self && password.length > 0 && passwordProblems(password, user.username).length > 0; + const ready = changed && current.length > 0 && !weakOwn && !busy; const submit = async () => { if (!ready) return; @@ -309,7 +320,10 @@ export function UserEditDialog({
    - +
    { - setPassword(generatePassword()); + setPassword(generatePassword(16, user.username)); setShowPassword(true); }} > @@ -351,6 +365,11 @@ export function UserEditDialog({
    + {self && password.length > 0 && ( +
    + +
    + )}
    diff --git a/client/src/i18n/locales/en/artifacts.json b/client/src/i18n/locales/en/artifacts.json index 10072fc5..d7ca5ff8 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -117,6 +117,20 @@ "edit.description": "Description", "previewFailed": "Could not load this file.", "truncated": "Showing the first 1 MB — download the file to see all of it.", + "section.hub.own": "Shared by me", + "section.hub.ownHint": "Yours, visible to every user", + "section.hub.others": "Shared by others", + "section.hub.othersHint": "What other users shared — install what you need", + "section.mine.own": "Mine", + "section.mine.ownHint": "What you added", + "section.mine.others": "Installed from others", + "section.mine.othersHint": "Shared by other users, installed in your account", + "section.all.others": "Shared by others", + "section.all.othersHint": "What other users shared", + "section.all.own": "Mine", + "section.all.ownHint": "What you added, shared or not", + "pages": "Pages", + "section.empty": "Nothing here yet.", "tab.all": "All", "installedBadge": "Installed", "installsTitle": "Installs", @@ -126,12 +140,7 @@ "installsPluginWhere": "claude plugin install --scope user", "installsUnrecorded": "{{count}} earlier installs were counted before details were kept", "installsNoneOfYours": "You have not installed it — only your own installs are listed.", - "reinstall": "Reinstall", - "reinstallHint": "Write this version over your installed copy", - "reinstallTitle": "Reinstall “{{title}}”?", - "reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.", "uninstall": "Uninstall", - "uninstallHint": "Remove it from your account", "uninstallTitle": "Uninstall “{{title}}”?", "uninstallSkillBody": "~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are moved to ~/.claude/agenthub-replaced-skills/, not deleted. Sessions already open keep it until they reload.", "uninstallPluginBody": "Runs claude plugin uninstall {{name}} for your account; the plugin's data folder is kept. Open sessions keep it until /reload-plugins.", @@ -145,7 +154,7 @@ "hubRemoveTitle": "Remove “{{title}}” from the Hub?", "hubRemoveBody": "It is no longer in your library, so it is deleted for good. Anything already installed from it stays installed.", "edit.otherFiles": "Its other {{count}} file(s) are kept as they are.", - "edit.reinstallNote": "Your installed copy keeps the old version until you Reinstall it.", + "edit.reinstallNote": "Your installed copy keeps the old version until you uninstall it and install it again.", "preview.instructions": "Instructions", "preview.frontmatter": "Frontmatter (SKILL.md header)", "preview.whenUsed": "When Claude uses it", diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json index 0b3a3c20..a10eb32b 100644 --- a/client/src/i18n/locales/en/common.json +++ b/client/src/i18n/locales/en/common.json @@ -198,7 +198,15 @@ "tooMany": "Too many failed attempts — try again in a few minutes", "logout": "Sign out", "noAccount": "No account yet? Ask your administrator to create one.", - "signedInAs": "Signed in as {{name}} ({{role}})" + "signedInAs": "Signed in as {{name}} ({{role}})", + "mustChangeTitle": "Choose your own password", + "mustChangeSubtitle": "Your administrator gave you a starting password. Choose your own before you continue.", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "mismatch": "The two new passwords differ", + "mustChangeSubmit": "Save and continue", + "saving": "Saving…" }, "insights": { "help": { diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 2c245a71..70765a87 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -1140,6 +1140,21 @@ "vi": "Tiếng Việt" } }, + "ompSharing": { + "title": "Oh My Pi login sharing", + "switch": "Share this server's Oh My Pi login", + "hint": "Every user's omp sessions run on the logins the host account made with `omp login`, through omp's own auth broker and gateway on this machine. A session gets only a key that works while its pane runs, never the login itself. Only omp's built-in providers are shared.", + "running": "Running: broker and gateway are up.", + "starting": "Starting…", + "notInstalled": "omp is not installed on this server.", + "failed": "Not running: {{error}}" + }, + "markdownView": { + "label": "Open Markdown as", + "description": "How a .md file opens in Files, Changes, the config viewer and a chat's file panel. Its Preview/Raw button still switches that one file. Saved to your account.", + "preview": "Preview", + "raw": "Raw" + }, "workspaceTabs": { "title": "Workspace tabs", "description": "Which sessions the session view's tab bar holds. Saved to your account.", @@ -1339,7 +1354,10 @@ "remove": "Remove picture", "saved": "Picture updated", "removed": "Picture removed", - "tooBig": "The picture must be at most 1 MB" + "tooBig": "The picture must be at most 1 MB", + "fromUrl": "Use a picture from a link", + "urlPlaceholder": "https://… image link", + "urlApply": "Use" }, "loadFailed": "Could not load your account: {{message}}", "memberSince": "Member since", diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index 3937d624..5a9a3302 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -30,7 +30,7 @@ "username": "Username", "usernameHint": "Letters, digits, '.', '_' or '-'", "password": "Password", - "passwordHint": "No minimum length", + "passwordHint": "Any starting password — they choose their own at first sign-in", "newPassword": "New password", "changeRole": "Change role", "disable": "Disable", @@ -68,6 +68,9 @@ "errors": { "EBADIMAGE": "Only a PNG, JPEG, GIF or WebP picture", "ETOOBIG": "The picture must be at most 1 MB", + "EBADURL": "Enter an https:// link to a picture.", + "EPRIVATEURL": "That link points to a private or local address.", + "EFETCH": "Could not download the picture from that link.", "EREAUTH": "Your password is wrong.", "ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.", "ESELF": "You cannot change your own role or disable yourself.", @@ -81,7 +84,9 @@ "EOWNERFOLDER": "That workspace's folder belongs to another user's account.", "EBADROLE": "Unknown role.", "ELOCALMODE": "Accounts are managed in server mode only.", - "ELASTADMIN": "The last admin cannot be deleted." + "ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.", + "EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).", + "EPWCHANGE": "Choose your own password first." }, "readOnly": "Analysts can view accounts but not change them.", "auditTitle": "Audit log", @@ -107,7 +112,7 @@ "selfRole": "You cannot change your own role.", "selfDisable": "You cannot disable yourself.", "password": "Password", - "passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere.", + "passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere, and they choose their own at their next sign-in.", "show": "Show password", "hide": "Hide password", "copy": "Copy password", @@ -122,7 +127,8 @@ "sessions": "Sign-ins", "signOut": "Sign out everywhere", "signOutHint": "Ends this user's {{n}} active sign-in(s); they sign in again with their password.", - "noChanges": "No changes yet." + "noChanges": "No changes yet.", + "ownPasswordHint": "Leave blank to keep the current one. Your other sign-ins end." }, "pool": { "title": "Account pool", @@ -253,5 +259,15 @@ "openHome": "Open home in Files", "copyUsername": "Copy username", "copyEmail": "Copy email" + }, + "mustChange": "New password due", + "mustChangeHint": "Signed in with a password an admin gave; must choose their own before using AgentHub", + "passwordRules": { + "length": "At least {{min}} characters", + "lower": "A lower-case letter", + "upper": "An upper-case letter", + "digit": "A digit", + "symbol": "A symbol (e.g. ! - _ @)", + "username": "Does not contain the username" } } diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index 6183cc45..db4e89e0 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -117,6 +117,20 @@ "edit.description": "Mô tả", "previewFailed": "Không tải được file này.", "truncated": "Chỉ hiển thị 1 MB đầu — tải file về để xem toàn bộ.", + "section.hub.own": "Tôi đã share", + "section.hub.ownHint": "Của bạn, mọi người dùng đều thấy", + "section.hub.others": "Người khác share", + "section.hub.othersHint": "Những gì người dùng khác đã share — cài cái bạn cần", + "section.mine.own": "Của tôi", + "section.mine.ownHint": "Những gì bạn đã thêm", + "section.mine.others": "Đã cài từ người khác", + "section.mine.othersHint": "Người dùng khác share, đã cài vào tài khoản của bạn", + "section.all.others": "Người khác share", + "section.all.othersHint": "Những gì người dùng khác đã share", + "section.all.own": "Của tôi", + "section.all.ownHint": "Những gì bạn đã thêm, đã share hoặc chưa", + "pages": "Trang", + "section.empty": "Chưa có gì.", "tab.all": "Tất cả", "installedBadge": "Đã cài", "installsTitle": "Lượt cài", @@ -126,12 +140,7 @@ "installsPluginWhere": "claude plugin install --scope user", "installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết", "installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.", - "reinstall": "Cài lại", - "reinstallHint": "Ghi bản này đè lên bản bạn đã cài", - "reinstallTitle": "Cài lại “{{title}}”?", - "reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.", "uninstall": "Gỡ cài đặt", - "uninstallHint": "Gỡ khỏi tài khoản của bạn", "uninstallTitle": "Gỡ cài đặt “{{title}}”?", "uninstallSkillBody": "~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá. Các phiên đang mở vẫn giữ nó cho tới khi tải lại.", "uninstallPluginBody": "Chạy claude plugin uninstall {{name}} cho tài khoản của bạn; thư mục dữ liệu của plugin được giữ lại. Các phiên đang mở vẫn giữ nó cho tới khi /reload-plugins.", @@ -145,7 +154,7 @@ "hubRemoveTitle": "Gỡ “{{title}}” khỏi Hub?", "hubRemoveBody": "Mục này không còn trong thư viện của bạn nên sẽ bị xoá hẳn. Những gì đã cài từ nó vẫn được giữ.", "edit.otherFiles": "{{count}} file khác của skill được giữ nguyên.", - "edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bấm Cài lại.", + "edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bạn gỡ cài đặt rồi cài lại.", "preview.instructions": "Hướng dẫn", "preview.frontmatter": "Frontmatter (phần đầu SKILL.md)", "preview.whenUsed": "Khi nào Claude dùng", diff --git a/client/src/i18n/locales/vi/common.json b/client/src/i18n/locales/vi/common.json index 8cb46494..fd1f9740 100644 --- a/client/src/i18n/locales/vi/common.json +++ b/client/src/i18n/locales/vi/common.json @@ -198,7 +198,15 @@ "tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút", "logout": "Đăng xuất", "noAccount": "Chưa có tài khoản? Hãy nhờ quản trị viên tạo cho bạn.", - "signedInAs": "Đang đăng nhập: {{name}} ({{role}})" + "signedInAs": "Đang đăng nhập: {{name}} ({{role}})", + "mustChangeTitle": "Đặt mật khẩu của bạn", + "mustChangeSubtitle": "Quản trị viên đã cấp cho bạn mật khẩu ban đầu. Hãy đặt mật khẩu riêng trước khi tiếp tục.", + "currentPassword": "Mật khẩu hiện tại", + "newPassword": "Mật khẩu mới", + "confirmPassword": "Nhập lại mật khẩu mới", + "mismatch": "Hai mật khẩu mới không khớp", + "mustChangeSubmit": "Lưu và tiếp tục", + "saving": "Đang lưu…" }, "insights": { "help": { diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index ddaf7544..01a6eec3 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -1140,6 +1140,21 @@ "vi": "Tiếng Việt" } }, + "ompSharing": { + "title": "Dùng chung đăng nhập Oh My Pi", + "switch": "Dùng chung đăng nhập Oh My Pi của máy chủ", + "hint": "Mọi session omp của người dùng chạy bằng các đăng nhập mà tài khoản máy chủ đã tạo bằng `omp login`, qua auth broker và gateway của chính omp trên máy này. Session chỉ nhận một key dùng được khi pane còn chạy, không bao giờ nhận chính đăng nhập. Chỉ các provider có sẵn của omp được dùng chung.", + "running": "Đang chạy: broker và gateway đã sẵn sàng.", + "starting": "Đang khởi động…", + "notInstalled": "Máy chủ chưa cài omp.", + "failed": "Không chạy: {{error}}" + }, + "markdownView": { + "label": "Mở file Markdown dạng", + "description": "Cách file .md mở trong Files, Changes, trình xem cấu hình và bảng file của chat. Nút Xem trước/Mã nguồn vẫn đổi riêng file đó. Lưu theo tài khoản.", + "preview": "Xem trước", + "raw": "Mã nguồn" + }, "workspaceTabs": { "title": "Tab của workspace", "description": "Thanh tab của màn hình session chứa những session nào. Lưu theo tài khoản.", @@ -1339,7 +1354,10 @@ "remove": "Xoá ảnh đại diện", "saved": "Đã cập nhật ảnh đại diện", "removed": "Đã xoá ảnh đại diện", - "tooBig": "Ảnh tối đa 1 MB" + "tooBig": "Ảnh tối đa 1 MB", + "fromUrl": "Dùng ảnh từ đường dẫn", + "urlPlaceholder": "https://… đường dẫn ảnh", + "urlApply": "Dùng" }, "loadFailed": "Không tải được tài khoản: {{message}}", "memberSince": "Tham gia từ", diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index 9aecb640..e1d164e6 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -30,7 +30,7 @@ "username": "Tên đăng nhập", "usernameHint": "Chữ, số, '.', '_' hoặc '-'", "password": "Mật khẩu", - "passwordHint": "Không giới hạn độ dài tối thiểu", + "passwordHint": "Mật khẩu ban đầu tuỳ ý — người dùng sẽ tự đặt mật khẩu riêng ở lần đăng nhập đầu", "newPassword": "Mật khẩu mới", "changeRole": "Đổi vai trò", "disable": "Khoá", @@ -68,6 +68,9 @@ "errors": { "EBADIMAGE": "Chỉ nhận ảnh PNG, JPEG, GIF hoặc WebP", "ETOOBIG": "Ảnh tối đa 1 MB", + "EBADURL": "Hãy nhập đường dẫn https:// tới một ảnh.", + "EPRIVATEURL": "Đường dẫn trỏ tới địa chỉ nội bộ hoặc cục bộ.", + "EFETCH": "Không tải được ảnh từ đường dẫn này.", "EREAUTH": "Mật khẩu của bạn không đúng.", "ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.", "ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.", @@ -81,7 +84,9 @@ "EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.", "EBADROLE": "Vai trò không hợp lệ.", "ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.", - "ELASTADMIN": "Không thể xoá admin cuối cùng." + "ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.", + "EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).", + "EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước." }, "readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.", "auditTitle": "Nhật ký audit", @@ -107,7 +112,7 @@ "selfRole": "Bạn không thể đổi vai trò của chính mình.", "selfDisable": "Bạn không thể tự vô hiệu hoá mình.", "password": "Mật khẩu", - "passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi.", + "passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi và họ phải tự đặt mật khẩu riêng ở lần đăng nhập sau.", "show": "Hiện mật khẩu", "hide": "Ẩn mật khẩu", "copy": "Sao chép mật khẩu", @@ -122,7 +127,8 @@ "sessions": "Phiên đăng nhập", "signOut": "Đăng xuất ở mọi nơi", "signOutHint": "Kết thúc {{n}} phiên đăng nhập đang hoạt động của người này; họ đăng nhập lại bằng mật khẩu.", - "noChanges": "Chưa có thay đổi." + "noChanges": "Chưa có thay đổi.", + "ownPasswordHint": "Để trống để giữ mật khẩu hiện tại. Các phiên đăng nhập khác của bạn sẽ kết thúc." }, "pool": { "title": "Pool tài khoản", @@ -253,5 +259,15 @@ "openHome": "Mở home trong Files", "copyUsername": "Sao chép username", "copyEmail": "Sao chép email" + }, + "mustChange": "Chờ đổi mật khẩu", + "mustChangeHint": "Đang dùng mật khẩu do admin cấp; phải tự đặt mật khẩu riêng trước khi dùng AgentHub", + "passwordRules": { + "length": "Ít nhất {{min}} ký tự", + "lower": "Có chữ thường", + "upper": "Có chữ hoa", + "digit": "Có chữ số", + "symbol": "Có ký tự đặc biệt (vd. ! - _ @)", + "username": "Không chứa tên đăng nhập" } } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 257679b7..9dd4fd0d 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -733,6 +733,8 @@ export type AuthUser = { /** When the account's avatar was last set (null = none) — versions its URL. */ avatarAt?: string | null; createdAt?: string; + /** An admin gave this password: the user must choose their own before anything else. */ + mustChangePassword?: boolean; }; export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null }; /** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */ @@ -1007,6 +1009,9 @@ export const api = { body: file, headers: { "Content-Type": "application/octet-stream" }, }), + /** POST /api/me/avatar/url — the server downloads the picture from an https link. */ + setAvatarFromUrl: (url: string) => + request("/me/avatar/url", { method: "POST", body: JSON.stringify({ url }) }), /** DELETE /api/me/avatar — back to the initials. */ removeAvatar: () => request("/me/avatar", { method: "DELETE" }), }, @@ -3051,6 +3056,14 @@ export const api = { aiTools: { /** GET /api/ai-tools - the catalog, what is installed, enabled, and the default. */ list: () => request("/ai-tools"), + /** GET /api/ai-tools/omp-sharing - the host's Oh My Pi login shared with members (S3). */ + ompSharing: () => request("/ai-tools/omp-sharing"), + /** PUT /api/ai-tools/omp-sharing - an admin's switch. */ + setOmpSharing: (enabled: boolean) => + request("/ai-tools/omp-sharing", { + method: "PUT", + body: JSON.stringify({ enabled }), + }), /** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */ refresh: () => request("/ai-tools/refresh", { method: "POST" }), /** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */ @@ -4307,6 +4320,16 @@ export interface ArtifactCandidate { pinned: boolean; } +/** `GET /api/ai-tools/omp-sharing` — never a token. */ +export interface OmpSharingState { + /** Server mode with per-user Linux accounts (S3): the only place it applies. */ + available: boolean; + enabled: boolean; + installed: boolean; + broker: { running: boolean; error?: string | null }; + gateway: { running: boolean; port?: number; error?: string | null }; +} + export interface ArtifactSettings { serverMode: boolean; canShare: boolean; diff --git a/client/src/lib/markdownView.ts b/client/src/lib/markdownView.ts new file mode 100644 index 00000000..0665c155 --- /dev/null +++ b/client/src/lib/markdownView.ts @@ -0,0 +1,29 @@ +/** + * @file markdownView — Settings → Appearance → "Open Markdown as": a viewer + * preference in `localStorage` (`agenthub.markdown.view`, synced to the + * account through `lib/userPrefs.ts`). Preview (the default) opens a `.md` + * file rendered; Raw opens its source. Read each time a viewer opens a file + * (the Files/Changes panel, the config file viewer, a chat artifact); the + * viewer's own Preview/Raw toggle still flips that one file. + * @author Nguyễn Ngọc Trí Vĩ + */ + +export const MARKDOWN_VIEW_KEY = "agenthub.markdown.view"; + +/** True when a Markdown file opens rendered (the default). */ +export function readMarkdownPreview(): boolean { + try { + return window.localStorage.getItem(MARKDOWN_VIEW_KEY) !== "raw"; + } catch { + return true; + } +} + +export function writeMarkdownPreview(on: boolean) { + try { + if (on) window.localStorage.removeItem(MARKDOWN_VIEW_KEY); + else window.localStorage.setItem(MARKDOWN_VIEW_KEY, "raw"); + } catch { + /* storage blocked — the default */ + } +} diff --git a/client/src/lib/passwordRules.ts b/client/src/lib/passwordRules.ts new file mode 100644 index 00000000..b454ac3a --- /dev/null +++ b/client/src/lib/passwordRules.ts @@ -0,0 +1,26 @@ +/** + * @file passwordRules.ts + * @description The rules a password an account picks for ITSELF must pass — + * a copy of `passwordProblems` in server/lib/auth.js, so the checklist can + * tick each one as it is typed. The server's copy is the one that decides + * (EWEAKPASSWORD); a password an admin gives someone else has no rules. + * @author Nguyễn Ngọc Trí Vĩ + */ + +export const PASSWORD_MIN_LENGTH = 8; + +export const PASSWORD_RULES = ["length", "lower", "upper", "digit", "symbol", "username"] as const; +export type PasswordRule = (typeof PASSWORD_RULES)[number]; + +/** The rules `password` fails (empty = it passes every one). */ +export function passwordProblems(password: string, username?: string): PasswordRule[] { + const out: PasswordRule[] = []; + if ([...password].length < PASSWORD_MIN_LENGTH) out.push("length"); + if (!/\p{Ll}/u.test(password)) out.push("lower"); + if (!/\p{Lu}/u.test(password)) out.push("upper"); + if (!/\p{N}/u.test(password)) out.push("digit"); + if (!/[^\p{L}\p{N}]/u.test(password)) out.push("symbol"); + const name = (username || "").toLowerCase(); + if (name && password.toLowerCase().includes(name)) out.push("username"); + return out; +} diff --git a/client/src/lib/userPrefs.ts b/client/src/lib/userPrefs.ts index f9545b41..2567d60d 100644 --- a/client/src/lib/userPrefs.ts +++ b/client/src/lib/userPrefs.ts @@ -43,6 +43,7 @@ export const PREF_KEYS = [ "agenthub.codex.bypass", "agenthub.tour", "agenthub.workspace.allTabs", + "agenthub.markdown.view", ] as const; const SYNCED = new Set(PREF_KEYS); const PUSH_DELAY_MS = 400; diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index afe2d07a..90f0dff6 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -1,22 +1,27 @@ /** * @file Artifacts.tsx * @description `/artifacts` — the Artifacts hub (`/api/artifacts`, - * server/lib/artifacts.js), laid out like an app store: a hero with the - * search, category chips, the unfiltered front page shelved by kind (Skills, - * Plugins, Pages, Files — "See all" picks the category), each artifact an - * app-icon card with one primary pill (Install / Installed / Open / Preview / - * Download) and a detail sheet a click on the card opens. It holds claude.ai pages sessions published, pinned + * server/lib/artifacts.js), in the same shapes as the other pages (a plain + * title row, Sessions' filter bar, `.card` tiles, no decorative icons): a + * search and category filter, the unfiltered front page shelved by kind + * (Skills, Plugins, Pages, Files — "See all" picks the category), each + * artifact a card with one primary action (Install / Installed / Open / + * Preview / Download), text actions in its footer, the rest in its ⋯ menu, + * and a detail sheet a click on the card opens. It holds claude.ai pages sessions published, pinned * workspace files (or one uploaded from the computer), skills (picked, * uploaded as a file/folder/zip, or written — `ArtifactAddPanels.tsx`) and * plugins (read from a marketplace link, or typed). One list holds the * caller's own rows and, in server mode, what every user shared; a small - * **All · My library · Hub** filter narrows it. A shared skill or plugin is + * **All · My library · Hub** filter narrows it. In server mode every tab is + * split by who it is from, others' first (All: others' shared rows / yours; + * Hub: shared by others / by you; My library: installed from others / your + * own), and every section pages through 12 cards at a time. A shared skill or plugin is * installed into the viewer's own account (after a confirmation that lists * what will be written or run); one already installed there shows - * **Installed** instead of the Install button, with **Uninstall** beside it - * (a skill's copies moved aside, a plugin `claude plugin uninstall`; a skill - * also keeps **Reinstall**, - * which replaces the user's copy after a confirmation). Editing a skill + * **Installed** instead of the Install button; **Uninstall** (a skill's + * copies moved aside, a plugin `claude plugin uninstall`) is offered only in + * the card's ⋯ menu. There is no Reinstall: to take a newer version, uninstall + * and install again. Editing a skill * edits its SKILL.md (name, description, instructions — a new snapshot); * its preview opens with what it installs as and when Claude uses it. Deleting a shared * row of your own only takes it out of your library — it stays in the Hub @@ -36,27 +41,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Check, - ChevronRight, - Copy, - Download, - ExternalLink, - Eye, - FileText, - Globe, - MoreHorizontal, - Package, - PackageMinus, - Pencil, - Plus, - RotateCcw, - Search, - Share2, - Sparkles, - Trash2, - X, -} from "lucide-react"; +import { MoreHorizontal, Search, X } from "lucide-react"; import { api, type Artifact, @@ -82,17 +67,8 @@ import { } from "../components/artifacts/ArtifactAddPanels"; import { MarkdownPreview, isMarkdown } from "../components/MarkdownPreview"; -const KIND_ICON = { page: Globe, file: FileText, skill: Sparkles, plugin: Package } as const; // The storefront's shelf order: what installs first, what is read after. const SHELVES: ArtifactKind[] = ["skill", "plugin", "page", "file"]; -// Each kind's app-icon tile. White glyphs sit on a fixed gradient, so they -// read the same in every theme. -const KIND_TILE: Record = { - skill: "from-violet-500 to-fuchsia-600", - plugin: "from-emerald-500 to-teal-600", - page: "from-sky-500 to-blue-600", - file: "from-amber-500 to-orange-600", -}; function fmtBytes(n: number | null): string { if (!n) return ""; @@ -124,31 +100,16 @@ function capabilities(a: Artifact) { }; } -/** The rounded "app icon" every artifact wears: its kind's glyph on its colour. */ -function AppIcon({ kind, size = "md" }: { kind: ArtifactKind; size?: "sm" | "md" | "lg" }) { - const Icon = KIND_ICON[kind]; - const box = { - sm: "h-6 w-6 rounded-lg", - md: "h-12 w-12 rounded-2xl", - lg: "h-20 w-20 rounded-[1.6rem]", - }[size]; - const glyph = { sm: "h-3.5 w-3.5", md: "h-6 w-6", lg: "h-10 w-10" }[size]; - return ( - - - - ); -} - -const pill = - "inline-flex shrink-0 items-center gap-1 rounded-full px-3.5 py-1 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50"; -const pillPrimary = `${pill} bg-accent-solid text-white hover:bg-accent-solid-hover`; -const pillSoft = `${pill} bg-surface-3 text-fg-primary hover:bg-surface-4`; -const iconBtn = - "flex h-7 w-7 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"; +// The page's controls, in the shapes the other pages use (Sessions' filter bar). +const segment = (on: boolean) => + `rounded-md px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${ + on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary" + }`; +const smallPrimary = "btn-primary shrink-0 px-3 py-1 text-xs"; +const smallSecondary = "btn-secondary shrink-0 px-2.5 py-1 text-xs"; +// A text action in a card's footer. +const footAction = + "rounded-md px-2 py-1 text-xs font-medium text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-50"; export function Artifacts() { const { t } = useTranslation("artifacts"); @@ -298,27 +259,6 @@ export function Artifacts() { load(); }; - // An installed skill is written again over the user's copy; the server - // moves the old one to ~/.claude/agenthub-replaced-skills/ first. - const reinstall = async (a: Artifact) => { - if ( - !(await confirmDialog({ - title: t("reinstallTitle", { title: a.title }), - message: t("reinstallBody", { name: a.source }), - confirmLabel: t("reinstall"), - destructive: false, - })) - ) - return; - try { - const r = await api.artifacts.install(a.id, true); - toast.success(t("installed", { path: r.installed })); - } catch (err) { - toast.error(err); - } - load(); - }; - // Taken out of the user's own accounts again; a skill's copies are moved to // ~/.claude/agenthub-replaced-skills/, never deleted. const uninstall = async (a: Artifact) => { @@ -350,7 +290,6 @@ export function Artifacts() { }, onDelete: () => void remove(a), onInstall: () => void install(a), - onReinstall: () => void reinstall(a), onUninstall: () => void uninstall(a), onPreview: (path) => { setDetailId(null); @@ -368,7 +307,7 @@ export function Artifacts() { /> ); - // The storefront's front page — everything, unsearched — is shelved by + // Local mode (everything is yours): the unfiltered page is shelved by // kind; a category or a search is one flat grid of results. const shelves = items && !kind && !q.trim() @@ -376,128 +315,102 @@ export function Artifacts() { (s) => s.rows.length > 0 ) : null; + // In server mode every tab is split by who it is from, others' first: All + // into others' shared rows and yours, the Hub into what others shared and + // what you shared, My library into what you installed from others (the + // server adds those to `mine`) and your own. + const halves = + server && items + ? (["others", "own"] as const).map((who) => ({ + key: `${tab}.${who}`, + rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)), + })) + : null; const detail = detailId === null ? null : (items?.find((a) => a.id === detailId) ?? null); return ( -
    -
    -
    -
    -
    -
    - - {SHELVES.map((k) => ( - - ))} - -
    -

    {t("title")}

    -

    - {t(server ? "subtitleServer" : "subtitle")} -

    -
    -
    -
    - - {addMenu && ( -
    - {SHELVES.map((k) => ( - - ))} -
    - )} -
    +
    +
    +
    +

    {t("title")}

    +

    + {t(server ? "subtitleServer" : "subtitle")} +

    -
    + +
    +
    + setQ(e.target.value)} placeholder={t("search")} aria-label={t("search")} - className="w-full rounded-full border border-border bg-surface-1 py-2 pl-10 pr-9 text-sm text-fg-primary shadow-sm outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/30" + className="input w-full pl-10 pr-9" /> {q && ( )} - -
    - -
    +
    - {(["", ...SHELVES] as const).map((k) => { - const on = kind === k; - const Icon = k ? KIND_ICON[k] : null; - return ( - - ); - })} + {(["", ...SHELVES] as const).map((k) => ( + + ))}
    {server && (
    {(["all", "mine", "hub"] as const).map((v) => ( @@ -506,11 +419,7 @@ export function Artifacts() { type="button" aria-pressed={tab === v} onClick={() => setTab(v)} - className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${ - tab === v - ? "bg-surface-1 text-fg-primary shadow-sm" - : "text-fg-secondary hover:text-fg-primary" - }`} + className={segment(tab === v)} > {t(`tab.${v}`)} @@ -527,40 +436,55 @@ export function Artifacts() { searching={!!q.trim() || !!kind} onAdd={(k) => setAdding(k)} /> + ) : halves ? ( +
    + {halves.map((h) => ( +
    +
    +

    + {t(`section.${h.key}`)} + {h.rows.length} +

    +

    {t(`section.${h.key}Hint`)}

    +
    + {h.rows.length ? ( + + ) : ( +

    + {t("section.empty")} +

    + )} +
    + ))} +
    ) : shelves ? ( -
    +
    {shelves.map((s) => (
    -
    +
    -

    +

    {t(`kind.${s.kind}`)} - - {s.rows.length} - + {s.rows.length}

    {t(`blurb.${s.kind}`)}

    -
      {s.rows.map(card)}
    +
    ))}
    ) : (

    {t("results", { count: items.length })}

    -
      {items.map(card)}
    +
    )} @@ -589,25 +513,70 @@ export function Artifacts() { ); } +// Cards per page of a section: four rows of the three-column grid. +const PAGE_SIZE = 12; + +/** A section's cards, a page at a time once there are more than `PAGE_SIZE` + * (the Sessions table's Previous · n / m · Next). Keyed by the caller on the + * filters, so a new tab, kind or search starts again at page one. */ +function PagedGrid({ rows, card }: { rows: Artifact[]; card: (a: Artifact) => React.ReactNode }) { + const { t } = useTranslation("artifacts"); + const [page, setPage] = useState(0); + const pages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + const at = Math.min(page, pages - 1); + const pager = + "rounded-md bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"; + return ( + <> +
      + {rows.slice(at * PAGE_SIZE, (at + 1) * PAGE_SIZE).map(card)} +
    + {pages > 1 && ( + + )} + + ); +} + function SkeletonGrid({ label }: { label: string }) { return (
    {label}
      {Array.from({ length: 6 }, (_, i) => ( -
    • -
      - - - - - -
      +
    • + + -
    • ))}
    @@ -626,14 +595,11 @@ function EmptyState({ }) { const { t } = useTranslation("artifacts"); return ( -
    - - {searching ? : } - -

    +

    +

    {t(searching ? "empty.searchTitle" : hub ? "empty.hubTitle" : "empty.title")}

    -

    +

    {searching ? t("empty.search") : t(hub ? "empty.hub" : "empty.mine")}

    {!hub && !searching && ( @@ -643,13 +609,10 @@ function EmptyState({ key={k} type="button" onClick={() => onAdd(k)} - className="flex items-center gap-3 rounded-2xl border border-border bg-surface-1 p-3 text-left transition hover:-translate-y-0.5 hover:shadow-md" + className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-left transition-colors hover:bg-surface-4" > - - - {t(`add.${k}`)} - {t(`blurb.${k}`)} - + {t(`add.${k}`)} + {t(`blurb.${k}`)} ))}
    @@ -664,12 +627,11 @@ type CardActions = { onRename: () => void; onDelete: () => void; onInstall: () => void; - onReinstall: () => void; onUninstall: () => void; onPreview: (path: string) => void; }; -/** The one primary pill on a card / detail sheet, App Store "Get" style. */ +/** The one primary action on a card / detail sheet. */ function PrimaryAction({ item: a, canInstall, @@ -686,10 +648,9 @@ function PrimaryAction({ if (a.installed) return ( - {t("installedBadge")} ); @@ -697,7 +658,7 @@ function PrimaryAction({ return ( ); if (c.canDownload) return ( - + {t("download")} ); @@ -736,7 +696,6 @@ function ArtifactCard({ onRename, onDelete, onInstall, - onReinstall, onUninstall, onPreview, }: CardActions & { @@ -751,11 +710,11 @@ function ArtifactCard({ fn(); }; const { previewPath, canPreview, canDownload, installable, hubOnly } = capabilities(a); - // The primary pill already offers Preview/Download for a plain file. + // The primary action already offers Preview/Download for a plain file. const primaryIsPreview = !installable && a.kind !== "page" && canPreview; return (
  • menu.open(e, null)} > @@ -764,26 +723,21 @@ function ArtifactCard({ type="button" onClick={onOpen} title={t("details")} - className="flex min-w-0 flex-1 items-start gap-3 rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" + className="min-w-0 flex-1 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" > - - - - - {a.title} - - - - {t(`kindOne.${a.kind}`)} - {a.source ? ` · ${a.source}` : ""} - {a.size ? ` · ${fmtBytes(a.size)}` : ""} - + + {a.title} + + + {t(`kindOne.${a.kind}`)} + {a.source ? ` · ${a.source}` : ""} + {a.size ? ` · ${fmtBytes(a.size)}` : ""}
  • - {(a.description || a.shared) && ( -
    - {a.shared && ( - - - {t(hubOnly ? "hubOnly" : "shared")} - - )} - {a.description && ( -

    - {a.description} -

    - )} -
    + {a.shared && ( + + {t(hubOnly ? "hubOnly" : "shared")} + )} -
    + {a.description && ( +

    {a.description}

    + )} +
    {server && a.owner && !a.mine && ( - <> - - {a.owner.slice(0, 1)} - - {t("by", { name: a.owner })} · - + {t("by", { name: a.owner })} · )} {fmtDate(a.createdAt)} @@ -834,92 +773,36 @@ function ArtifactCard({ )} - + {canPreview && !primaryIsPreview && ( - )} {canDownload && primaryIsPreview && ( - - + + {t("download")} )} - {a.installed && a.kind === "skill" && ( - - )} - {a.installed && ( - - )} {server && a.canChange && ( - )} {a.canChange && ( - <> - - {!hubOnly && ( - - )} - + )}
    @@ -951,11 +834,6 @@ function ArtifactCard({ {t("install")} )} - {a.installed && a.kind === "skill" && ( - - {t("reinstall")} - - )} {a.installed && ( {t("uninstall")} @@ -983,8 +861,8 @@ function ArtifactCard({ ); } -/** An artifact's own page, App Store style: icon and Get button up top, a - * strip of facts, what it is, how it installs, and what it carries. */ +/** An artifact's own page: title and main action up top, a strip of facts, + * what it is, how it installs, and what it carries. */ function DetailDialog({ item: a, server, @@ -994,8 +872,6 @@ function DetailDialog({ onRename, onDelete, onInstall, - onReinstall, - onUninstall, onPreview, }: CardActions & { item: Artifact; @@ -1005,8 +881,7 @@ function DetailDialog({ }) { const { t } = useTranslation("artifacts"); const { canPreview, canDownload, hubOnly } = capabilities(a); - const secondary = - "inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs font-medium text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-50"; + const secondary = smallSecondary; const stats: { label: string; value: React.ReactNode }[] = [ { label: t("stat.kind"), value: t(`kindOne.${a.kind}`) }, ...(a.kind === "skill" || a.kind === "plugin" @@ -1019,17 +894,15 @@ function DetailDialog({ return (
    -
    - -
    -

    {a.title}

    +
    +
    +

    {a.title}

    {server && a.owner && !a.mine ? t("by", { name: a.owner }) : t(`kindOne.${a.kind}`)} {a.source ? ` · ${a.source}` : ""}

    {a.shared && ( - - + {t(hubOnly ? "hubOnly" : "shared")} )} @@ -1040,47 +913,22 @@ function DetailDialog({ onInstall={onInstall} onPreview={onPreview} /> - {a.installed && a.kind === "skill" && ( - - )} - {a.installed && ( - - )} {canPreview && a.kind !== "file" && ( )} {canDownload && canPreview && ( - {t("download")} )} {a.kind === "page" && a.url && ( )} @@ -1088,7 +936,7 @@ function DetailDialog({
    -
    +
    {stats.map((s) => (
    @@ -1109,7 +957,7 @@ function DetailDialog({
    {a.kind === "skill" && a.source && ( -
    +

    {t("detail.runAs")} @@ -1122,7 +970,7 @@ function DetailDialog({

    )} {a.kind === "plugin" && ( -
    +

    {t("form.pluginKey")}: {a.source}

    @@ -1139,10 +987,9 @@ function DetailDialog({

    {t("files", { count: a.files.length })}

    -
      +
        {a.files.map((f) => (
      • - onPreview(f.path)} > - + {t("preview")} )} - + {t("download")}
      • ))} @@ -1178,24 +1025,21 @@ function DetailDialog({

        {a.canChange && ( -
        +
        {server && ( )} {!hubOnly && ( )} @@ -1343,7 +1187,7 @@ function Modal({ aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()} - className={`flex max-h-[90vh] w-full ${wide ? "max-w-6xl" : size === "md" ? "max-w-2xl" : "max-w-lg"} flex-col overflow-hidden rounded-2xl border border-border bg-surface-1 shadow-2xl shadow-black/50`} + className={`flex max-h-[90vh] w-full ${wide ? "max-w-6xl" : size === "md" ? "max-w-2xl" : "max-w-lg"} flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50`} >

        {title}

        @@ -1619,15 +1463,11 @@ function SkillSummary({ item }: { item: Artifact }) { data-testid="skill-summary" >
        - /{item.source} {item.installed && ( - - - {t("installedBadge")} - + {t("installedBadge")} )} {t("files", { count: item.files.length })} @@ -1691,7 +1531,6 @@ function PreviewDialog({ )}
        - {current} @@ -1699,7 +1538,6 @@ function PreviewDialog({ href={api.artifacts.rawUrl(item.id, current, true)} className="btn-ghost px-2 py-1 text-xs" > - {t("download")}
        @@ -1711,7 +1549,6 @@ function PreviewDialog({ href={api.artifacts.rawUrl(item.id, current, true)} className="btn-primary text-sm" > - {t("download")}
        diff --git a/client/src/pages/SettingsHub.tsx b/client/src/pages/SettingsHub.tsx index 5056938d..1a2856b7 100644 --- a/client/src/pages/SettingsHub.tsx +++ b/client/src/pages/SettingsHub.tsx @@ -69,6 +69,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings"; import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings"; import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings"; import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings"; +import { OmpSharingSettings } from "../components/settings/OmpSharingSettings"; import { PricingSettings } from "../components/settings/PricingSettings"; import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings"; import { useAiTools } from "../hooks/useAiTools"; @@ -297,6 +298,8 @@ export function SettingsHub({ section }: { section?: string } = {}) { {/* The Hub's install switch: server mode, an admin's (it hides itself). */} + {/* The host's omp login for members: server mode + S3, an admin's. */} +
        ), }, diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx index d93e87c6..bdcffa59 100644 --- a/client/src/pages/Users.tsx +++ b/client/src/pages/Users.tsx @@ -14,7 +14,8 @@ * account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.) * Every change asks for the admin's OWN password again (the * server refuses without it); an analyst only reads. A row shows the display - * name and email under the username when set; right-clicking a row offers + * name and email under the username when set, and "New password due" while + * the account still has a password an admin gave; right-clicking a row offers * Edit, Open home (in /files), and copying the username or email. * Laid out like the other list pages: icon header, a row of totals * (`StatCard`), the accounts in a `card` table, the audit log in a card. @@ -273,6 +274,15 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) { {t("disabled")} )} + {!u.disabled && u.mustChangePassword && ( + + {t("mustChange")} + + )}
        diff --git a/client/src/pages/__tests__/Artifacts.test.tsx b/client/src/pages/__tests__/Artifacts.test.tsx index aff2a1f2..ab97e766 100644 --- a/client/src/pages/__tests__/Artifacts.test.tsx +++ b/client/src/pages/__tests__/Artifacts.test.tsx @@ -227,6 +227,66 @@ describe("Artifacts", () => { ); }); + it("splits the Hub and My library into yours and others'", async () => { + vi.mocked(api.artifacts.settings).mockResolvedValue({ + serverMode: true, + canShare: true, + installsEnabled: true, + canManage: false, + }); + vi.mocked(api.artifacts.list).mockResolvedValue({ + items: [ + item({ id: 1, title: "my-page", shared: true }), + item({ + id: 2, + kind: "skill", + title: "their-skill", + mine: false, + owner: "an", + shared: true, + }), + ], + }); + const user = userEvent.setup(); + open(); + // All is split too, others' first. + const allOthers = await screen.findByTestId("half-all.others"); + expect(within(allOthers).getByText("their-skill")).toBeInTheDocument(); + expect( + allOthers.compareDocumentPosition(screen.getByTestId("half-all.own")) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + await user.click(await screen.findByRole("button", { name: "Hub" })); + const own = await screen.findByTestId("half-hub.own"); + expect(own).toHaveTextContent("Shared by me"); + expect(within(own).getByText("my-page")).toBeInTheDocument(); + const others = screen.getByTestId("half-hub.others"); + expect(within(others).getByText("their-skill")).toBeInTheDocument(); + expect(within(others).queryByText("my-page")).toBeNull(); + + await user.click(screen.getByRole("button", { name: "My library" })); + expect(await screen.findByTestId("half-mine.others")).toHaveTextContent( + "Installed from others" + ); + expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument(); + }); + + it("pages a long section 12 cards at a time", async () => { + vi.mocked(api.artifacts.list).mockResolvedValue({ + items: Array.from({ length: 14 }, (_, i) => + item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] }) + ), + }); + const user = userEvent.setup(); + open(); + await screen.findByText("p1"); + expect(screen.queryByText("p13")).toBeNull(); + expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByText("p13")).toBeInTheDocument(); + expect(screen.queryByText("p1")).toBeNull(); + }); + it("lists who installed an artifact, when and where, from its install count", async () => { vi.mocked(api.artifacts.list).mockResolvedValue({ items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installs: 3 })], @@ -287,13 +347,13 @@ describe("Artifacts", () => { const card = await screen.findByTestId("artifact-2"); expect(within(card).getByTestId("artifact-installed")).toHaveTextContent("Installed"); expect(within(card).queryByRole("button", { name: "Install" })).toBeNull(); - - vi.mocked(confirmDialog).mockResolvedValueOnce(true); - vi.mocked(api.artifacts.install).mockResolvedValueOnce({ - installed: "/h/.claude/skills/deploy", - }); - await userEvent.setup().click(within(card).getByRole("button", { name: "Reinstall" })); - await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true)); + // No Reinstall anywhere, and Uninstall lives only in the ⋯ menu. + expect(within(card).queryByRole("button", { name: "Reinstall" })).toBeNull(); + expect(within(card).queryByRole("button", { name: "Uninstall" })).toBeNull(); + await userEvent.setup().click(within(card).getByRole("button", { name: "More actions" })); + const menu = screen.getByTestId("artifact-menu"); + expect(within(menu).queryByRole("menuitem", { name: "Reinstall" })).toBeNull(); + expect(within(menu).getByRole("menuitem", { name: "Uninstall" })).toBeInTheDocument(); }); it("uninstalls an installed plugin only after a confirmation", async () => { @@ -307,7 +367,13 @@ describe("Artifacts", () => { const card = await screen.findByTestId("artifact-4"); const user = userEvent.setup(); vi.mocked(confirmDialog).mockResolvedValueOnce(false); - await user.click(within(card).getByRole("button", { name: "Uninstall" })); + const uninstall = async () => { + await user.click(within(card).getByRole("button", { name: "More actions" })); + await user.click( + within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Uninstall" }) + ); + }; + await uninstall(); await waitFor(() => expect(confirmDialog).toHaveBeenCalled()); expect(api.artifacts.uninstall).not.toHaveBeenCalled(); @@ -316,7 +382,7 @@ describe("Artifacts", () => { uninstalled: "tool@market", paths: [], }); - await user.click(within(card).getByRole("button", { name: "Uninstall" })); + await uninstall(); await waitFor(() => expect(api.artifacts.uninstall).toHaveBeenCalledWith(4)); }); diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index c719cab4..6f541a10 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = ` - UI build v2.8.14 (rebuild client to match) + UI build v2.8.15 (rebuild client to match)

        diff --git a/docs/API.md b/docs/API.md index dd512b4d..0338da06 100644 --- a/docs/API.md +++ b/docs/API.md @@ -85,9 +85,9 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S | `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode | | `POST /api/auth/logout` | `{ok: true}`, cookie cleared | -In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). +In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). -An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. +An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. `GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login). @@ -104,10 +104,11 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude | `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) | | `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` | | `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` | -| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays. `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` | +| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` | | `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends | | `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=`), `404 ENOAVATAR` when it has none | | `PUT /api/me/avatar` (raw image bytes) | the account's picture (PNG, JPEG, GIF or WebP by magic bytes — never SVG), stored as `/avatars/u.`; the `/api/me/account` shape back with `user.avatarAt` set. `400 EBADIMAGE`, `413 ETOOBIG` over 1 MB, `403 ELOCALMODE`. Login cookie only, audited | +| `POST /api/me/avatar/url` (`{url}`) | the same picture, downloaded by the SERVER from an `https://` link (no userinfo; every resolved address — an IP literal too — must be public, never loopback/private/link-local/CGNAT/reserved, and the connection is pinned to the vetted address; ≤ 3 redirects, each re-checked; 10 s timeout; ≤ 1 MB read; no cookie sent), then checked like an upload (`server/lib/avatar-url.js`). `400 EBADURL`/`EPRIVATEURL`/`EBADIMAGE`, `413 ETOOBIG`, `502 EFETCH`, `403 ELOCALMODE`. Login cookie only, audited | | `DELETE /api/me/avatar` | removes it (`avatarAt` back to `null`); deleting the user removes it too | The keys are the browser's own localStorage names: `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` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them. @@ -2918,6 +2919,8 @@ Every history row carries `lane_id` (the workspace whose run it belongs to) and GET /api/ai-tools Catalog + detection — { tools: [...], default } POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true, defaultModel?: string|null } — same body +GET /api/ai-tools/omp-sharing { available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?} } — S3 only, never a token +PUT /api/ai-tools/omp-sharing Body: { enabled: boolean } — an admin's; 409 ENOS3 without per-user accounts; audited GET /api/ai-tools/:id/models { flag, models: [{ id, label, hint? }], defaultModel } — the tool's own list GET /api/ai-tools/:id/usage { sessions, cost } — sessions with tool = id, their tokens priced GET /api/ai-tools/:id/config { files: [{ index, path, format: "json"|"jsonc"|"text", exists, size }] } @@ -3056,16 +3059,16 @@ GET /api/artifacts/:id/installs {items: [{user, mine, at, replace What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`): -- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner. +- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned. - **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `/artifacts//`. - **skill** — the caller's own `~/.claude/skills/` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`. - **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept). - **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way. - **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it. -- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright. +- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright. - **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment. - **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills//` AND `~/.agents/skills//` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/[-agents]-