+
{
/>
{showValidationErrors && (
-
-
+
+
{validationErrors.map((err, idx) => (
- -
+
-
{err}
))}
diff --git a/src/hooks/useComponentsQuery.ts b/src/hooks/useComponentsQuery.ts
index 9c39834c..092d4261 100644
--- a/src/hooks/useComponentsQuery.ts
+++ b/src/hooks/useComponentsQuery.ts
@@ -8,7 +8,7 @@ export interface GetComponentsHookResult {
isLoading: boolean;
}
export function useComponentsQuery(): GetComponentsHookResult {
- const { data: components, error, isLoading } = useApiResource(ListManagedComponents(), undefined, true);
+ const { data: components, error, isLoading } = useApiResource(ListManagedComponents(), undefined, null);
return { components, error, isLoading };
}
diff --git a/src/hooks/useCustomResourceDefinitionQuery.spec.ts b/src/hooks/useCustomResourceDefinitionQuery.spec.ts
new file mode 100644
index 00000000..5cf48fb3
--- /dev/null
+++ b/src/hooks/useCustomResourceDefinitionQuery.spec.ts
@@ -0,0 +1,275 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
+import { useCustomResourceDefinitionQuery } from './useCustomResourceDefinitionQuery';
+import { CustomResourceDefinition } from '../types/customResourceDefinition';
+import * as useApiResourceModule from '../lib/api/useApiResource';
+import * as useResourcePluralNamesModule from './useResourcePluralNames';
+
+vi.mock('../lib/api/useApiResource');
+vi.mock('./useResourcePluralNames');
+
+const createMockCrd = (versions: CustomResourceDefinition['spec']['versions']): CustomResourceDefinition => ({
+ kind: 'CustomResourceDefinition',
+ apiVersion: 'apiextensions.k8s.io/v1',
+ metadata: {
+ name: 'workspaces.core.openmcp.cloud',
+ uid: 'test-uid',
+ resourceVersion: '1',
+ generation: 1,
+ creationTimestamp: '2024-01-01T00:00:00Z',
+ },
+ spec: {
+ group: 'core.openmcp.cloud',
+ names: {
+ plural: 'workspaces',
+ singular: 'workspace',
+ kind: 'Workspace',
+ listKind: 'WorkspaceList',
+ },
+ scope: 'Namespaced',
+ versions,
+ conversion: {
+ strategy: 'None',
+ },
+ },
+});
+
+describe('useCustomResourceDefinitionQuery', () => {
+ let useApiResourceMock: Mock;
+ let useResourcePluralNamesMock: Mock;
+
+ const setupApiResourceMock = (
+ data: CustomResourceDefinition | undefined,
+ isLoading = false,
+ error: unknown = undefined,
+ ) => {
+ useApiResourceMock.mockReturnValue({ data, isLoading, error });
+ };
+
+ const setupResourcePluralNamesMock = (isLoading = false) => {
+ useResourcePluralNamesMock.mockReturnValue({
+ getPluralKind: (kind: string) => (kind ? kind.toLowerCase() + 's' : ''),
+ isLoading,
+ });
+ };
+
+ beforeEach(() => {
+ useApiResourceMock = vi.fn();
+ vi.spyOn(useApiResourceModule, 'useApiResource').mockImplementation(useApiResourceMock);
+
+ useResourcePluralNamesMock = vi.fn();
+ vi.spyOn(useResourcePluralNamesModule, 'useResourcePluralNames').mockImplementation(useResourcePluralNamesMock);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should return schema and CRD data on successful load', async () => {
+ const mockCRD = createMockCrd([
+ {
+ name: 'v1alpha1',
+ served: true,
+ storage: true,
+ schema: {
+ openAPIV3Schema: {
+ type: 'object',
+ properties: {
+ spec: {
+ type: 'object',
+ properties: { name: { type: 'string' } },
+ },
+ },
+ },
+ },
+ },
+ ]);
+
+ setupApiResourceMock(mockCRD);
+ setupResourcePluralNamesMock();
+
+ const { result } = renderHook(() =>
+ useCustomResourceDefinitionQuery({
+ kind: 'Workspace',
+ apiGroupName: 'core.openmcp.cloud',
+ apiVersion: 'v1alpha1',
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.crdData).toEqual(mockCRD);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeUndefined();
+ expect(result.current.schema).toBeDefined();
+ expect(result.current.schema).toHaveProperty('type', 'object');
+ });
+ });
+
+ it('should construct the correct API path for the CRD', () => {
+ setupApiResourceMock(undefined, true);
+ setupResourcePluralNamesMock();
+
+ renderHook(() =>
+ useCustomResourceDefinitionQuery({
+ kind: 'Workspace',
+ apiGroupName: 'core.openmcp.cloud',
+ apiVersion: 'v1alpha1',
+ }),
+ );
+
+ expect(useApiResourceMock).toHaveBeenCalledWith(
+ {
+ path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions/workspaces.core.openmcp.cloud',
+ },
+ undefined,
+ undefined,
+ false,
+ );
+ });
+
+ it('should not fetch if the kind is undefined', () => {
+ setupApiResourceMock(undefined);
+ setupResourcePluralNamesMock();
+
+ renderHook(() =>
+ useCustomResourceDefinitionQuery({
+ kind: undefined,
+ apiGroupName: 'core.openmcp.cloud',
+ apiVersion: 'v1alpha1',
+ }),
+ );
+
+ expect(useApiResourceMock).toHaveBeenCalledWith(
+ {
+ path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions/.core.openmcp.cloud',
+ },
+ undefined,
+ undefined,
+ true, // disabled
+ );
+ });
+
+ it('should return undefined schema and data when CRD is not found', () => {
+ setupApiResourceMock(undefined);
+ setupResourcePluralNamesMock();
+
+ const { result } = renderHook(() =>
+ useCustomResourceDefinitionQuery({
+ kind: 'Workspace',
+ apiGroupName: 'core.openmcp.cloud',
+ apiVersion: 'v1alpha1',
+ }),
+ );
+
+ expect(result.current.schema).toBeUndefined();
+ expect(result.current.crdData).toBeUndefined();
+ });
+
+ it('should use the first available version if the specified one is not found', async () => {
+ const mockCRD = createMockCrd([
+ {
+ name: 'v1beta1',
+ served: true,
+ storage: true,
+ schema: {
+ openAPIV3Schema: {
+ type: 'object',
+ properties: {
+ spec: {
+ type: 'object',
+ properties: { fallbackField: { type: 'string' } },
+ },
+ },
+ },
+ },
+ },
+ ]);
+
+ setupApiResourceMock(mockCRD);
+ setupResourcePluralNamesMock();
+
+ const { result } = renderHook(() =>
+ useCustomResourceDefinitionQuery({
+ kind: 'Workspace',
+ apiGroupName: 'core.openmcp.cloud',
+ apiVersion: 'v1alpha1', // This version doesn't exist
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.schema).toBeDefined();
+ expect(result.current.schema?.properties?.spec?.properties).toHaveProperty('fallbackField');
+ });
+ });
+
+ it('should propagate errors from the API call', () => {
+ const mockError = { message: 'Failed to fetch CRD', status: 404 };
+ setupApiResourceMock(undefined, false, mockError);
+ setupResourcePluralNamesMock();
+
+ const { result } = renderHook(() =>
+ useCustomResourceDefinitionQuery({
+ kind: 'Workspace',
+ apiGroupName: 'core.openmcp.cloud',
+ apiVersion: 'v1alpha1',
+ }),
+ );
+
+ expect(result.current.error).toEqual(mockError);
+ expect(result.current.schema).toBeUndefined();
+ });
+
+ it('should select the correct schema for the specified API version', async () => {
+ const mockCRD = createMockCrd([
+ {
+ name: 'v1alpha1',
+ served: true,
+ storage: false,
+ schema: {
+ openAPIV3Schema: {
+ type: 'object',
+ properties: {
+ spec: {
+ type: 'object',
+ properties: { alphaField: { type: 'string' } },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: 'v1beta1',
+ served: true,
+ storage: true,
+ schema: {
+ openAPIV3Schema: {
+ type: 'object',
+ properties: {
+ spec: {
+ type: 'object',
+ properties: { betaField: { type: 'string' } },
+ },
+ },
+ },
+ },
+ },
+ ]);
+
+ setupApiResourceMock(mockCRD);
+ setupResourcePluralNamesMock();
+
+ const { result } = renderHook(() =>
+ useCustomResourceDefinitionQuery({
+ kind: 'Workspace',
+ apiGroupName: 'core.openmcp.cloud',
+ apiVersion: 'v1beta1',
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.schema).toBeDefined();
+ expect(result.current.schema?.properties?.spec?.properties).toHaveProperty('betaField');
+ expect(result.current.schema?.properties?.spec?.properties).not.toHaveProperty('alphaField');
+ });
+ });
+});
diff --git a/src/hooks/useCustomResourceDefinitionQuery.ts b/src/hooks/useCustomResourceDefinitionQuery.ts
new file mode 100644
index 00000000..c666dbd6
--- /dev/null
+++ b/src/hooks/useCustomResourceDefinitionQuery.ts
@@ -0,0 +1,63 @@
+import { useMemo } from 'react';
+import { useApiResource } from '../lib/api/useApiResource.ts';
+import { CustomResourceDefinition } from '../types/customResourceDefinition.ts';
+
+import openapiSchemaToJsonSchema from '@openapi-contrib/openapi-schema-to-json-schema';
+import { APIError } from '../lib/api/error.ts';
+import { useResourcePluralNames } from './useResourcePluralNames.ts';
+
+export interface UseCustomResourceDefinitionQueryParams {
+ kind?: string;
+ apiGroupName: string;
+ apiVersion: string;
+}
+
+export interface UseCustomResourceDefinitionQueryResult {
+ schema: ReturnType | undefined;
+ crdData: CustomResourceDefinition | undefined;
+ isLoading: boolean;
+ error: APIError | undefined;
+}
+
+export function useCustomResourceDefinitionQuery({
+ kind,
+ apiGroupName,
+ apiVersion,
+}: UseCustomResourceDefinitionQueryParams): UseCustomResourceDefinitionQueryResult {
+ const { getPluralKind, isLoading: isGetPluralNameLoading } = useResourcePluralNames();
+ const customResourceDefinitionName = getPluralKind(kind ?? '');
+
+ const {
+ data: crdData,
+ isLoading,
+ error,
+ } = useApiResource(
+ {
+ path: `/apis/apiextensions.k8s.io/v1/customresourcedefinitions/${customResourceDefinitionName}.${apiGroupName}`,
+ },
+ undefined,
+ undefined,
+ !customResourceDefinitionName,
+ );
+
+ const openAPISchema = useMemo(() => {
+ if (!crdData) {
+ return undefined;
+ }
+
+ // Find the schema for the specified API version, or fall back to the first version
+ return (
+ crdData.spec.versions?.find(({ name }) => name === apiVersion)?.schema.openAPIV3Schema ??
+ crdData.spec.versions?.[0]?.schema.openAPIV3Schema
+ );
+ }, [crdData, apiVersion]);
+
+ const schema = useMemo(() => (openAPISchema ? openapiSchemaToJsonSchema(openAPISchema) : undefined), [openAPISchema]);
+
+ return {
+ schema,
+ crdData,
+ isLoading: isLoading || isGetPluralNameLoading,
+ error,
+ };
+}
diff --git a/src/lib/api/types/crate/controlPlanes.ts b/src/lib/api/types/crate/controlPlanes.ts
index 1176614e..7ce612f9 100644
--- a/src/lib/api/types/crate/controlPlanes.ts
+++ b/src/lib/api/types/crate/controlPlanes.ts
@@ -12,6 +12,20 @@ export interface Metadata {
};
}
+export interface Subject {
+ kind: string;
+ name: string;
+}
+
+export interface RoleBinding {
+ role: string;
+ subjects: Subject[];
+}
+
+export interface Authorization {
+ roleBindings: RoleBinding[];
+}
+
export interface ControlPlaneType {
metadata: Metadata;
spec:
@@ -19,6 +33,7 @@ export interface ControlPlaneType {
authentication: {
enableSystemIdentityProvider?: boolean;
};
+ authorization?: Authorization;
components: ControlPlaneComponentsType;
}
| undefined;
@@ -85,6 +100,6 @@ export const ControlPlane = (
): Resource => {
return {
path: `/apis/core.openmcp.cloud/v1alpha1/namespaces/project-${projectName}--ws-${workspaceName}/managedcontrolplanes/${controlPlaneName}`,
- jq: '{ spec: .spec | {components}, metadata: .metadata | {name, namespace, creationTimestamp, annotations}, status: { conditions: [.status.conditions[] | {type: .type, status: .status, message: .message, reason: .reason, lastTransitionTime: .lastTransitionTime}], access: .status.components.authentication.access, status: .status.status }}',
+ jq: '{ spec: .spec | {components, authorization}, metadata: .metadata | {name, namespace, creationTimestamp, annotations}, status: { conditions: [.status.conditions[] | {type: .type, status: .status, message: .message, reason: .reason, lastTransitionTime: .lastTransitionTime}], access: .status.components.authentication.access, status: .status.status }}',
};
};
diff --git a/src/lib/api/types/crate/customResourceDefinitionObject.ts b/src/lib/api/types/crate/customResourceDefinitionObject.ts
new file mode 100644
index 00000000..d14f9e6e
--- /dev/null
+++ b/src/lib/api/types/crate/customResourceDefinitionObject.ts
@@ -0,0 +1,17 @@
+import { Resource } from '../resource';
+import { CustomResourceDefinition } from '../../../../types/customResourceDefinition';
+
+const crdNameMapping = {
+ projects: 'projects.core.openmcp.cloud',
+ workspaces: 'workspaces.core.openmcp.cloud',
+ managedcontrolplanes: 'managedcontrolplanes.core.openmcp.cloud',
+};
+
+export const CustomResourceDefinitionObject = (
+ resourceType: keyof typeof crdNameMapping,
+): Resource => {
+ const crdName = crdNameMapping[resourceType];
+ return {
+ path: `/apis/apiextensions.k8s.io/v1/customresourcedefinitions/${crdName}`,
+ };
+};
diff --git a/src/lib/api/types/k8s/listCustomResourceDefinition.ts b/src/lib/api/types/k8s/listCustomResourceDefinition.ts
deleted file mode 100644
index 2d9881c2..00000000
--- a/src/lib/api/types/k8s/listCustomResourceDefinition.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Resource } from '../resource';
-
-//TODO: typing and jq query
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const CustomResourceDefinitions: Resource = {
- path: '/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
- jq: '[.items[]]',
-};
diff --git a/src/lib/api/useApiResource.ts b/src/lib/api/useApiResource.ts
index ef4ec87e..77cea3ed 100644
--- a/src/lib/api/useApiResource.ts
+++ b/src/lib/api/useApiResource.ts
@@ -13,17 +13,19 @@ import { ProviderConfigs, ProviderConfigsData, ProviderConfigsDataForRequest } f
export const useApiResource = (
resource: Resource,
config?: SWRConfiguration,
- excludeMcpConfig?: boolean,
+ overrideMcpConfig?: ApiConfig['mcpConfig'] | null,
disable?: boolean,
) => {
const apiConfig = useContext(ApiConfigContext);
const { data, error, isLoading, isValidating } = useSWR(
- disable || resource.path === null ? null : [resource.path, apiConfig, excludeMcpConfig],
- ([path, apiConfig, excludeMcpConfig]) =>
+ disable || resource.path === null ? null : [resource.path, apiConfig, overrideMcpConfig],
+ ([path, apiConfig, overrideMcpConfig]) =>
fetchApiServerJson(
path,
- excludeMcpConfig ? { ...apiConfig, mcpConfig: undefined } : apiConfig,
+ overrideMcpConfig === undefined
+ ? apiConfig
+ : { ...apiConfig, mcpConfig: overrideMcpConfig === null ? undefined : overrideMcpConfig },
resource.jq,
resource.method,
resource.body,
diff --git a/src/lib/shared/McpContext.tsx b/src/lib/shared/McpContext.tsx
index 8c46a06d..4c6fcebe 100644
--- a/src/lib/shared/McpContext.tsx
+++ b/src/lib/shared/McpContext.tsx
@@ -1,5 +1,5 @@
import { createContext, ReactNode, useContext } from 'react';
-import { ControlPlane as ManagedControlPlaneResource } from '../api/types/crate/controlPlanes.ts';
+import { ControlPlane as ManagedControlPlaneResource, RoleBinding } from '../api/types/crate/controlPlanes.ts';
import { ApiConfigProvider } from '../../components/Shared/k8s';
import { useApiResource } from '../api/useApiResource.ts';
import { GetKubeconfig } from '../api/types/crate/getKubeconfig.ts';
@@ -15,6 +15,7 @@ interface Mcp {
secretName?: string;
secretKey?: string;
kubeconfig?: string;
+ roleBindings?: RoleBinding[];
}
interface Props {
@@ -44,6 +45,7 @@ export const McpContextProvider = ({ children, context }: Props) => {
return <>>;
}
context.kubeconfig = kubeconfig.data;
+ context.roleBindings = mcp.data?.spec?.authorization?.roleBindings;
return {children};
};
diff --git a/src/spaces/mcp/auth/useHasMcpAdminRights.spec.tsx b/src/spaces/mcp/auth/useHasMcpAdminRights.spec.tsx
new file mode 100644
index 00000000..625a2657
--- /dev/null
+++ b/src/spaces/mcp/auth/useHasMcpAdminRights.spec.tsx
@@ -0,0 +1,223 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useHasMcpAdminRights } from './useHasMcpAdminRights.ts';
+import { useAuthOnboarding } from '../../onboarding/auth/AuthContextOnboarding.tsx';
+import { useMcp } from '../../../lib/shared/McpContext.tsx';
+import type { RoleBinding } from '../../../lib/api/types/crate/controlPlanes.ts';
+
+vi.mock('../../onboarding/auth/AuthContextOnboarding.tsx');
+vi.mock('../../../lib/shared/McpContext.tsx');
+
+const mockedUseAuthOnboarding = vi.mocked(useAuthOnboarding);
+const mockedUseMcp = vi.mocked(useMcp);
+
+// Helper function to create mock auth context
+const mockAuth = (userEmail: string | null | undefined) => {
+ mockedUseAuthOnboarding.mockReturnValue({
+ user: userEmail !== null && userEmail !== undefined ? ({ email: userEmail } as any) : null,
+ isLoading: false,
+ isAuthenticated: userEmail !== null,
+ error: null,
+ login: vi.fn(),
+ logout: vi.fn(),
+ });
+};
+
+// Helper function to create mock MCP context
+const mockMcp = (roleBindings?: RoleBinding[]) => {
+ mockedUseMcp.mockReturnValue({
+ project: 'test-project',
+ workspace: 'test-workspace',
+ name: 'test-mcp',
+ roleBindings,
+ });
+};
+
+describe('useHasMcpAdminRights', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns false when there is no authenticated user', () => {
+ mockAuth(null);
+ mockMcp([]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when user email is missing', () => {
+ mockAuth(undefined);
+ mockMcp([]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when roleBindings is undefined', () => {
+ mockAuth('user@example.com');
+ mockMcp(undefined);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when roleBindings is empty', () => {
+ mockAuth('user@example.com');
+ mockMcp([]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when there is no matching role binding', () => {
+ mockAuth('user@example.com');
+ mockMcp([
+ {
+ subjects: [{ kind: 'User', name: 'other@example.com' }],
+ role: 'admin',
+ },
+ ]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when matching role binding does not have admin role', () => {
+ mockAuth('user@example.com');
+ mockMcp([
+ {
+ subjects: [{ kind: 'User', name: 'user@example.com' }],
+ role: 'viewer',
+ },
+ ]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns true when matching role binding has admin role', () => {
+ mockAuth('user@example.com');
+ mockMcp([
+ {
+ subjects: [{ kind: 'User', name: 'user@example.com' }],
+ role: 'admin',
+ },
+ ]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(true);
+ });
+
+ it('uses partial match on subject name for email', () => {
+ mockAuth('user@example.com');
+ mockMcp([
+ {
+ subjects: [{ kind: 'User', name: 'prefix-user@example.com-suffix' }],
+ role: 'admin',
+ },
+ ]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(true);
+ });
+
+ it('handles missing subjects array safely', () => {
+ mockAuth('user@example.com');
+ mockMcp([
+ {
+ subjects: undefined as any,
+ role: 'admin',
+ },
+ ]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(false);
+ });
+
+ it('returns false when user has viewer role before admin role (find returns first match)', () => {
+ mockAuth('user@example.com');
+ mockMcp([
+ {
+ subjects: [{ kind: 'User', name: 'other@example.com' }],
+ role: 'admin',
+ },
+ {
+ subjects: [{ kind: 'User', name: 'user@example.com' }],
+ role: 'viewer',
+ },
+ {
+ subjects: [{ kind: 'User', name: 'user@example.com' }],
+ role: 'admin',
+ },
+ ]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ // Returns false because .find() stops at the first matching role binding (viewer)
+ expect(result.current).toBe(false);
+ });
+
+ it('returns true when user has admin role as first matching binding', () => {
+ mockAuth('user@example.com');
+ mockMcp([
+ {
+ subjects: [{ kind: 'User', name: 'other@example.com' }],
+ role: 'viewer',
+ },
+ {
+ subjects: [{ kind: 'User', name: 'user@example.com' }],
+ role: 'admin',
+ },
+ {
+ subjects: [{ kind: 'User', name: 'user@example.com' }],
+ role: 'viewer',
+ },
+ ]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(true);
+ });
+
+ it('handles multiple subjects in a single role binding', () => {
+ mockAuth('user@example.com');
+ mockMcp([
+ {
+ subjects: [
+ { kind: 'User', name: 'other@example.com' },
+ { kind: 'User', name: 'user@example.com' },
+ { kind: 'User', name: 'another@example.com' },
+ ],
+ role: 'admin',
+ },
+ ]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(true);
+ });
+
+ it('handles null subject name safely', () => {
+ mockAuth('user@example.com');
+ mockMcp([
+ {
+ subjects: [{ kind: 'User', name: null as any }],
+ role: 'admin',
+ },
+ ]);
+
+ const { result } = renderHook(() => useHasMcpAdminRights());
+
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/src/spaces/mcp/auth/useHasMcpAdminRights.ts b/src/spaces/mcp/auth/useHasMcpAdminRights.ts
new file mode 100644
index 00000000..46fbc6f9
--- /dev/null
+++ b/src/spaces/mcp/auth/useHasMcpAdminRights.ts
@@ -0,0 +1,20 @@
+import { useAuthOnboarding } from '../../onboarding/auth/AuthContextOnboarding.tsx';
+import { useMcp } from '../../../lib/shared/McpContext.tsx';
+
+export function useHasMcpAdminRights(): boolean {
+ const auth = useAuthOnboarding();
+ const mcp = useMcp();
+ const userEmail = auth.user?.email;
+ const mcpUsers = mcp.roleBindings ?? [];
+
+ if (!userEmail) {
+ return false;
+ }
+
+ const matchingRoleBinding = mcpUsers.find(
+ (roleBinding) =>
+ Array.isArray(roleBinding.subjects) && roleBinding.subjects.some((subject) => subject?.name?.includes(userEmail)),
+ );
+
+ return matchingRoleBinding?.role === 'admin';
+}
diff --git a/src/spaces/mcp/pages/McpPage.tsx b/src/spaces/mcp/pages/McpPage.tsx
index b33e3bde..4086215a 100644
--- a/src/spaces/mcp/pages/McpPage.tsx
+++ b/src/spaces/mcp/pages/McpPage.tsx
@@ -116,6 +116,7 @@ export default function McpPage() {
workspaceName={mcp?.status?.access?.namespace}
resourceType={'managedcontrolplanes'}
resourceName={controlPlaneName}
+ withoutApiConfig
/>
;
+ subresource?: string;
+ }[];
+ };
+ spec: {
+ group: string;
+ names: {
+ plural: string;
+ singular: string;
+ shortNames?: string[];
+ kind: string;
+ listKind: string;
+ };
+ scope: 'Namespaced' | 'Cluster';
+ versions: {
+ name: string;
+ served: boolean;
+ storage: boolean;
+ schema: {
+ openAPIV3Schema: JSONSchemaProps;
+ };
+ subresources?: {
+ status?: Record;
+ scale?: Record;
+ };
+ additionalPrinterColumns?: {
+ name: string;
+ type: string;
+ jsonPath: string;
+ description?: string;
+ format?: string;
+ priority?: number;
+ }[];
+ }[];
+ conversion: {
+ strategy: 'None' | 'Webhook';
+ webhook?: Record;
+ };
+ };
+ status?: {
+ conditions?: {
+ type: string;
+ status: 'True' | 'False' | 'Unknown';
+ lastTransitionTime: string;
+ reason: string;
+ message: string;
+ }[];
+ acceptedNames?: {
+ plural: string;
+ singular: string;
+ shortNames?: string[];
+ kind: string;
+ listKind: string;
+ };
+ storedVersions?: string[];
+ };
+}
+
+// JSON Schema Props interface for OpenAPI V3 Schema
+export interface JSONSchemaProps {
+ description?: string;
+ type?: string;
+ format?: string;
+ title?: string;
+ default?: unknown;
+ maximum?: number;
+ exclusiveMaximum?: boolean;
+ minimum?: number;
+ exclusiveMinimum?: boolean;
+ maxLength?: number;
+ minLength?: number;
+ pattern?: string;
+ maxItems?: number;
+ minItems?: number;
+ uniqueItems?: boolean;
+ maxProperties?: number;
+ minProperties?: number;
+ required?: string[];
+ enum?: unknown[];
+ properties?: Record;
+ additionalProperties?: boolean | JSONSchemaProps;
+ items?: JSONSchemaProps | JSONSchemaProps[];
+ allOf?: JSONSchemaProps[];
+ oneOf?: JSONSchemaProps[];
+ anyOf?: JSONSchemaProps[];
+ not?: JSONSchemaProps;
+ nullable?: boolean;
+ 'x-kubernetes-preserve-unknown-fields'?: boolean;
+ 'x-kubernetes-validations'?: {
+ rule: string;
+ message: string;
+ messageExpression?: string;
+ reason?: string;
+ fieldPath?: string;
+ }[];
+ 'x-kubernetes-embedded-resource'?: boolean;
+ 'x-kubernetes-int-or-string'?: boolean;
+ 'x-kubernetes-list-map-keys'?: string[];
+ 'x-kubernetes-list-type'?: 'atomic' | 'set' | 'map';
+ 'x-kubernetes-map-type'?: 'atomic' | 'granular';
+}
diff --git a/src/utils/parseResourceApiInfo.ts b/src/utils/parseResourceApiInfo.ts
new file mode 100644
index 00000000..e2946948
--- /dev/null
+++ b/src/utils/parseResourceApiInfo.ts
@@ -0,0 +1,22 @@
+import { Resource } from './removeManagedFieldsAndFilterData.ts';
+
+export interface ResourceApiInfo {
+ apiGroupName: string;
+ apiVersion: string;
+ kind: string;
+}
+
+export function parseResourceApiInfo(resource: Resource): ResourceApiInfo {
+ const apiVersionString = resource?.apiVersion ?? 'core.openmcp.cloud/v1alpha1';
+ const [apiGroupName, apiVersion] = apiVersionString.includes('/')
+ ? apiVersionString.split('/')
+ : ['core', apiVersionString];
+
+ const kind = resource?.kind ?? '';
+
+ return {
+ apiGroupName,
+ apiVersion,
+ kind,
+ };
+}
diff --git a/vite.config.js b/vite.config.js
index b824bac3..e0de6691 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -30,6 +30,21 @@ export default defineConfig({
}),
],
+ resolve: {
+ alias: {
+ path: 'path-browserify',
+ },
+ },
+
+ optimizeDeps: {
+ include: ['path-browserify'],
+ esbuildOptions: {
+ define: {
+ global: 'globalThis',
+ },
+ },
+ },
+
test: {
environment: 'jsdom',
},
@@ -37,5 +52,9 @@ export default defineConfig({
build: {
sourcemap: true, // crucial for sentry
target: 'esnext', // Support top-level await
+ commonjsOptions: {
+ include: [/path-browserify/, /node_modules/],
+ transformMixedEsModules: true,
+ },
},
});