feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars

- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
@@ -2,8 +2,8 @@
* @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, and asks before signing
* the other devices out.
* passwords differ, sends the current + new password, asks before signing
* the other devices out, and uploads/removes the avatar.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,6 +16,9 @@ const me = vi.hoisted(() => ({
updateAccount: vi.fn(),
changePassword: vi.fn(),
signOutOthers: vi.fn(),
setAvatar: vi.fn(),
removeAvatar: vi.fn(),
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -44,7 +47,7 @@ const account = {
};
beforeEach(() => {
for (const fn of Object.values(me)) fn.mockReset();
for (const fn of Object.values(me)) if ("mockReset" in fn) fn.mockReset();
me.account.mockResolvedValue(account);
me.updateAccount.mockResolvedValue(account);
me.changePassword.mockResolvedValue({ ok: true });
@@ -53,6 +56,26 @@ beforeEach(() => {
});
describe("AccountSettings", () => {
it("uploads a picked avatar and removes it again", async () => {
const ue = userEvent.setup();
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-14T00:00:00Z" } };
me.setAvatar.mockResolvedValue(withAvatar);
me.removeAvatar.mockResolvedValue(account);
render(<AccountSettings />);
await screen.findByTestId("account-card");
expect(screen.getByTestId("user-avatar-initials").textContent).toBe("Mi");
expect(screen.queryByTestId("account-avatar-remove")).toBeNull();
const file = new File([new Uint8Array([0x89, 0x50])], "me.png", { type: "image/png" });
await ue.upload(screen.getByTestId("account-avatar-input"), file);
await waitFor(() => expect(me.setAvatar).toHaveBeenCalledWith(file));
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
"/api/me/avatar?v=2026-10-14T00:00:00Z"
);
await ue.click(screen.getByTestId("account-avatar-remove"));
await waitFor(() => expect(me.removeAvatar).toHaveBeenCalled());
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
});
it("shows the account's own information", async () => {
render(<AccountSettings />);
const card = await screen.findByTestId("account-card");
@@ -0,0 +1,46 @@
/**
* @file Tests for ArtifactsHubSettings — the admin's Hub install switch in
* Settings → AI tools: hidden in local mode and for a non-manager, and a
* toggle saves through PUT /api/artifacts/settings.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "../../../i18n";
import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } },
}));
const settings = vi.mocked(api.artifacts.settings);
const setInstalls = vi.mocked(api.artifacts.setInstallsEnabled);
const base = { serverMode: true, canShare: true, installsEnabled: true, canManage: true };
describe("ArtifactsHubSettings", () => {
beforeEach(() => vi.clearAllMocks());
it("lets an admin turn Hub installs off", async () => {
settings.mockResolvedValue(base);
setInstalls.mockResolvedValue({ installsEnabled: false });
render(<ArtifactsHubSettings />);
const sw = await screen.findByRole("switch", { name: "Allow installing from the Hub" });
fireEvent.click(sw);
await waitFor(() => expect(setInstalls).toHaveBeenCalledWith(false));
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
});
it("renders nothing for a non-manager or in local mode", async () => {
settings.mockResolvedValue({ ...base, canManage: false });
const { container, unmount } = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
unmount();
settings.mockResolvedValue({ ...base, serverMode: false });
const r = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
expect(r.container).toBeEmptyDOMElement();
});
});