diff --git a/libs/frontend/core/src/lib/models/mcp.types.ts b/libs/frontend/core/src/lib/models/mcp.types.ts index 4455c7fbd..ab55f717d 100644 --- a/libs/frontend/core/src/lib/models/mcp.types.ts +++ b/libs/frontend/core/src/lib/models/mcp.types.ts @@ -43,7 +43,7 @@ export enum McpApprovalStatus /** * Per-user connection state of a server the user has installed. * - * Activation in the user's agent runtime ("Claw") is automatic once connected; + * Activation in the OpenCrane agent runtime is automatic once connected; * `Activating` and `ActivationFailed` surface that backend step. */ export enum McpConnectionStatus diff --git a/libs/frontend/elements/ui/.storybook/main.ts b/libs/frontend/elements/ui/.storybook/main.ts index 4eb6761e9..094ef270c 100644 --- a/libs/frontend/elements/ui/.storybook/main.ts +++ b/libs/frontend/elements/ui/.storybook/main.ts @@ -20,6 +20,7 @@ const config: StorybookConfig = ,"../../../features/agent-threads/src/**/__tests__/*.stories.@(js|jsx|mjs|ts|tsx)" ,"../../../features/conversation-workspace/src/**/__tests__/*.stories.@(js|jsx|mjs|ts|tsx)" ,"../../../features/settings/src/**/__tests__/*.stories.@(js|jsx|mjs|ts|tsx)" + ,"../../../features/tools/src/**/__tests__/*.stories.@(js|jsx|mjs|ts|tsx)" ], addons: [ diff --git a/libs/frontend/elements/ui/.storybook/tsconfig.json b/libs/frontend/elements/ui/.storybook/tsconfig.json index 6c5fb2f16..a6a26d05f 100644 --- a/libs/frontend/elements/ui/.storybook/tsconfig.json +++ b/libs/frontend/elements/ui/.storybook/tsconfig.json @@ -30,6 +30,7 @@ "../../../features/agent-threads/src/**/__tests__/*.stories.ts", "../../../features/conversation-workspace/src/**/__tests__/*.stories.ts", "../../../features/settings/src/**/__tests__/*.stories.ts", + "../../../features/tools/src/**/__tests__/*.stories.ts", "preview.ts" ] } diff --git a/libs/frontend/elements/ui/project.json b/libs/frontend/elements/ui/project.json index 153dcab18..e03f2d686 100644 --- a/libs/frontend/elements/ui/project.json +++ b/libs/frontend/elements/ui/project.json @@ -41,7 +41,8 @@ "{workspaceRoot}/libs/frontend/elements/conversation/src/**/*", "{workspaceRoot}/libs/frontend/features/agent-threads/src/**/*", "{workspaceRoot}/libs/frontend/features/conversation-workspace/src/**/*", - "{workspaceRoot}/libs/frontend/features/settings/src/**/*" + "{workspaceRoot}/libs/frontend/features/settings/src/**/*", + "{workspaceRoot}/libs/frontend/features/tools/src/**/*" ], "options": { "browserTarget": "frontend-elements-ui:build-storybook", @@ -78,7 +79,8 @@ "{workspaceRoot}/libs/frontend/elements/conversation/src/**/*", "{workspaceRoot}/libs/frontend/features/agent-threads/src/**/*", "{workspaceRoot}/libs/frontend/features/conversation-workspace/src/**/*", - "{workspaceRoot}/libs/frontend/features/settings/src/**/*" + "{workspaceRoot}/libs/frontend/features/settings/src/**/*", + "{workspaceRoot}/libs/frontend/features/tools/src/**/*" ], "outputs": [ "{options.outputDir}" diff --git a/libs/frontend/features/tools/src/lib/admin/access-policy/__tests__/access-policy.component.stories.ts b/libs/frontend/features/tools/src/lib/admin/access-policy/__tests__/access-policy.component.stories.ts new file mode 100644 index 000000000..29dda6f35 --- /dev/null +++ b/libs/frontend/features/tools/src/lib/admin/access-policy/__tests__/access-policy.component.stories.ts @@ -0,0 +1,127 @@ +import { provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { applicationConfig } from "@storybook/angular"; +import type { Meta, StoryObj } from "@storybook/angular"; +import { expect, userEvent, waitFor, within } from "storybook/test"; + +import { McpAccessPolicy } from "@opencrane/core"; +import { MCP_ACCESS_POLICIES, MCP_CATALOGUE, MCP_DIRECTORY } from "@opencrane/core/testing"; +import { SessionStore, type Capabilities } from "@opencrane/state/core"; +import { MCP_GATEWAY, McpGateway } from "@opencrane/state/mcp/adapter"; + +import { AccessPolicyComponent } from "../access-policy.component"; + +/** Session fixture that behaves like a customer admin. */ +const _ADMIN_SESSION = +{ + capabilities: signal({ + isOperator: false, + isPlatformOperator: false, + customerAdmin: true, + manageCustomers: false, + managePolicies: true, + manageBudgets: false + }) +} as Pick; + +/** Session fixture that shows the denied state. */ +const _DENIED_SESSION = +{ + capabilities: signal({ + isOperator: false, + isPlatformOperator: false, + customerAdmin: false, + manageCustomers: false, + managePolicies: false, + manageBudgets: false + }) +} as Pick; + +/** Creates an isolated policy gateway that returns saved grants on the next read. */ +function _CreateAccessPolicyGateway(): Pick +{ + const policies = new Map(Object.values(MCP_ACCESS_POLICIES).map(function byServer(policy: McpAccessPolicy): [string, McpAccessPolicy] { return [policy.serverId, { ...policy, groups: [...policy.groups], users: [...policy.users] }]; })); + + return { + listCatalogue: function listCatalogue() { return Promise.resolve(MCP_CATALOGUE.map(function clone(server) { return { ...server }; })); }, + getDirectory: function getDirectory() { return Promise.resolve({ users: [...MCP_DIRECTORY.users], groups: [...MCP_DIRECTORY.groups] }); }, + getAccessPolicy: function getAccessPolicy(serverId: string) + { + const policy = policies.get(serverId) ?? { serverId, everyoneInOrg: false, groups: [], users: [] }; + return Promise.resolve({ ...policy, groups: [...policy.groups], users: [...policy.users] }); + }, + updateAccessPolicy: function updateAccessPolicy(serverId: string, policy: McpAccessPolicy) + { + const updated = { ...policy, serverId, groups: [...policy.groups], users: [...policy.users] }; + policies.set(serverId, updated); + return Promise.resolve({ ...updated, groups: [...updated.groups], users: [...updated.users] }); + } + }; +} + +/** Storybook metadata for the admin access-policy surface. */ +const meta: Meta = +{ + title: "Tools/Access policy", + component: AccessPolicyComponent, + tags: ["autodocs"], + decorators: [applicationConfig({ providers: [provideRouter([{ path: "**", children: [] }]), { provide: MCP_GATEWAY, useFactory: _CreateAccessPolicyGateway }, { provide: SessionStore, useValue: _ADMIN_SESSION }] })], + parameters: + { + docs: + { + description: + { + component: "The entitlement editor for one MCP server. Stories keep the everyone-in-org, group, and user grants readable without a live policy backend." + } + } + } +}; + +export default meta; + +/** Local Storybook story type for the access-policy surface. */ +type Story = StoryObj; + +/** The editor opens on a selected server with live entitlement chips. */ +export const SelectedPolicy: Story = +{ + tags: ["visual-test"], + play: async function play({ canvasElement }) + { + const canvas = within(canvasElement); + await waitFor(function policyControlsLoaded() { expect(canvas.getAllByRole("combobox").length).toBeGreaterThan(0); }); + await userEvent.selectOptions(canvas.getAllByRole("combobox")[0], "Marketing"); + await waitFor(function savedGroup() { expect(canvas.getByText("Marketing", { selector: "span.wo-ap__chip" })).toBeVisible(); }); + }, + render: function render() + { + return { props: { server: "github" }, template: `` }; + }, + parameters: + { + docs: + { + description: + { + story: "The normal edit state for a selected server, with org-wide, group, and user grants visible." + } + } + } +}; + +/** The denied state explains the admin-only boundary. */ +export const Denied: Story = +{ + decorators: [applicationConfig({ providers: [provideRouter([{ path: "**", children: [] }]), { provide: MCP_GATEWAY, useFactory: _CreateAccessPolicyGateway }, { provide: SessionStore, useValue: _DENIED_SESSION }] })], + parameters: + { + docs: + { + description: + { + story: "The access-gated state for users without the customer-admin capability." + } + } + } +}; diff --git a/libs/frontend/features/tools/src/lib/admin/access-policy/access-policy.component.html b/libs/frontend/features/tools/src/lib/admin/access-policy/access-policy.component.html index c439ee48e..44e391398 100644 --- a/libs/frontend/features/tools/src/lib/admin/access-policy/access-policy.component.html +++ b/libs/frontend/features/tools/src/lib/admin/access-policy/access-policy.component.html @@ -36,7 +36,7 @@

{{ srv.name }}

Everyone in org

All current and future members can install this server.

- +

Groups

@@ -51,7 +51,7 @@

{{ srv.name }}

No groups } @if (availableGroups().length > 0) { - @for (group of availableGroups(); track group) { @@ -72,7 +72,7 @@

{{ srv.name }}

No individual users } @if (availableUsers().length > 0) { - @for (user of availableUsers(); track user.id) { diff --git a/libs/frontend/features/tools/src/lib/admin/catalogue-admin/__tests__/catalogue-admin.component.stories.ts b/libs/frontend/features/tools/src/lib/admin/catalogue-admin/__tests__/catalogue-admin.component.stories.ts new file mode 100644 index 000000000..47b7454e5 --- /dev/null +++ b/libs/frontend/features/tools/src/lib/admin/catalogue-admin/__tests__/catalogue-admin.component.stories.ts @@ -0,0 +1,132 @@ +import { provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { applicationConfig } from "@storybook/angular"; +import type { Meta, StoryObj } from "@storybook/angular"; +import { expect, userEvent, waitFor, within } from "storybook/test"; + +import { McpApprovalStatus, McpServer } from "@opencrane/core"; +import { MCP_CATALOGUE } from "@opencrane/core/testing"; +import { SessionStore, type Capabilities } from "@opencrane/state/core"; +import { MCP_GATEWAY, McpGateway } from "@opencrane/state/mcp/adapter"; + +import { CatalogueAdminComponent } from "../catalogue-admin.component"; + +/** Gives the governance story one approved server while retaining every other shared state. */ +const _ADMIN_CATALOGUE = MCP_CATALOGUE.map(function withApprovedState(server: McpServer): McpServer +{ + if (server.id !== "linear") return { ...server }; + return { ...server, approvalStatus: McpApprovalStatus.Approved }; +}); + +/** Session fixture that behaves like a customer admin. */ +const _ADMIN_SESSION = +{ + capabilities: signal({ + isOperator: false, + isPlatformOperator: false, + customerAdmin: true, + manageCustomers: false, + managePolicies: true, + manageBudgets: false + }) +} as Pick; + +/** Session fixture that shows the denied state. */ +const _DENIED_SESSION = +{ + capabilities: signal({ + isOperator: false, + isPlatformOperator: false, + customerAdmin: false, + manageCustomers: false, + managePolicies: false, + manageBudgets: false + }) +} as Pick; + +/** Creates an isolated governance gateway that retains lifecycle changes after reloads. */ +function _CreateAdminCatalogueGateway(): Pick +{ + const catalogue = new Map(_ADMIN_CATALOGUE.map(function byId(server: McpServer): [string, McpServer] { return [server.id, { ...server }]; })); + + /** Applies one governance state transition to the requested server. */ + function _SetStatus(serverId: string, approvalStatus: McpApprovalStatus): McpServer + { + const current = catalogue.get(serverId); + if (!current) throw new Error(`Unknown MCP server: ${serverId}`); + const updated = { ...current, approvalStatus }; + catalogue.set(serverId, updated); + return { ...updated }; + } + + return { + listCatalogue: function listCatalogue() { return Promise.resolve(Array.from(catalogue.values(), function clone(server) { return { ...server }; })); }, + approve: function approve(serverId: string) { return Promise.resolve(_SetStatus(serverId, McpApprovalStatus.Approved)); }, + publish: function publish(serverId: string) { return Promise.resolve(_SetStatus(serverId, McpApprovalStatus.Published)); }, + reject: function reject(serverId: string) { return Promise.resolve(_SetStatus(serverId, McpApprovalStatus.Disabled)); }, + setEnabled: function setEnabled(serverId: string, enabled: boolean) { return Promise.resolve(_SetStatus(serverId, enabled ? McpApprovalStatus.Published : McpApprovalStatus.Disabled)); } + }; +} + +/** Storybook metadata for the admin catalogue surface. */ +const meta: Meta = +{ + title: "Tools/Admin catalogue", + component: CatalogueAdminComponent, + tags: ["autodocs"], + decorators: [applicationConfig({ providers: [provideRouter([{ path: "**", children: [] }]), { provide: MCP_GATEWAY, useFactory: _CreateAdminCatalogueGateway }, { provide: SessionStore, useValue: _ADMIN_SESSION }] })], + parameters: + { + docs: + { + description: + { + component: "The org-admin governance surface for MCP servers. Stories keep approved, pending-review, published, and disabled states visible without a live control plane." + } + } + } +}; + +export default meta; + +/** Local Storybook story type for the admin catalogue surface. */ +type Story = StoryObj; + +/** The admin catalogue shows governance actions for every server state. */ +export const AdminView: Story = +{ + tags: ["visual-test"], + play: async function play({ canvasElement }) + { + const canvas = within(canvasElement); + await waitFor(function catalogueRowsLoaded() { expect(canvas.getByRole("button", { name: "Approve" })).toBeVisible(); }); + await userEvent.click(canvas.getAllByRole("button", { name: "Approve" })[0]); + await waitFor(function approvedState() { expect(canvas.getByRole("button", { name: "Publish" })).toBeVisible(); }); + }, + parameters: + { + docs: + { + description: + { + story: "The governance table with pending-review, approved, published, and disabled rows plus their action buttons." + } + } + } +}; + +/** The denied state explains why a non-admin cannot reach the governance table. */ +export const Denied: Story = +{ + decorators: [applicationConfig({ providers: [provideRouter([{ path: "**", children: [] }]), { provide: MCP_GATEWAY, useFactory: _CreateAdminCatalogueGateway }, { provide: SessionStore, useValue: _DENIED_SESSION }] })], + parameters: + { + docs: + { + description: + { + story: "The access-gated state for users without the customer-admin capability." + } + } + } +}; diff --git a/libs/frontend/features/tools/src/lib/admin/catalogue-admin/catalogue-admin.component.html b/libs/frontend/features/tools/src/lib/admin/catalogue-admin/catalogue-admin.component.html index 0df5674d3..532ea6b66 100644 --- a/libs/frontend/features/tools/src/lib/admin/catalogue-admin/catalogue-admin.component.html +++ b/libs/frontend/features/tools/src/lib/admin/catalogue-admin/catalogue-admin.component.html @@ -14,7 +14,7 @@ Type Entitlements Status - + Actions diff --git a/libs/frontend/features/tools/src/lib/admin/catalogue-admin/catalogue-admin.component.scss b/libs/frontend/features/tools/src/lib/admin/catalogue-admin/catalogue-admin.component.scss index 4b48b4750..1b6c71af2 100644 --- a/libs/frontend/features/tools/src/lib/admin/catalogue-admin/catalogue-admin.component.scss +++ b/libs/frontend/features/tools/src/lib/admin/catalogue-admin/catalogue-admin.component.scss @@ -29,5 +29,5 @@ } .wo-admin__row--off { - opacity: 0.55; + background: var(--oc-surface-subtle); } diff --git a/libs/frontend/features/tools/src/lib/catalogue/__tests__/catalogue.component.stories.ts b/libs/frontend/features/tools/src/lib/catalogue/__tests__/catalogue.component.stories.ts new file mode 100644 index 000000000..9eb53a70b --- /dev/null +++ b/libs/frontend/features/tools/src/lib/catalogue/__tests__/catalogue.component.stories.ts @@ -0,0 +1,82 @@ +import { provideRouter } from "@angular/router"; +import { applicationConfig } from "@storybook/angular"; +import type { Meta, StoryObj } from "@storybook/angular"; +import { expect, userEvent, waitFor, within } from "storybook/test"; + +import { McpApprovalStatus, McpConnectionStatus, McpInstalledServer, McpServerType } from "@opencrane/core"; +import { MCP_CATALOGUE, MCP_INSTALLED } from "@opencrane/core/testing"; +import { MCP_GATEWAY, McpGateway } from "@opencrane/state/mcp/adapter"; + +import { CatalogueComponent } from "../catalogue.component"; + +/** Creates an isolated catalogue gateway whose install results match each server type. */ +function _CreateCatalogueGateway(): Pick +{ + const installed = new Map(MCP_INSTALLED.map(function byServer(record: McpInstalledServer): [string, McpInstalledServer] { return [record.serverId, { ...record }]; })); + + return { + listEntitledCatalogue: function listEntitledCatalogue() + { + return Promise.resolve(MCP_CATALOGUE.filter(function published(server) { return server.approvalStatus === McpApprovalStatus.Published; }).map(function clone(server) { return { ...server }; })); + }, + listInstalled: function listInstalled() + { + return Promise.resolve(Array.from(installed.values(), function clone(record) { return { ...record }; })); + }, + install: function install(serverId: string) + { + const server = MCP_CATALOGUE.find(function matching(candidate) { return candidate.id === serverId; }); + const connectionStatus = server?.type === McpServerType.MultiUser ? McpConnectionStatus.SharedKey : McpConnectionStatus.NeedsCredential; + const record: McpInstalledServer = { serverId, connectionStatus, lastUsed: null }; + installed.set(serverId, record); + return Promise.resolve({ ...record }); + } + }; +} + +/** Storybook metadata for the user-facing MCP catalogue. */ +const meta: Meta = +{ + title: "Tools/Catalogue", + component: CatalogueComponent, + tags: ["autodocs"], + decorators: [applicationConfig({ providers: [provideRouter([{ path: "**", children: [] }]), { provide: MCP_GATEWAY, useFactory: _CreateCatalogueGateway }] })], + parameters: + { + docs: + { + description: + { + component: "The browse-and-install surface for entitled MCP servers. Stories keep the published catalogue and installed markers reviewable without a live control plane." + } + } + } +}; + +export default meta; + +/** Local Storybook story type for the catalogue surface. */ +type Story = StoryObj; + +/** The default catalogue shows installed and installable servers together. */ +export const Default: Story = +{ + tags: ["visual-test"], + play: async function play({ canvasElement }) + { + const canvas = within(canvasElement); + await waitFor(function catalogueCardsLoaded() { expect(canvas.getByRole("button", { name: "Install" })).toBeVisible(); }); + await userEvent.click(canvas.getByRole("button", { name: "Install" })); + await waitFor(function installedState() { expect(canvas.queryByRole("button", { name: "Install" })).not.toBeInTheDocument(); }); + }, + parameters: + { + docs: + { + description: + { + story: "The standard browse view with published servers, installed markers, and the approved-only explanation note." + } + } + } +}; diff --git a/libs/frontend/features/tools/src/lib/catalogue/catalogue.component.html b/libs/frontend/features/tools/src/lib/catalogue/catalogue.component.html index a82efbd3d..302e42b6b 100644 --- a/libs/frontend/features/tools/src/lib/catalogue/catalogue.component.html +++ b/libs/frontend/features/tools/src/lib/catalogue/catalogue.component.html @@ -10,7 +10,7 @@ - diff --git a/libs/frontend/features/tools/src/lib/catalogue/catalogue.component.scss b/libs/frontend/features/tools/src/lib/catalogue/catalogue.component.scss index cf34ea4d4..62c2575be 100644 --- a/libs/frontend/features/tools/src/lib/catalogue/catalogue.component.scss +++ b/libs/frontend/features/tools/src/lib/catalogue/catalogue.component.scss @@ -116,7 +116,7 @@ margin-top: 18px; a { - color: var(--oc-accent); + color: var(--oc-accent-active); text-decoration: none; } } diff --git a/libs/frontend/features/tools/src/lib/connect-drawer/__tests__/connect-drawer.component.stories.ts b/libs/frontend/features/tools/src/lib/connect-drawer/__tests__/connect-drawer.component.stories.ts new file mode 100644 index 000000000..87a0d6139 --- /dev/null +++ b/libs/frontend/features/tools/src/lib/connect-drawer/__tests__/connect-drawer.component.stories.ts @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/angular"; +import { expect, userEvent, within } from "storybook/test"; + +import { McpConnectionStatus } from "@opencrane/core"; +import { MCP_CATALOGUE, MCP_INSTALLED } from "@opencrane/core/testing"; + +import { ConnectDrawerComponent } from "../connect-drawer.component"; + +const _STRIPE = MCP_CATALOGUE.find(function find(server) { return server.id === "stripe" })!; +const _GITHUB = MCP_CATALOGUE.find(function find(server) { return server.id === "github" })!; +const _POSTGRES = MCP_CATALOGUE.find(function find(server) { return server.id === "postgres-prod" })!; +const _STRIPE_INSTALLED = MCP_INSTALLED.find(function find(record) { return record.serverId === "stripe" })!; +const _GITHUB_INSTALLED = MCP_INSTALLED.find(function find(record) { return record.serverId === "github" })!; +const _POSTGRES_INSTALLED = MCP_INSTALLED.find(function find(record) { return record.serverId === "postgres-prod" })!; + +const meta: Meta = { + title: "Tools/Connect drawer", + component: ConnectDrawerComponent, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: "The secure connection surface for MCP servers. Stories keep write-only credentials, OAuth account state, and administrator-managed credentials visibly distinct." + } + } + } +}; + +export default meta; +type Story = StoryObj; + +/** A single-user server starts with an empty write-only credential form. */ +export const SingleUserCredential: Story = { + tags: ["visual-test"], + args: { server: _STRIPE, installed: _STRIPE_INSTALLED }, + play: async function play({ canvasElement }) + { + const canvas = within(canvasElement); + await expect(canvas.getByRole("button", { name: "Save & connect" })).toBeDisabled(); + } +}; + +/** A stored single-user credential stays masked until the user chooses Replace. */ +export const StoredCredential: Story = { + tags: ["visual-test"], + args: { server: _STRIPE, installed: { ..._STRIPE_INSTALLED, connectionStatus: McpConnectionStatus.Connected } }, + play: async function play({ canvasElement }) + { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Replace" })); + await expect(canvas.getAllByRole("textbox")[0]).toBeVisible(); + } +}; + +/** A disconnected OAuth server offers the provider consent action. */ +export const DisconnectedOauth: Story = { + tags: ["visual-test"], + args: { server: _GITHUB, installed: { ..._GITHUB_INSTALLED, connectionStatus: McpConnectionStatus.NeedsCredential } }, + render: function render(args) { return { props: { ...args, connectCount: 0 }, template: `` }; }, + play: async function play({ canvasElement }) + { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /Connect with OAuth/iu })); + await expect(canvas.getByTestId("connect-count")).toHaveAttribute("data-count", "1"); + } +}; + +/** A connected OAuth server shows the account identity and disconnect action. */ +export const ConnectedOauth: Story = { + tags: ["visual-test"], + args: { server: _GITHUB, installed: _GITHUB_INSTALLED }, + render: function render(args) { return { props: { ...args, disconnectCount: 0 }, template: `` }; }, + play: async function play({ canvasElement }) + { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Disconnect" })); + await expect(canvas.getByTestId("disconnect-count")).toHaveAttribute("data-count", "1"); + } +}; + +/** An administrator-managed server explains why no participant credential is required. */ +export const SharedKey: Story = { + tags: ["visual-test"], + args: { server: _POSTGRES, installed: _POSTGRES_INSTALLED }, + render: function render(args) { return { props: { ...args, closeCount: 0 }, template: `` }; }, + play: async function play({ canvasElement }) + { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Close" })); + await expect(canvas.getByTestId("close-count")).toHaveAttribute("data-count", "1"); + } +}; diff --git a/libs/frontend/features/tools/src/lib/connect-drawer/connect-drawer.component.html b/libs/frontend/features/tools/src/lib/connect-drawer/connect-drawer.component.html index 91cf18842..d0d92e658 100644 --- a/libs/frontend/features/tools/src/lib/connect-drawer/connect-drawer.component.html +++ b/libs/frontend/features/tools/src/lib/connect-drawer/connect-drawer.component.html @@ -4,6 +4,7 @@ position="right" [modal]="true" [header]="title()" + ariaCloseLabel="Close connection drawer" [style]="{ width: '420px' }" styleClass="wo-connect-drawer" > @@ -31,6 +32,7 @@ [class.wo-mono]="field.sensitive" [type]="field.sensitive ? 'password' : 'text'" [attr.autocomplete]="field.sensitive ? 'new-password' : 'off'" + [attr.aria-label]="field.label" [placeholder]="field.placeholder ?? ''" [value]="formValues()[field.key]" (input)="onFieldInput(field.key, $event)" diff --git a/libs/frontend/features/tools/src/lib/connect-drawer/connect-drawer.component.scss b/libs/frontend/features/tools/src/lib/connect-drawer/connect-drawer.component.scss index e86b9d299..bffe3ae1a 100644 --- a/libs/frontend/features/tools/src/lib/connect-drawer/connect-drawer.component.scss +++ b/libs/frontend/features/tools/src/lib/connect-drawer/connect-drawer.component.scss @@ -19,7 +19,7 @@ } &__req { - color: var(--oc-accent); + color: var(--oc-accent-active); font-size: 11px; } diff --git a/libs/frontend/features/tools/src/lib/my-tools/__tests__/my-tools.component.stories.ts b/libs/frontend/features/tools/src/lib/my-tools/__tests__/my-tools.component.stories.ts new file mode 100644 index 000000000..7c77c0fde --- /dev/null +++ b/libs/frontend/features/tools/src/lib/my-tools/__tests__/my-tools.component.stories.ts @@ -0,0 +1,57 @@ +import { provideRouter } from "@angular/router"; +import { applicationConfig } from "@storybook/angular"; +import type { Meta, StoryObj } from "@storybook/angular"; + +import { McpApprovalStatus, McpConnectionStatus, McpInstalledServer } from "@opencrane/core"; +import { MCP_CATALOGUE, MCP_INSTALLED } from "@opencrane/core/testing"; +import { MCP_GATEWAY, McpGateway } from "@opencrane/state/mcp/adapter"; + +import { MyToolsComponent } from "../my-tools.component"; + +/** Creates an isolated gateway that preserves connection changes after each reload. */ +function _CreateMyToolsGateway(): Pick +{ + const installed = new Map(MCP_INSTALLED.map(function byServer(record: McpInstalledServer): [string, McpInstalledServer] { return [record.serverId, { ...record }]; })); + + /** Updates the requested server and returns the same state that the next read will expose. */ + function _SetConnection(serverId: string, connectionStatus: McpConnectionStatus, connectedAccount?: string): McpInstalledServer + { + const current = installed.get(serverId); + if (!current) throw new Error(`Unknown installed MCP server: ${serverId}`); + const updated: McpInstalledServer = { ...current, connectionStatus, connectedAccount }; + installed.set(serverId, updated); + return { ...updated }; + } + + return { + listEntitledCatalogue: function listEntitledCatalogue() { return Promise.resolve(MCP_CATALOGUE.filter(function published(server) { return server.approvalStatus === McpApprovalStatus.Published; }).map(function clone(server) { return { ...server }; })); }, + listInstalled: function listInstalled() { return Promise.resolve(Array.from(installed.values(), function clone(record) { return { ...record }; })); }, + uninstall: function uninstall(serverId: string) { installed.delete(serverId); return Promise.resolve(); }, + setCredential: function setCredential(serverId: string) { return Promise.resolve(_SetConnection(serverId, McpConnectionStatus.Connected)); }, + removeCredential: function removeCredential(serverId: string) { return Promise.resolve(_SetConnection(serverId, McpConnectionStatus.NeedsCredential)); }, + connectOauth: function connectOauth(serverId: string) { return Promise.resolve(_SetConnection(serverId, McpConnectionStatus.OauthConnected, "storybook@example.com")); }, + disconnect: function disconnect(serverId: string) { return Promise.resolve(_SetConnection(serverId, McpConnectionStatus.NeedsCredential)); } + }; +} + +const meta: Meta = { + title: "Tools/My tools", + component: MyToolsComponent, + tags: ["autodocs"], + decorators: [applicationConfig({ providers: [provideRouter([{ path: "**", children: [] }]), { provide: MCP_GATEWAY, useFactory: _CreateMyToolsGateway }] })], + parameters: { + docs: { + description: { + component: "The participant-facing inventory of installed MCP servers. The story uses shared fixture data so representative connection states remain reviewable without a live control plane." + } + } + } +}; + +export default meta; +type Story = StoryObj; + +/** Installed tools cover pending credentials, OAuth, token, shared-key, and activation states. */ +export const InstalledStates: Story = { + parameters: { docs: { description: { story: "The default fixture shows the status table, the pending-credential callout, and the catalogue navigation affordance." } } } +}; diff --git a/libs/frontend/features/tools/src/lib/my-tools/my-tools.component.html b/libs/frontend/features/tools/src/lib/my-tools/my-tools.component.html index 80cb523ce..976ce3259 100644 --- a/libs/frontend/features/tools/src/lib/my-tools/my-tools.component.html +++ b/libs/frontend/features/tools/src/lib/my-tools/my-tools.component.html @@ -2,7 +2,7 @@
@if (rows().length > 0) { @@ -13,7 +13,7 @@ Type Status Last used - + Actions @@ -64,7 +64,7 @@ @if (firstNeedsCredential(); as pending) {
- {{ pending.name }} needs a credential before your agent can use it. Until then its tools are installed but inactive in Claw. + {{ pending.name }} needs a credential before your agent can use it. Until then its tools are installed but inactive in the OpenCrane agent runtime.
} diff --git a/libs/frontend/features/tools/src/lib/my-tools/my-tools.component.ts b/libs/frontend/features/tools/src/lib/my-tools/my-tools.component.ts index 1e4694148..a72a63853 100644 --- a/libs/frontend/features/tools/src/lib/my-tools/my-tools.component.ts +++ b/libs/frontend/features/tools/src/lib/my-tools/my-tools.component.ts @@ -22,8 +22,8 @@ interface _McpToolRow * Joins each install record to its catalogue detail, renders the connection * state (the one terracotta CTA is the "needs credential" row), and opens the * {@link ConnectDrawerComponent} for the secure connect/credential flow. All - * writes go through the injected gateway; activation in the agent runtime - * ("Claw") is automatic once connected. + * writes go through the injected gateway; activation in the OpenCrane agent + * runtime is automatic once connected. */ @Component({ selector: "wo-my-tools", diff --git a/libs/frontend/features/tools/theme.scss b/libs/frontend/features/tools/theme.scss index c3b03c3a6..66c84bfbe 100644 --- a/libs/frontend/features/tools/theme.scss +++ b/libs/frontend/features/tools/theme.scss @@ -81,6 +81,10 @@ color: var(--oc-ink-muted); line-height: 1.5; + a { + color: var(--oc-accent-active); + } + &--info { border-left-color: var(--oc-info); background: var(--oc-info-soft);