Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/frontend/core/src/lib/models/mcp.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions libs/frontend/elements/ui/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
[
Expand Down
1 change: 1 addition & 0 deletions libs/frontend/elements/ui/.storybook/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
6 changes: 4 additions & 2 deletions libs/frontend/elements/ui/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Capabilities>({
isOperator: false,
isPlatformOperator: false,
customerAdmin: true,
manageCustomers: false,
managePolicies: true,
manageBudgets: false
})
} as Pick<SessionStore, "capabilities">;

/** Session fixture that shows the denied state. */
const _DENIED_SESSION =
{
capabilities: signal<Capabilities>({
isOperator: false,
isPlatformOperator: false,
customerAdmin: false,
manageCustomers: false,
managePolicies: false,
manageBudgets: false
})
} as Pick<SessionStore, "capabilities">;

/** Creates an isolated policy gateway that returns saved grants on the next read. */
function _CreateAccessPolicyGateway(): Pick<McpGateway, "listCatalogue" | "getDirectory" | "getAccessPolicy" | "updateAccessPolicy">
{
const policies = new Map<string, McpAccessPolicy>(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<AccessPolicyComponent> =
{
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<AccessPolicyComponent>;

/** 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: `<wo-access-policy [server]="server" />` };
},
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."
}
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ <h3 class="wo-mono wo-ap__detail-title">{{ srv.name }}</h3>
<p class="wo-ap__everyone-title">Everyone in org</p>
<p class="wo-ap__everyone-hint">All current and future members can install this server.</p>
</div>
<p-toggleswitch [ngModel]="pol.everyoneInOrg" (ngModelChange)="onToggleEveryone($event)" />
<p-toggleswitch inputId="everyone-in-org" ariaLabel="Everyone in org" [ngModel]="pol.everyoneInOrg" (ngModelChange)="onToggleEveryone($event)" />
</div>

<p class="wo-ap__group-label">Groups</p>
Expand All @@ -51,7 +51,7 @@ <h3 class="wo-mono wo-ap__detail-title">{{ srv.name }}</h3>
<span class="wo-ap__none">No groups</span>
}
@if (availableGroups().length > 0) {
<select class="wo-select wo-ap__add" (change)="addGroup($event)">
<select class="wo-select wo-ap__add" aria-label="Add group" (change)="addGroup($event)">
<option value="">+ Add group</option>
@for (group of availableGroups(); track group) {
<option [value]="group">{{ group }}</option>
Expand All @@ -72,7 +72,7 @@ <h3 class="wo-mono wo-ap__detail-title">{{ srv.name }}</h3>
<span class="wo-ap__none">No individual users</span>
}
@if (availableUsers().length > 0) {
<select class="wo-select wo-ap__add" (change)="addUser($event)">
<select class="wo-select wo-ap__add" aria-label="Add user" (change)="addUser($event)">
<option value="">+ Add user</option>
@for (user of availableUsers(); track user.id) {
<option [value]="user.id">{{ user.name }}</option>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Capabilities>({
isOperator: false,
isPlatformOperator: false,
customerAdmin: true,
manageCustomers: false,
managePolicies: true,
manageBudgets: false
})
} as Pick<SessionStore, "capabilities">;

/** Session fixture that shows the denied state. */
const _DENIED_SESSION =
{
capabilities: signal<Capabilities>({
isOperator: false,
isPlatformOperator: false,
customerAdmin: false,
manageCustomers: false,
managePolicies: false,
manageBudgets: false
})
} as Pick<SessionStore, "capabilities">;

/** Creates an isolated governance gateway that retains lifecycle changes after reloads. */
function _CreateAdminCatalogueGateway(): Pick<McpGateway, "listCatalogue" | "approve" | "publish" | "reject" | "setEnabled">
{
const catalogue = new Map<string, McpServer>(_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<CatalogueAdminComponent> =
{
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<CatalogueAdminComponent>;

/** 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."
}
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<th>Type</th>
<th>Entitlements</th>
<th>Status</th>
<th></th>
<th>Actions</th>
</tr>
</thead>
<tbody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@
}

.wo-admin__row--off {
opacity: 0.55;
background: var(--oc-surface-subtle);
}
Loading
Loading