first pass at access control setup

This commit is contained in:
2026-03-24 14:45:07 -05:00
parent af5175b96a
commit 2ebb03b868
105 changed files with 6163 additions and 1416 deletions

View File

@@ -47,6 +47,15 @@ const TriggerCreatePage = lazy(
);
const TriggerEditPage = lazy(() => import("@/pages/triggers/TriggerEditPage"));
const SensorsPage = lazy(() => import("@/pages/sensors/SensorsPage"));
const AccessControlPage = lazy(
() => import("@/pages/access-control/AccessControlPage"),
);
const IdentityDetailPage = lazy(
() => import("@/pages/access-control/IdentityDetailPage"),
);
const PermissionSetDetailPage = lazy(
() => import("@/pages/access-control/PermissionSetDetailPage"),
);
function PageLoader() {
return (
@@ -134,6 +143,18 @@ function App() {
/>
<Route path="sensors" element={<SensorsPage />} />
<Route path="sensors/:ref" element={<SensorsPage />} />
<Route
path="access-control"
element={<AccessControlPage />}
/>
<Route
path="access-control/identities/:id"
element={<IdentityDetailPage />}
/>
<Route
path="access-control/permission-sets/:ref"
element={<PermissionSetDetailPage />}
/>
</Route>
{/* Catch all - redirect to dashboard */}

View File

@@ -9,12 +9,15 @@ export type { OpenAPIConfig } from './core/OpenAPI';
export type { ActionResponse } from './models/ActionResponse';
export type { ActionSummary } from './models/ActionSummary';
export type { AgentArchInfo } from './models/AgentArchInfo';
export type { AgentBinaryInfo } from './models/AgentBinaryInfo';
export type { ApiResponse_ActionResponse } from './models/ApiResponse_ActionResponse';
export type { ApiResponse_AuthSettingsResponse } from './models/ApiResponse_AuthSettingsResponse';
export type { ApiResponse_CurrentUserResponse } from './models/ApiResponse_CurrentUserResponse';
export type { ApiResponse_EnforcementResponse } from './models/ApiResponse_EnforcementResponse';
export type { ApiResponse_EventResponse } from './models/ApiResponse_EventResponse';
export type { ApiResponse_ExecutionResponse } from './models/ApiResponse_ExecutionResponse';
export type { ApiResponse_IdentitySummary } from './models/ApiResponse_IdentitySummary';
export type { ApiResponse_IdentityResponse } from './models/ApiResponse_IdentityResponse';
export type { ApiResponse_InquiryResponse } from './models/ApiResponse_InquiryResponse';
export type { ApiResponse_KeyResponse } from './models/ApiResponse_KeyResponse';
export type { ApiResponse_PackInstallResponse } from './models/ApiResponse_PackInstallResponse';
@@ -32,10 +35,12 @@ export type { ApiResponse_WorkflowResponse } from './models/ApiResponse_Workflow
export type { ChangePasswordRequest } from './models/ChangePasswordRequest';
export type { CreateActionRequest } from './models/CreateActionRequest';
export type { CreateIdentityRequest } from './models/CreateIdentityRequest';
export type { CreateIdentityRoleAssignmentRequest } from './models/CreateIdentityRoleAssignmentRequest';
export type { CreateInquiryRequest } from './models/CreateInquiryRequest';
export type { CreateKeyRequest } from './models/CreateKeyRequest';
export type { CreatePackRequest } from './models/CreatePackRequest';
export type { CreatePermissionAssignmentRequest } from './models/CreatePermissionAssignmentRequest';
export type { CreatePermissionSetRoleAssignmentRequest } from './models/CreatePermissionSetRoleAssignmentRequest';
export type { CreateRuleRequest } from './models/CreateRuleRequest';
export type { CreateRuntimeRequest } from './models/CreateRuntimeRequest';
export type { CreateSensorRequest } from './models/CreateSensorRequest';
@@ -53,6 +58,8 @@ export { ExecutionStatus } from './models/ExecutionStatus';
export type { ExecutionSummary } from './models/ExecutionSummary';
export type { HealthResponse } from './models/HealthResponse';
export type { i64 } from './models/i64';
export type { IdentityResponse } from './models/IdentityResponse';
export type { IdentityRoleAssignmentResponse } from './models/IdentityRoleAssignmentResponse';
export type { IdentitySummary } from './models/IdentitySummary';
export type { InquiryRespondRequest } from './models/InquiryRespondRequest';
export type { InquiryResponse } from './models/InquiryResponse';
@@ -61,10 +68,12 @@ export type { InquirySummary } from './models/InquirySummary';
export type { InstallPackRequest } from './models/InstallPackRequest';
export type { KeyResponse } from './models/KeyResponse';
export type { KeySummary } from './models/KeySummary';
export type { LdapLoginRequest } from './models/LdapLoginRequest';
export type { LoginRequest } from './models/LoginRequest';
export { NullableJsonPatch } from './models/NullableJsonPatch';
export { NullableStringPatch } from './models/NullableStringPatch';
export { OwnerType } from './models/OwnerType';
export { PackDescriptionPatch } from './models/PackDescriptionPatch';
export type { PackInstallResponse } from './models/PackInstallResponse';
export type { PackResponse } from './models/PackResponse';
export type { PackSummary } from './models/PackSummary';
@@ -89,6 +98,7 @@ export type { PaginatedResponse_TriggerSummary } from './models/PaginatedRespons
export type { PaginatedResponse_WorkflowSummary } from './models/PaginatedResponse_WorkflowSummary';
export type { PaginationMeta } from './models/PaginationMeta';
export type { PermissionAssignmentResponse } from './models/PermissionAssignmentResponse';
export type { PermissionSetRoleAssignmentResponse } from './models/PermissionSetRoleAssignmentResponse';
export type { PermissionSetSummary } from './models/PermissionSetSummary';
export type { QueueStatsResponse } from './models/QueueStatsResponse';
export type { RefreshTokenRequest } from './models/RefreshTokenRequest';
@@ -98,6 +108,7 @@ export type { RuleResponse } from './models/RuleResponse';
export type { RuleSummary } from './models/RuleSummary';
export type { RuntimeResponse } from './models/RuntimeResponse';
export type { RuntimeSummary } from './models/RuntimeSummary';
export { RuntimeVersionConstraintPatch } from './models/RuntimeVersionConstraintPatch';
export type { SensorResponse } from './models/SensorResponse';
export type { SensorSummary } from './models/SensorSummary';
export type { SuccessResponse } from './models/SuccessResponse';
@@ -106,6 +117,7 @@ export { TestStatus } from './models/TestStatus';
export type { TestSuiteResult } from './models/TestSuiteResult';
export type { TokenResponse } from './models/TokenResponse';
export type { TriggerResponse } from './models/TriggerResponse';
export { TriggerStringPatch } from './models/TriggerStringPatch';
export type { TriggerSummary } from './models/TriggerSummary';
export type { UpdateActionRequest } from './models/UpdateActionRequest';
export type { UpdateIdentityRequest } from './models/UpdateIdentityRequest';
@@ -126,6 +138,7 @@ export type { WorkflowSummary } from './models/WorkflowSummary';
export type { WorkflowSyncResult } from './models/WorkflowSyncResult';
export { ActionsService } from './services/ActionsService';
export { AgentService } from './services/AgentService';
export { AuthService } from './services/AuthService';
export { EnforcementsService } from './services/EnforcementsService';
export { EventsService } from './services/EventsService';

View File

@@ -6,65 +6,64 @@
* Response DTO for action information
*/
export type ActionResponse = {
/**
* Creation timestamp
*/
created: string;
/**
* Action description
*/
description: string;
/**
* Entry point
*/
entrypoint: string;
/**
* Action ID
*/
id: number;
/**
* Whether this is an ad-hoc action (not from pack installation)
*/
is_adhoc: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Output schema
*/
out_schema: any | null;
/**
* Pack ID
*/
pack: number;
/**
* Pack reference
*/
pack_ref: string;
/**
* Parameter schema (StackStorm-style with inline required/secret)
*/
param_schema: any | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime?: number | null;
/**
* Semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0")
*/
runtime_version_constraint?: string | null;
/**
* Last update timestamp
*/
updated: string;
/**
* Workflow definition ID (non-null if this action is a workflow)
*/
workflow_def?: number | null;
/**
* Creation timestamp
*/
created: string;
/**
* Action description
*/
description: string | null;
/**
* Entry point
*/
entrypoint: string;
/**
* Action ID
*/
id: number;
/**
* Whether this is an ad-hoc action (not from pack installation)
*/
is_adhoc: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Output schema
*/
out_schema: any | null;
/**
* Pack ID
*/
pack: number;
/**
* Pack reference
*/
pack_ref: string;
/**
* Parameter schema (StackStorm-style with inline required/secret)
*/
param_schema: any | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime?: number | null;
/**
* Semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0")
*/
runtime_version_constraint?: string | null;
/**
* Last update timestamp
*/
updated: string;
/**
* Workflow definition ID (non-null if this action is a workflow)
*/
workflow_def?: number | null;
};

View File

@@ -6,49 +6,48 @@
* Simplified action response (for list endpoints)
*/
export type ActionSummary = {
/**
* Creation timestamp
*/
created: string;
/**
* Action description
*/
description: string;
/**
* Entry point
*/
entrypoint: string;
/**
* Action ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime?: number | null;
/**
* Semver version constraint for the runtime
*/
runtime_version_constraint?: string | null;
/**
* Last update timestamp
*/
updated: string;
/**
* Workflow definition ID (non-null if this action is a workflow)
*/
workflow_def?: number | null;
/**
* Creation timestamp
*/
created: string;
/**
* Action description
*/
description: string | null;
/**
* Entry point
*/
entrypoint: string;
/**
* Action ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime?: number | null;
/**
* Semver version constraint for the runtime
*/
runtime_version_constraint?: string | null;
/**
* Last update timestamp
*/
updated: string;
/**
* Workflow definition ID (non-null if this action is a workflow)
*/
workflow_def?: number | null;
};

View File

@@ -0,0 +1,22 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Per-architecture binary info
*/
export type AgentArchInfo = {
/**
* Architecture name
*/
arch: string;
/**
* Whether this binary is available
*/
available: boolean;
/**
* Binary size in bytes
*/
size_bytes: number;
};

View File

@@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AgentArchInfo } from './AgentArchInfo';
/**
* Agent binary metadata
*/
export type AgentBinaryInfo = {
/**
* Available architectures
*/
architectures: Array<AgentArchInfo>;
/**
* Agent version (from build)
*/
version: string;
};

View File

@@ -6,74 +6,73 @@
* Standard API response wrapper
*/
export type ApiResponse_ActionResponse = {
/**
* Response DTO for action information
*/
data: {
/**
* Response DTO for action information
* Creation timestamp
*/
data: {
/**
* Creation timestamp
*/
created: string;
/**
* Action description
*/
description: string;
/**
* Entry point
*/
entrypoint: string;
/**
* Action ID
*/
id: number;
/**
* Whether this is an ad-hoc action (not from pack installation)
*/
is_adhoc: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Output schema
*/
out_schema: any | null;
/**
* Pack ID
*/
pack: number;
/**
* Pack reference
*/
pack_ref: string;
/**
* Parameter schema (StackStorm-style with inline required/secret)
*/
param_schema: any | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime?: number | null;
/**
* Semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0")
*/
runtime_version_constraint?: string | null;
/**
* Last update timestamp
*/
updated: string;
/**
* Workflow definition ID (non-null if this action is a workflow)
*/
workflow_def?: number | null;
};
created: string;
/**
* Optional message
* Action description
*/
message?: string | null;
description: string | null;
/**
* Entry point
*/
entrypoint: string;
/**
* Action ID
*/
id: number;
/**
* Whether this is an ad-hoc action (not from pack installation)
*/
is_adhoc: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Output schema
*/
out_schema: any | null;
/**
* Pack ID
*/
pack: number;
/**
* Pack reference
*/
pack_ref: string;
/**
* Parameter schema (StackStorm-style with inline required/secret)
*/
param_schema: any | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime?: number | null;
/**
* Semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0")
*/
runtime_version_constraint?: string | null;
/**
* Last update timestamp
*/
updated: string;
/**
* Workflow definition ID (non-null if this action is a workflow)
*/
workflow_def?: number | null;
};
/**
* Optional message
*/
message?: string | null;
};

View File

@@ -0,0 +1,75 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Standard API response wrapper
*/
export type ApiResponse_AuthSettingsResponse = {
/**
* Public authentication settings for the login page.
*/
data: {
/**
* Whether authentication is enabled for the server.
*/
authentication_enabled: boolean;
/**
* Whether LDAP login is configured and enabled.
*/
ldap_enabled: boolean;
/**
* Optional icon URL shown beside the provider label.
*/
ldap_provider_icon_url?: string | null;
/**
* User-facing provider label for the login button.
*/
ldap_provider_label?: string | null;
/**
* Provider name for `?auth=<provider>`.
*/
ldap_provider_name?: string | null;
/**
* Whether LDAP login should be shown by default.
*/
ldap_visible_by_default: boolean;
/**
* Whether local username/password login is configured.
*/
local_password_enabled: boolean;
/**
* Whether local username/password login should be shown by default.
*/
local_password_visible_by_default: boolean;
/**
* Whether OIDC login is configured and enabled.
*/
oidc_enabled: boolean;
/**
* Optional icon URL shown beside the provider label.
*/
oidc_provider_icon_url?: string | null;
/**
* User-facing provider label for the login button.
*/
oidc_provider_label?: string | null;
/**
* Provider name for `?auth=<provider>`.
*/
oidc_provider_name?: string | null;
/**
* Whether OIDC login should be shown by default.
*/
oidc_visible_by_default: boolean;
/**
* Whether unauthenticated self-service registration is allowed.
*/
self_registration_enabled: boolean;
};
/**
* Optional message
*/
message?: string | null;
};

View File

@@ -2,16 +2,21 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { IdentityRoleAssignmentResponse } from './IdentityRoleAssignmentResponse';
import type { PermissionAssignmentResponse } from './PermissionAssignmentResponse';
import type { Value } from './Value';
/**
* Standard API response wrapper
*/
export type ApiResponse_IdentitySummary = {
export type ApiResponse_IdentityResponse = {
data: {
attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null;
frozen: boolean;
id: number;
login: string;
roles: Array<IdentityRoleAssignmentResponse>;
};
/**
* Optional message

View File

@@ -6,82 +6,81 @@
* Standard API response wrapper
*/
export type ApiResponse_RuleResponse = {
/**
* Response DTO for rule information
*/
data: {
/**
* Response DTO for rule information
* Action ID (null if the referenced action has been deleted)
*/
data: {
/**
* Action ID (null if the referenced action has been deleted)
*/
action?: number | null;
/**
* Parameters to pass to the action when rule is triggered
*/
action_params: Record<string, any>;
/**
* Action reference
*/
action_ref: string;
/**
* Conditions for rule evaluation
*/
conditions: Record<string, any>;
/**
* Creation timestamp
*/
created: string;
/**
* Rule description
*/
description: string;
/**
* Whether the rule is enabled
*/
enabled: boolean;
/**
* Rule ID
*/
id: number;
/**
* Whether this is an ad-hoc rule (not from pack installation)
*/
is_adhoc: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Pack ID
*/
pack: number;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Trigger ID (null if the referenced trigger has been deleted)
*/
trigger?: number | null;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params: Record<string, any>;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
};
action?: number | null;
/**
* Optional message
* Parameters to pass to the action when rule is triggered
*/
message?: string | null;
action_params: Record<string, any>;
/**
* Action reference
*/
action_ref: string;
/**
* Conditions for rule evaluation
*/
conditions: Record<string, any>;
/**
* Creation timestamp
*/
created: string;
/**
* Rule description
*/
description: string | null;
/**
* Whether the rule is enabled
*/
enabled: boolean;
/**
* Rule ID
*/
id: number;
/**
* Whether this is an ad-hoc rule (not from pack installation)
*/
is_adhoc: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Pack ID
*/
pack: number;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Trigger ID (null if the referenced trigger has been deleted)
*/
trigger?: number | null;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params: Record<string, any>;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
};
/**
* Optional message
*/
message?: string | null;
};

View File

@@ -6,74 +6,73 @@
* Standard API response wrapper
*/
export type ApiResponse_SensorResponse = {
/**
* Response DTO for sensor information
*/
data: {
/**
* Response DTO for sensor information
* Creation timestamp
*/
data: {
/**
* Creation timestamp
*/
created: string;
/**
* Sensor description
*/
description: string;
/**
* Whether the sensor is enabled
*/
enabled: boolean;
/**
* Entry point
*/
entrypoint: string;
/**
* Sensor ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack ID (optional)
*/
pack?: number | null;
/**
* Pack reference (optional)
*/
pack_ref?: string | null;
/**
* Parameter schema (StackStorm-style with inline required/secret)
*/
param_schema: any | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime: number;
/**
* Runtime reference
*/
runtime_ref: string;
/**
* Trigger ID
*/
trigger: number;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
};
created: string;
/**
* Optional message
* Sensor description
*/
message?: string | null;
description: string | null;
/**
* Whether the sensor is enabled
*/
enabled: boolean;
/**
* Entry point
*/
entrypoint: string;
/**
* Sensor ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack ID (optional)
*/
pack?: number | null;
/**
* Pack reference (optional)
*/
pack_ref?: string | null;
/**
* Parameter schema (StackStorm-style with inline required/secret)
*/
param_schema: any | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime: number;
/**
* Runtime reference
*/
runtime_ref: string;
/**
* Trigger ID
*/
trigger: number;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
};
/**
* Optional message
*/
message?: string | null;
};

View File

@@ -6,41 +6,40 @@
* Request DTO for creating a new action
*/
export type CreateActionRequest = {
/**
* Action description
*/
description: string;
/**
* Entry point for action execution (e.g., path to script, function name)
*/
entrypoint: string;
/**
* Human-readable label
*/
label: string;
/**
* Output schema (flat format) defining expected outputs with inline required/secret
*/
out_schema?: any | null;
/**
* Pack reference this action belongs to
*/
pack_ref: string;
/**
* Parameter schema (StackStorm-style) defining expected inputs with inline required/secret
*/
param_schema?: any | null;
/**
* Unique reference identifier (e.g., "core.http", "aws.ec2.start_instance")
*/
ref: string;
/**
* Optional runtime ID for this action
*/
runtime?: number | null;
/**
* Optional semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0")
*/
runtime_version_constraint?: string | null;
/**
* Action description
*/
description?: string | null;
/**
* Entry point for action execution (e.g., path to script, function name)
*/
entrypoint: string;
/**
* Human-readable label
*/
label: string;
/**
* Output schema (flat format) defining expected outputs with inline required/secret
*/
out_schema?: any | null;
/**
* Pack reference this action belongs to
*/
pack_ref: string;
/**
* Parameter schema (StackStorm-style) defining expected inputs with inline required/secret
*/
param_schema?: any | null;
/**
* Unique reference identifier (e.g., "core.http", "aws.ec2.start_instance")
*/
ref: string;
/**
* Optional runtime ID for this action
*/
runtime?: number | null;
/**
* Optional semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0")
*/
runtime_version_constraint?: string | null;
};

View File

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreateIdentityRoleAssignmentRequest = {
role: string;
};

View File

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreatePermissionSetRoleAssignmentRequest = {
role: string;
};

View File

@@ -6,45 +6,44 @@
* Request DTO for creating a new rule
*/
export type CreateRuleRequest = {
/**
* Parameters to pass to the action when rule is triggered
*/
action_params?: Record<string, any>;
/**
* Action reference to execute when rule matches
*/
action_ref: string;
/**
* Conditions for rule evaluation (JSON Logic or custom format)
*/
conditions?: Record<string, any>;
/**
* Rule description
*/
description: string;
/**
* Whether the rule is enabled
*/
enabled?: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference this rule belongs to
*/
pack_ref: string;
/**
* Unique reference identifier (e.g., "mypack.notify_on_error")
*/
ref: string;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params?: Record<string, any>;
/**
* Trigger reference that activates this rule
*/
trigger_ref: string;
/**
* Parameters to pass to the action when rule is triggered
*/
action_params?: Record<string, any>;
/**
* Action reference to execute when rule matches
*/
action_ref: string;
/**
* Conditions for rule evaluation (JSON Logic or custom format)
*/
conditions?: Record<string, any>;
/**
* Rule description
*/
description?: string | null;
/**
* Whether the rule is enabled
*/
enabled?: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference this rule belongs to
*/
pack_ref: string;
/**
* Unique reference identifier (e.g., "mypack.notify_on_error")
*/
ref: string;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params?: Record<string, any>;
/**
* Trigger reference that activates this rule
*/
trigger_ref: string;
};

View File

@@ -6,45 +6,44 @@
* Request DTO for creating a new sensor
*/
export type CreateSensorRequest = {
/**
* Configuration values for this sensor instance (conforms to param_schema)
*/
config?: any | null;
/**
* Sensor description
*/
description: string;
/**
* Whether the sensor is enabled
*/
enabled?: boolean;
/**
* Entry point for sensor execution (e.g., path to script, function name)
*/
entrypoint: string;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference this sensor belongs to
*/
pack_ref: string;
/**
* Parameter schema (flat format) for sensor configuration
*/
param_schema?: any | null;
/**
* Unique reference identifier (e.g., "mypack.cpu_monitor")
*/
ref: string;
/**
* Runtime reference for this sensor
*/
runtime_ref: string;
/**
* Trigger reference this sensor monitors for
*/
trigger_ref: string;
/**
* Configuration values for this sensor instance (conforms to param_schema)
*/
config?: any | null;
/**
* Sensor description
*/
description?: string | null;
/**
* Whether the sensor is enabled
*/
enabled?: boolean;
/**
* Entry point for sensor execution (e.g., path to script, function name)
*/
entrypoint: string;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference this sensor belongs to
*/
pack_ref: string;
/**
* Parameter schema (flat format) for sensor configuration
*/
param_schema?: any | null;
/**
* Unique reference identifier (e.g., "mypack.cpu_monitor")
*/
ref: string;
/**
* Runtime reference for this sensor
*/
runtime_ref: string;
/**
* Trigger reference this sensor monitors for
*/
trigger_ref: string;
};

View File

@@ -0,0 +1,17 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { IdentityRoleAssignmentResponse } from './IdentityRoleAssignmentResponse';
import type { PermissionAssignmentResponse } from './PermissionAssignmentResponse';
import type { Value } from './Value';
export type IdentityResponse = {
attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null;
frozen: boolean;
id: number;
login: string;
roles: Array<IdentityRoleAssignmentResponse>;
};

View File

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type IdentityRoleAssignmentResponse = {
created: string;
id: number;
identity_id: number;
managed: boolean;
role: string;
source: string;
updated: string;
};

View File

@@ -6,7 +6,9 @@ import type { Value } from './Value';
export type IdentitySummary = {
attributes: Value;
display_name?: string | null;
frozen: boolean;
id: number;
login: string;
roles: Array<string>;
};

View File

@@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Request body for LDAP login.
*/
export type LdapLoginRequest = {
/**
* User login name (uid, sAMAccountName, etc.)
*/
login: string;
/**
* User password
*/
password: string;
};

View File

@@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PackDescriptionPatch =
| {
op: PackDescriptionPatch.op;
value: string;
}
| {
op: PackDescriptionPatch.op;
};
export namespace PackDescriptionPatch {
export enum op {
SET = "set",
CLEAR = "clear",
}
}

View File

@@ -2,63 +2,62 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PaginationMeta } from './PaginationMeta';
import type { PaginationMeta } from "./PaginationMeta";
/**
* Paginated response wrapper
*/
export type PaginatedResponse_ActionSummary = {
/**
* The data items
*/
data: Array<{
/**
* The data items
* Creation timestamp
*/
data: Array<{
/**
* Creation timestamp
*/
created: string;
/**
* Action description
*/
description: string;
/**
* Entry point
*/
entrypoint: string;
/**
* Action ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime?: number | null;
/**
* Semver version constraint for the runtime
*/
runtime_version_constraint?: string | null;
/**
* Last update timestamp
*/
updated: string;
/**
* Workflow definition ID (non-null if this action is a workflow)
*/
workflow_def?: number | null;
}>;
created: string;
/**
* Pagination metadata
* Action description
*/
pagination: PaginationMeta;
description: string | null;
/**
* Entry point
*/
entrypoint: string;
/**
* Action ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime?: number | null;
/**
* Semver version constraint for the runtime
*/
runtime_version_constraint?: string | null;
/**
* Last update timestamp
*/
updated: string;
/**
* Workflow definition ID (non-null if this action is a workflow)
*/
workflow_def?: number | null;
}>;
/**
* Pagination metadata
*/
pagination: PaginationMeta;
};

View File

@@ -14,8 +14,10 @@ export type PaginatedResponse_IdentitySummary = {
data: Array<{
attributes: Value;
display_name?: string | null;
frozen: boolean;
id: number;
login: string;
roles: Array<string>;
}>;
/**
* Pagination metadata

View File

@@ -2,67 +2,66 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PaginationMeta } from './PaginationMeta';
import type { PaginationMeta } from "./PaginationMeta";
/**
* Paginated response wrapper
*/
export type PaginatedResponse_RuleSummary = {
/**
* The data items
*/
data: Array<{
/**
* The data items
* Parameters to pass to the action when rule is triggered
*/
data: Array<{
/**
* Parameters to pass to the action when rule is triggered
*/
action_params: Record<string, any>;
/**
* Action reference
*/
action_ref: string;
/**
* Creation timestamp
*/
created: string;
/**
* Rule description
*/
description: string;
/**
* Whether the rule is enabled
*/
enabled: boolean;
/**
* Rule ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params: Record<string, any>;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
}>;
action_params: Record<string, any>;
/**
* Pagination metadata
* Action reference
*/
pagination: PaginationMeta;
action_ref: string;
/**
* Creation timestamp
*/
created: string;
/**
* Rule description
*/
description: string | null;
/**
* Whether the rule is enabled
*/
enabled: boolean;
/**
* Rule ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params: Record<string, any>;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
}>;
/**
* Pagination metadata
*/
pagination: PaginationMeta;
};

View File

@@ -2,55 +2,54 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PaginationMeta } from './PaginationMeta';
import type { PaginationMeta } from "./PaginationMeta";
/**
* Paginated response wrapper
*/
export type PaginatedResponse_SensorSummary = {
/**
* The data items
*/
data: Array<{
/**
* The data items
* Creation timestamp
*/
data: Array<{
/**
* Creation timestamp
*/
created: string;
/**
* Sensor description
*/
description: string;
/**
* Whether the sensor is enabled
*/
enabled: boolean;
/**
* Sensor ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference (optional)
*/
pack_ref?: string | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
}>;
created: string;
/**
* Pagination metadata
* Sensor description
*/
pagination: PaginationMeta;
description: string | null;
/**
* Whether the sensor is enabled
*/
enabled: boolean;
/**
* Sensor ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference (optional)
*/
pack_ref?: string | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
}>;
/**
* Pagination metadata
*/
pagination: PaginationMeta;
};

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PermissionSetRoleAssignmentResponse = {
created: string;
id: number;
permission_set_id: number;
permission_set_ref?: string | null;
role: string;
};

View File

@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PermissionSetRoleAssignmentResponse } from './PermissionSetRoleAssignmentResponse';
import type { Value } from './Value';
export type PermissionSetSummary = {
description?: string | null;
@@ -10,5 +11,6 @@ export type PermissionSetSummary = {
label?: string | null;
pack_ref?: string | null;
ref: string;
roles: Array<PermissionSetRoleAssignmentResponse>;
};

View File

@@ -6,73 +6,72 @@
* Response DTO for rule information
*/
export type RuleResponse = {
/**
* Action ID (null if the referenced action has been deleted)
*/
action?: number | null;
/**
* Parameters to pass to the action when rule is triggered
*/
action_params: Record<string, any>;
/**
* Action reference
*/
action_ref: string;
/**
* Conditions for rule evaluation
*/
conditions: Record<string, any>;
/**
* Creation timestamp
*/
created: string;
/**
* Rule description
*/
description: string;
/**
* Whether the rule is enabled
*/
enabled: boolean;
/**
* Rule ID
*/
id: number;
/**
* Whether this is an ad-hoc rule (not from pack installation)
*/
is_adhoc: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Pack ID
*/
pack: number;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Trigger ID (null if the referenced trigger has been deleted)
*/
trigger?: number | null;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params: Record<string, any>;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
/**
* Action ID (null if the referenced action has been deleted)
*/
action?: number | null;
/**
* Parameters to pass to the action when rule is triggered
*/
action_params: Record<string, any>;
/**
* Action reference
*/
action_ref: string;
/**
* Conditions for rule evaluation
*/
conditions: Record<string, any>;
/**
* Creation timestamp
*/
created: string;
/**
* Rule description
*/
description: string | null;
/**
* Whether the rule is enabled
*/
enabled: boolean;
/**
* Rule ID
*/
id: number;
/**
* Whether this is an ad-hoc rule (not from pack installation)
*/
is_adhoc: boolean;
/**
* Human-readable label
*/
label: string;
/**
* Pack ID
*/
pack: number;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Trigger ID (null if the referenced trigger has been deleted)
*/
trigger?: number | null;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params: Record<string, any>;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
};

View File

@@ -6,53 +6,52 @@
* Simplified rule response (for list endpoints)
*/
export type RuleSummary = {
/**
* Parameters to pass to the action when rule is triggered
*/
action_params: Record<string, any>;
/**
* Action reference
*/
action_ref: string;
/**
* Creation timestamp
*/
created: string;
/**
* Rule description
*/
description: string;
/**
* Whether the rule is enabled
*/
enabled: boolean;
/**
* Rule ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params: Record<string, any>;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
/**
* Parameters to pass to the action when rule is triggered
*/
action_params: Record<string, any>;
/**
* Action reference
*/
action_ref: string;
/**
* Creation timestamp
*/
created: string;
/**
* Rule description
*/
description: string | null;
/**
* Whether the rule is enabled
*/
enabled: boolean;
/**
* Rule ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference
*/
pack_ref: string;
/**
* Unique reference identifier
*/
ref: string;
/**
* Parameters for trigger configuration and event filtering
*/
trigger_params: Record<string, any>;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
};

View File

@@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Explicit patch operation for a nullable runtime version constraint.
*/
export type RuntimeVersionConstraintPatch = ({
op: RuntimeVersionConstraintPatch.op;
value: string;
} | {
op: RuntimeVersionConstraintPatch.op;
});
export namespace RuntimeVersionConstraintPatch {
export enum op {
SET = 'set',
}
}

View File

@@ -6,65 +6,64 @@
* Response DTO for sensor information
*/
export type SensorResponse = {
/**
* Creation timestamp
*/
created: string;
/**
* Sensor description
*/
description: string;
/**
* Whether the sensor is enabled
*/
enabled: boolean;
/**
* Entry point
*/
entrypoint: string;
/**
* Sensor ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack ID (optional)
*/
pack?: number | null;
/**
* Pack reference (optional)
*/
pack_ref?: string | null;
/**
* Parameter schema (StackStorm-style with inline required/secret)
*/
param_schema: any | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime: number;
/**
* Runtime reference
*/
runtime_ref: string;
/**
* Trigger ID
*/
trigger: number;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
/**
* Creation timestamp
*/
created: string;
/**
* Sensor description
*/
description: string | null;
/**
* Whether the sensor is enabled
*/
enabled: boolean;
/**
* Entry point
*/
entrypoint: string;
/**
* Sensor ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack ID (optional)
*/
pack?: number | null;
/**
* Pack reference (optional)
*/
pack_ref?: string | null;
/**
* Parameter schema (StackStorm-style with inline required/secret)
*/
param_schema: any | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Runtime ID
*/
runtime: number;
/**
* Runtime reference
*/
runtime_ref: string;
/**
* Trigger ID
*/
trigger: number;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
};

View File

@@ -6,41 +6,40 @@
* Simplified sensor response (for list endpoints)
*/
export type SensorSummary = {
/**
* Creation timestamp
*/
created: string;
/**
* Sensor description
*/
description: string;
/**
* Whether the sensor is enabled
*/
enabled: boolean;
/**
* Sensor ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference (optional)
*/
pack_ref?: string | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
/**
* Creation timestamp
*/
created: string;
/**
* Sensor description
*/
description: string | null;
/**
* Whether the sensor is enabled
*/
enabled: boolean;
/**
* Sensor ID
*/
id: number;
/**
* Human-readable label
*/
label: string;
/**
* Pack reference (optional)
*/
pack_ref?: string | null;
/**
* Unique reference identifier
*/
ref: string;
/**
* Trigger reference
*/
trigger_ref: string;
/**
* Last update timestamp
*/
updated: string;
};

View File

@@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type TriggerStringPatch =
| {
op: TriggerStringPatch.op;
value: string;
}
| {
op: TriggerStringPatch.op;
};
export namespace TriggerStringPatch {
export enum op {
SET = "set",
CLEAR = "clear",
}
}

View File

@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { RuntimeVersionConstraintPatch } from './RuntimeVersionConstraintPatch';
/**
* Request DTO for updating an action
*/
@@ -30,9 +31,6 @@ export type UpdateActionRequest = {
* Runtime ID
*/
runtime?: number | null;
/**
* Optional semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0")
*/
runtime_version_constraint?: string | null;
runtime_version_constraint?: (null | RuntimeVersionConstraintPatch);
};

View File

@@ -6,6 +6,7 @@ import type { Value } from './Value';
export type UpdateIdentityRequest = {
attributes?: (null | Value);
display_name?: string | null;
frozen?: boolean | null;
password?: string | null;
};

View File

@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PackDescriptionPatch } from './PackDescriptionPatch';
/**
* Request DTO for updating a pack
*/
@@ -18,10 +19,7 @@ export type UpdatePackRequest = {
* Pack dependencies (refs of required packs)
*/
dependencies?: any[] | null;
/**
* Pack description
*/
description?: string | null;
description?: (null | PackDescriptionPatch);
/**
* Whether this is a standard pack
*/

View File

@@ -2,14 +2,12 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TriggerStringPatch } from './TriggerStringPatch';
/**
* Request DTO for updating a trigger
*/
export type UpdateTriggerRequest = {
/**
* Trigger description
*/
description?: string | null;
description?: (null | TriggerStringPatch);
/**
* Whether the trigger is enabled
*/

View File

@@ -0,0 +1,61 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AgentBinaryInfo } from '../models/AgentBinaryInfo';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class AgentService {
/**
* Download the agent binary
* Returns the statically-linked attune-agent binary for the requested architecture.
* The binary can be injected into any container to turn it into an Attune worker.
* @returns any Agent binary
* @throws ApiError
*/
public static downloadAgentBinary({
arch,
token,
}: {
/**
* Target architecture (x86_64, aarch64). Defaults to x86_64.
*/
arch?: string | null,
/**
* Optional bootstrap token for authentication
*/
token?: string | null,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/agent/binary',
query: {
'arch': arch,
'token': token,
},
errors: {
400: `Invalid architecture`,
401: `Invalid or missing bootstrap token`,
404: `Agent binary not found`,
503: `Agent binary distribution not configured`,
},
});
}
/**
* Get agent binary metadata
* Returns information about available agent binaries, including
* supported architectures and binary sizes.
* @returns AgentBinaryInfo Agent binary info
* @throws ApiError
*/
public static agentInfo(): CancelablePromise<AgentBinaryInfo> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/agent/info',
errors: {
503: `Agent binary distribution not configured`,
},
});
}
}

View File

@@ -3,6 +3,7 @@
/* tslint:disable */
/* eslint-disable */
import type { ChangePasswordRequest } from '../models/ChangePasswordRequest';
import type { LdapLoginRequest } from '../models/LdapLoginRequest';
import type { LoginRequest } from '../models/LoginRequest';
import type { RefreshTokenRequest } from '../models/RefreshTokenRequest';
import type { RegisterRequest } from '../models/RegisterRequest';
@@ -52,6 +53,55 @@ export class AuthService {
},
});
}
/**
* Authenticate via LDAP directory.
* POST /auth/ldap/login
* @returns any Successfully authenticated via LDAP
* @throws ApiError
*/
public static ldapLogin({
requestBody,
}: {
requestBody: LdapLoginRequest,
}): CancelablePromise<{
/**
* Token response
*/
data: {
/**
* Access token (JWT)
*/
access_token: string;
/**
* Access token expiration in seconds
*/
expires_in: number;
/**
* Refresh token
*/
refresh_token: string;
/**
* Token type (always "Bearer")
*/
token_type: string;
user?: (null | UserInfo);
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/auth/ldap/login',
body: requestBody,
mediaType: 'application/json',
errors: {
401: `Invalid LDAP credentials`,
501: `LDAP not configured`,
},
});
}
/**
* Login endpoint
* POST /auth/login
@@ -237,4 +287,82 @@ export class AuthService {
},
});
}
/**
* Authentication settings endpoint
* GET /auth/settings
* @returns any Authentication settings
* @throws ApiError
*/
public static authSettings(): CancelablePromise<{
/**
* Public authentication settings for the login page.
*/
data: {
/**
* Whether authentication is enabled for the server.
*/
authentication_enabled: boolean;
/**
* Whether LDAP login is configured and enabled.
*/
ldap_enabled: boolean;
/**
* Optional icon URL shown beside the provider label.
*/
ldap_provider_icon_url?: string | null;
/**
* User-facing provider label for the login button.
*/
ldap_provider_label?: string | null;
/**
* Provider name for `?auth=<provider>`.
*/
ldap_provider_name?: string | null;
/**
* Whether LDAP login should be shown by default.
*/
ldap_visible_by_default: boolean;
/**
* Whether local username/password login is configured.
*/
local_password_enabled: boolean;
/**
* Whether local username/password login should be shown by default.
*/
local_password_visible_by_default: boolean;
/**
* Whether OIDC login is configured and enabled.
*/
oidc_enabled: boolean;
/**
* Optional icon URL shown beside the provider label.
*/
oidc_provider_icon_url?: string | null;
/**
* User-facing provider label for the login button.
*/
oidc_provider_label?: string | null;
/**
* Provider name for `?auth=<provider>`.
*/
oidc_provider_name?: string | null;
/**
* Whether OIDC login should be shown by default.
*/
oidc_visible_by_default: boolean;
/**
* Whether unauthenticated self-service registration is allowed.
*/
self_registration_enabled: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'GET',
url: '/auth/settings',
});
}
}

View File

@@ -3,7 +3,10 @@
/* tslint:disable */
/* eslint-disable */
import type { CreateIdentityRequest } from '../models/CreateIdentityRequest';
import type { CreateIdentityRoleAssignmentRequest } from '../models/CreateIdentityRoleAssignmentRequest';
import type { CreatePermissionAssignmentRequest } from '../models/CreatePermissionAssignmentRequest';
import type { CreatePermissionSetRoleAssignmentRequest } from '../models/CreatePermissionSetRoleAssignmentRequest';
import type { IdentityRoleAssignmentResponse } from '../models/IdentityRoleAssignmentResponse';
import type { PaginatedResponse_IdentitySummary } from '../models/PaginatedResponse_IdentitySummary';
import type { PermissionAssignmentResponse } from '../models/PermissionAssignmentResponse';
import type { PermissionSetSummary } from '../models/PermissionSetSummary';
@@ -50,9 +53,12 @@ export class PermissionsService {
}): CancelablePromise<{
data: {
attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null;
frozen: boolean;
id: number;
login: string;
roles: Array<IdentityRoleAssignmentResponse>;
};
/**
* Optional message
@@ -69,6 +75,47 @@ export class PermissionsService {
},
});
}
/**
* @returns any Identity role assignment deleted
* @throws ApiError
*/
public static deleteIdentityRoleAssignment({
id,
}: {
/**
* Identity role assignment ID
*/
id: number,
}): CancelablePromise<{
/**
* Success message response (for operations that don't return data)
*/
data: {
/**
* Message describing the operation
*/
message: string;
/**
* Success indicator
*/
success: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/identities/roles/{id}',
path: {
'id': id,
},
errors: {
404: `Identity role assignment not found`,
},
});
}
/**
* @returns any Identity details
* @throws ApiError
@@ -83,9 +130,12 @@ export class PermissionsService {
}): CancelablePromise<{
data: {
attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null;
frozen: boolean;
id: number;
login: string;
roles: Array<IdentityRoleAssignmentResponse>;
};
/**
* Optional message
@@ -119,9 +169,12 @@ export class PermissionsService {
}): CancelablePromise<{
data: {
attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null;
frozen: boolean;
id: number;
login: string;
roles: Array<IdentityRoleAssignmentResponse>;
};
/**
* Optional message
@@ -182,6 +235,47 @@ export class PermissionsService {
},
});
}
/**
* @returns any Identity frozen
* @throws ApiError
*/
public static freezeIdentity({
id,
}: {
/**
* Identity ID
*/
id: number,
}): CancelablePromise<{
/**
* Success message response (for operations that don't return data)
*/
data: {
/**
* Message describing the operation
*/
message: string;
/**
* Success indicator
*/
success: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/identities/{id}/freeze',
path: {
'id': id,
},
errors: {
404: `Identity not found`,
},
});
}
/**
* @returns PermissionAssignmentResponse List permission assignments for an identity
* @throws ApiError
@@ -205,6 +299,88 @@ export class PermissionsService {
},
});
}
/**
* @returns any Identity role assignment created
* @throws ApiError
*/
public static createIdentityRoleAssignment({
id,
requestBody,
}: {
/**
* Identity ID
*/
id: number,
requestBody: CreateIdentityRoleAssignmentRequest,
}): CancelablePromise<{
data: {
created: string;
id: number;
identity_id: number;
managed: boolean;
role: string;
source: string;
updated: string;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/identities/{id}/roles',
path: {
'id': id,
},
body: requestBody,
mediaType: 'application/json',
errors: {
404: `Identity not found`,
},
});
}
/**
* @returns any Identity unfrozen
* @throws ApiError
*/
public static unfreezeIdentity({
id,
}: {
/**
* Identity ID
*/
id: number,
}): CancelablePromise<{
/**
* Success message response (for operations that don't return data)
*/
data: {
/**
* Message describing the operation
*/
message: string;
/**
* Success indicator
*/
success: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/identities/{id}/unfreeze',
path: {
'id': id,
},
errors: {
404: `Identity not found`,
},
});
}
/**
* @returns any Permission assignment created
* @throws ApiError
@@ -295,4 +471,84 @@ export class PermissionsService {
},
});
}
/**
* @returns any Permission set role assignment deleted
* @throws ApiError
*/
public static deletePermissionSetRoleAssignment({
id,
}: {
/**
* Permission set role assignment ID
*/
id: number,
}): CancelablePromise<{
/**
* Success message response (for operations that don't return data)
*/
data: {
/**
* Message describing the operation
*/
message: string;
/**
* Success indicator
*/
success: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/permissions/sets/roles/{id}',
path: {
'id': id,
},
errors: {
404: `Permission set role assignment not found`,
},
});
}
/**
* @returns any Permission set role assignment created
* @throws ApiError
*/
public static createPermissionSetRoleAssignment({
id,
requestBody,
}: {
/**
* Permission set ID
*/
id: number,
requestBody: CreatePermissionSetRoleAssignmentRequest,
}): CancelablePromise<{
data: {
created: string;
id: number;
permission_set_id: number;
permission_set_ref?: string | null;
role: string;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/permissions/sets/{id}/roles',
path: {
'id': id,
},
body: requestBody,
mediaType: 'application/json',
errors: {
404: `Permission set not found`,
},
});
}
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useCreatePack, useUpdatePack } from "@/hooks/usePacks";
import type { PackResponse } from "@/api";
import { PackDescriptionPatch, type PackResponse } from "@/api";
import { labelToRef } from "@/lib/format-utils";
import SchemaBuilder from "@/components/common/SchemaBuilder";
import ParamSchemaForm, {
@@ -173,7 +173,9 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
if (isEditing) {
const updateData = {
label: label.trim(),
description: description.trim() || undefined,
description: description.trim()
? { op: PackDescriptionPatch.op.SET, value: description.trim() }
: { op: PackDescriptionPatch.op.CLEAR },
version: version.trim(),
conf_schema: parsedConfSchema,
config: configValues,

View File

@@ -9,6 +9,7 @@ import ParamSchemaForm, {
type ParamSchema,
} from "@/components/common/ParamSchemaForm";
import SearchableSelect from "@/components/common/SearchableSelect";
import RuleMatchConditionsEditor from "@/components/forms/RuleMatchConditionsEditor";
import type {
RuleResponse,
ActionSummary,
@@ -40,9 +41,21 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
const [description, setDescription] = useState(rule?.description || "");
const [triggerId, setTriggerId] = useState<number>(rule?.trigger || 0);
const [actionId, setActionId] = useState<number>(rule?.action || 0);
const [conditions, setConditions] = useState(
rule?.conditions ? JSON.stringify(rule.conditions, null, 2) : "",
);
const [conditions, setConditions] = useState<JsonValue | undefined>(() => {
if (!rule?.conditions) {
return undefined;
}
if (
typeof rule.conditions === "object" &&
!Array.isArray(rule.conditions) &&
Object.keys(rule.conditions).length === 0
) {
return undefined;
}
return rule.conditions;
});
const [triggerParameters, setTriggerParameters] = useState<
Record<string, JsonValue>
>(rule?.trigger_params || {});
@@ -57,6 +70,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
const [actionParamErrors, setActionParamErrors] = useState<
Record<string, string>
>({});
const [conditionsError, setConditionsError] = useState<string | undefined>();
// Data fetching
const { data: packsData } = usePacks({ pageSize: 1000 });
@@ -143,10 +157,6 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
newErrors.label = "Label is required";
}
if (!description.trim()) {
newErrors.description = "Description is required";
}
if (!packId) {
newErrors.pack = "Pack is required";
}
@@ -159,13 +169,8 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
newErrors.action = "Action is required";
}
// Validate conditions JSON if provided
if (conditions.trim()) {
try {
JSON.parse(conditions);
} catch {
newErrors.conditions = "Invalid JSON format";
}
if (conditionsError) {
newErrors.conditions = conditionsError;
}
// Validate trigger parameters (allow templates in rule context)
@@ -210,15 +215,18 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
pack_ref: selectedPackData?.ref || "",
ref: fullRef,
label: label.trim(),
description: description.trim(),
trigger_ref: selectedTrigger?.ref || "",
action_ref: selectedAction?.ref || "",
enabled,
};
if (description.trim()) {
formData.description = description.trim();
}
// Only add optional fields if they have values
if (conditions.trim()) {
formData.conditions = JSON.parse(conditions);
if (conditions !== undefined) {
formData.conditions = conditions;
}
// Add trigger parameters if any
@@ -274,280 +282,252 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
)}
{/* Basic Information */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<div className="bg-white rounded-lg shadow p-5 lg:p-6">
<h3 className="text-lg font-semibold text-gray-900">
Basic Information
</h3>
{/* Pack Selection */}
<div>
<label
htmlFor="pack"
className="block text-sm font-medium text-gray-700 mb-1"
>
Pack <span className="text-red-500">*</span>
</label>
<SearchableSelect
id="pack"
value={packId}
onChange={(v) => setPackId(Number(v))}
options={packs.map((pack) => ({
value: pack.id,
label: `${pack.label} (${pack.version})`,
}))}
placeholder="Select a pack..."
disabled={isEditing}
error={!!errors.pack}
/>
{errors.pack && (
<p className="mt-1 text-sm text-red-600">{errors.pack}</p>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-12">
{/* Pack Selection */}
<div className="lg:col-span-4">
<label
htmlFor="pack"
className="block text-sm font-medium text-gray-700 mb-1"
>
Pack <span className="text-red-500">*</span>
</label>
<SearchableSelect
id="pack"
value={packId}
onChange={(v) => setPackId(Number(v))}
options={packs.map((pack) => ({
value: pack.id,
label: `${pack.label} (${pack.version})`,
}))}
placeholder="Select a pack..."
disabled={isEditing}
error={!!errors.pack}
/>
{errors.pack && (
<p className="mt-1 text-sm text-red-600">{errors.pack}</p>
)}
</div>
{/* Label - MOVED FIRST */}
<div>
<label
htmlFor="label"
className="block text-sm font-medium text-gray-700 mb-1"
>
Label <span className="text-red-500">*</span>
</label>
<input
type="text"
id="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
onBlur={() => {
// Auto-populate localRef from label if localRef is empty and not editing
if (!isEditing && !localRef.trim() && label.trim()) {
setLocalRef(labelToRef(label));
}
}}
placeholder="e.g., Notify on Error"
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.label ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.label && (
<p className="mt-1 text-sm text-red-600">{errors.label}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Human-readable name for display
</p>
</div>
{/* Reference - MOVED AFTER LABEL with Pack Prefix */}
<div>
<label
htmlFor="ref"
className="block text-sm font-medium text-gray-700 mb-1"
>
Reference <span className="text-red-500">*</span>
</label>
<div className="input-with-prefix">
<span className={`prefix ${errors.ref ? "error" : ""}`}>
{selectedPack?.ref || "pack"}.
</span>
{/* Label */}
<div className="lg:col-span-8">
<label
htmlFor="label"
className="block text-sm font-medium text-gray-700 mb-1"
>
Label <span className="text-red-500">*</span>
</label>
<input
type="text"
id="ref"
value={localRef}
onChange={(e) => setLocalRef(e.target.value)}
placeholder="e.g., notify_on_error"
disabled={isEditing}
className={errors.ref ? "error" : ""}
id="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
onBlur={() => {
// Auto-populate localRef from label if localRef is empty and not editing
if (!isEditing && !localRef.trim() && label.trim()) {
setLocalRef(labelToRef(label));
}
}}
placeholder="e.g., Notify on Error"
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.label ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.label && (
<p className="mt-1 text-sm text-red-600">{errors.label}</p>
)}
</div>
{errors.ref && (
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Local identifier within the pack. Auto-populated from label.
</p>
</div>
{/* Description */}
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-gray-700 mb-1"
>
Description <span className="text-red-500">*</span>
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this rule does..."
rows={3}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.description ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description}</p>
)}
</div>
{/* Reference */}
<div className="lg:col-span-7">
<label
htmlFor="ref"
className="block text-sm font-medium text-gray-700 mb-1"
>
Reference <span className="text-red-500">*</span>
</label>
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="input-with-prefix flex-1">
<span className={`prefix ${errors.ref ? "error" : ""}`}>
{selectedPack?.ref || "pack"}.
</span>
<input
type="text"
id="ref"
value={localRef}
onChange={(e) => setLocalRef(e.target.value)}
placeholder="e.g., notify_on_error"
disabled={isEditing}
className={errors.ref ? "error" : ""}
/>
</div>
<label
htmlFor="enabled"
className="flex items-center gap-2 whitespace-nowrap rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-700"
>
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Enable immediately
</label>
</div>
{errors.ref && (
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
)}
</div>
{/* Enabled Toggle */}
<div className="flex items-center">
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="enabled" className="ml-2 text-sm text-gray-700">
Enable rule immediately
</label>
{/* Description */}
<div className="lg:col-span-12">
<label
htmlFor="description"
className="block text-sm font-medium text-gray-700 mb-1"
>
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this rule does..."
rows={2}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.description ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description}</p>
)}
</div>
</div>
</div>
{/* Trigger Configuration */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Trigger Configuration
</h3>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
{/* Trigger Configuration */}
<div className="bg-white rounded-lg shadow p-5 lg:p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Trigger Configuration
</h3>
{!packId ? (
<p className="text-sm text-gray-500">
Select a pack first to choose a trigger
</p>
) : !triggers || triggers.length === 0 ? (
<p className="text-sm text-gray-500">
No triggers available in the system
</p>
) : (
<>
{/* Trigger Selection */}
<div>
<label
htmlFor="trigger"
className="block text-sm font-medium text-gray-700 mb-1"
>
Trigger <span className="text-red-500">*</span>
</label>
<SearchableSelect
id="trigger"
value={triggerId}
onChange={(v) => setTriggerId(Number(v))}
options={triggers.map((trigger) => ({
value: trigger.id,
label: `${trigger.ref} - ${trigger.label}`,
}))}
placeholder="Select a trigger..."
disabled={isEditing}
error={!!errors.trigger}
/>
{errors.trigger && (
<p className="mt-1 text-sm text-red-600">{errors.trigger}</p>
)}
</div>
{/* Trigger Parameters - Dynamic Form */}
{selectedTrigger && (
{!triggers || triggers.length === 0 ? (
<p className="text-sm text-gray-500">
No triggers available in the system
</p>
) : (
<>
{/* Trigger Selection */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Trigger Parameters
</h4>
<ParamSchemaForm
schema={triggerParamSchema}
values={triggerParameters}
onChange={setTriggerParameters}
errors={triggerParamErrors}
allowTemplates
<label
htmlFor="trigger"
className="block text-sm font-medium text-gray-700 mb-1"
>
Trigger <span className="text-red-500">*</span>
</label>
<SearchableSelect
id="trigger"
value={triggerId}
onChange={(v) => setTriggerId(Number(v))}
options={triggers.map((trigger) => ({
value: trigger.id,
label: `${trigger.ref} - ${trigger.label}`,
}))}
placeholder="Select a trigger..."
disabled={isEditing}
error={!!errors.trigger}
/>
{errors.trigger && (
<p className="mt-1 text-sm text-red-600">{errors.trigger}</p>
)}
</div>
)}
{/* Conditions (JSON) */}
<div>
<label
htmlFor="conditions"
className="block text-sm font-medium text-gray-700 mb-1"
>
Match Conditions (JSON)
</label>
<textarea
id="conditions"
{/* Trigger Parameters - Dynamic Form */}
{selectedTrigger && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Trigger Parameters
</h4>
<ParamSchemaForm
schema={triggerParamSchema}
values={triggerParameters}
onChange={setTriggerParameters}
errors={triggerParamErrors}
allowTemplates
/>
</div>
)}
<RuleMatchConditionsEditor
value={conditions}
onChange={(e) => setConditions(e.target.value)}
placeholder={`{\n "and": [\n {"var": "payload.severity", ">=": 3},\n {"var": "payload.status", "==": "error"}\n ]\n}`}
rows={8}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm ${
errors.conditions ? "border-red-500" : "border-gray-300"
}`}
onChange={setConditions}
error={errors.conditions}
onErrorChange={setConditionsError}
/>
{errors.conditions && (
<p className="mt-1 text-sm text-red-600">{errors.conditions}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Optional. Leave empty to match all events from this trigger.
</p>
</div>
</>
)}
</div>
</>
)}
</div>
{/* Action Configuration */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Action Configuration
</h3>
{/* Action Configuration */}
<div className="bg-white rounded-lg shadow p-5 lg:p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Action Configuration
</h3>
{!packId ? (
<p className="text-sm text-gray-500">
Select a pack first to choose an action
</p>
) : !actions || actions.length === 0 ? (
<p className="text-sm text-gray-500">
No actions available in the system
</p>
) : (
<>
{/* Action Selection */}
<div>
<label
htmlFor="action"
className="block text-sm font-medium text-gray-700 mb-1"
>
Action <span className="text-red-500">*</span>
</label>
<SearchableSelect
id="action"
value={actionId}
onChange={(v) => setActionId(Number(v))}
options={actions.map((action) => ({
value: action.id,
label: `${action.ref} - ${action.label}`,
}))}
placeholder="Select an action..."
disabled={isEditing}
error={!!errors.action}
/>
{errors.action && (
<p className="mt-1 text-sm text-red-600">{errors.action}</p>
)}
</div>
{/* Action Parameters - Dynamic Form */}
{selectedAction && (
{!actions || actions.length === 0 ? (
<p className="text-sm text-gray-500">
No actions available in the system
</p>
) : (
<>
{/* Action Selection */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Action Parameters
</h4>
<ParamSchemaForm
schema={actionParamSchema}
values={actionParameters}
onChange={setActionParameters}
errors={actionParamErrors}
allowTemplates
<label
htmlFor="action"
className="block text-sm font-medium text-gray-700 mb-1"
>
Action <span className="text-red-500">*</span>
</label>
<SearchableSelect
id="action"
value={actionId}
onChange={(v) => setActionId(Number(v))}
options={actions.map((action) => ({
value: action.id,
label: `${action.ref} - ${action.label}`,
}))}
placeholder="Select an action..."
disabled={isEditing}
error={!!errors.action}
/>
{errors.action && (
<p className="mt-1 text-sm text-red-600">{errors.action}</p>
)}
</div>
)}
</>
)}
{/* Action Parameters - Dynamic Form */}
{selectedAction && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Action Parameters
</h4>
<ParamSchemaForm
schema={actionParamSchema}
values={actionParameters}
onChange={setActionParameters}
errors={actionParamErrors}
allowTemplates
/>
</div>
)}
</>
)}
</div>
</div>
{/* Form Actions */}

View File

@@ -0,0 +1,507 @@
import { useEffect, useId, useState } from "react";
import { Braces, ListFilter, Plus, Trash2 } from "lucide-react";
import SearchableSelect from "@/components/common/SearchableSelect";
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
type ConditionOperator = "equals" | "not_equals" | "contains";
type ConditionValueType = "string" | "number" | "boolean" | "null" | "json";
type EditorMode = "guided" | "raw";
interface ConditionRow {
id: string;
field: string;
operator: ConditionOperator;
valueType: ConditionValueType;
valueInput: string;
}
interface RuleMatchConditionsEditorProps {
value: unknown;
onChange: (value: JsonValue[] | JsonValue | undefined) => void;
error?: string;
onErrorChange?: (message?: string) => void;
}
const OPERATOR_OPTIONS = [
{
value: "equals",
label: "Equals",
},
{
value: "not_equals",
label: "Does not equal",
},
{
value: "contains",
label: "Contains",
},
] satisfies Array<{ value: ConditionOperator; label: string }>;
const VALUE_TYPE_OPTIONS = [
{
value: "string",
label: "Text",
},
{
value: "number",
label: "Number",
},
{
value: "boolean",
label: "True/False",
},
{
value: "null",
label: "Empty",
},
{
value: "json",
label: "JSON",
},
] satisfies Array<{ value: ConditionValueType; label: string }>;
const DEFAULT_OPERATOR: ConditionOperator = "equals";
const DEFAULT_VALUE_TYPE: ConditionValueType = "string";
function createRow(partial?: Partial<ConditionRow>): ConditionRow {
return {
id: Math.random().toString(36).slice(2, 10),
field: partial?.field || "",
operator: partial?.operator || DEFAULT_OPERATOR,
valueType: partial?.valueType || DEFAULT_VALUE_TYPE,
valueInput: partial?.valueInput || "",
};
}
function inferValueType(value: unknown): ConditionValueType {
if (value === null) {
return "null";
}
if (typeof value === "string") {
return "string";
}
if (typeof value === "number") {
return "number";
}
if (typeof value === "boolean") {
return "boolean";
}
return "json";
}
function formatValueInput(
value: unknown,
valueType: ConditionValueType,
): string {
if (valueType === "null") {
return "";
}
if (valueType === "json") {
return JSON.stringify(value, null, 2);
}
return String(value ?? "");
}
function isGuidedCondition(value: unknown): value is {
field: string;
operator: ConditionOperator;
value: unknown;
} {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const condition = value as Record<string, unknown>;
return (
typeof condition.field === "string" &&
typeof condition.operator === "string" &&
Object.prototype.hasOwnProperty.call(condition, "value") &&
OPERATOR_OPTIONS.some((option) => option.value === condition.operator)
);
}
function parseInitialState(value: unknown): {
mode: EditorMode;
rows: ConditionRow[];
rawText: string;
unsupportedMessage?: string;
} {
if (
value == null ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value as Record<string, unknown>).length === 0)
) {
return {
mode: "guided",
rows: [],
rawText: "",
};
}
if (Array.isArray(value) && value.every(isGuidedCondition)) {
return {
mode: "guided",
rows: value.map((condition) => {
const valueType = inferValueType(condition.value);
return createRow({
field: condition.field,
operator: condition.operator,
valueType,
valueInput: formatValueInput(condition.value, valueType),
});
}),
rawText: JSON.stringify(value, null, 2),
};
}
return {
mode: "raw",
rows: [],
rawText: JSON.stringify(value, null, 2),
unsupportedMessage:
"This rule uses a condition shape outside the guided builder. Edit it in raw JSON to preserve it.",
};
}
function parseConditionValue(row: ConditionRow): {
value?: JsonValue;
error?: string;
} {
switch (row.valueType) {
case "string":
return { value: row.valueInput };
case "number": {
const trimmed = row.valueInput.trim();
if (!trimmed) {
return { error: "Number value is required." };
}
const parsed = Number(trimmed);
if (Number.isNaN(parsed)) {
return { error: "Enter a valid number." };
}
return { value: parsed };
}
case "boolean":
return { value: row.valueInput === "true" };
case "null":
return { value: null };
case "json":
if (!row.valueInput.trim()) {
return { error: "JSON value is required." };
}
try {
return { value: JSON.parse(row.valueInput) as JsonValue };
} catch {
return { error: "Enter valid JSON." };
}
}
}
export default function RuleMatchConditionsEditor({
value,
onChange,
error,
onErrorChange,
}: RuleMatchConditionsEditorProps) {
const fieldId = useId();
const [mode, setMode] = useState<EditorMode>(
() => parseInitialState(value).mode,
);
const [rows, setRows] = useState<ConditionRow[]>(
() => parseInitialState(value).rows,
);
const [rawText, setRawText] = useState(
() => parseInitialState(value).rawText,
);
const [unsupportedMessage] = useState<string | undefined>(
() => parseInitialState(value).unsupportedMessage,
);
useEffect(() => {
if (mode === "raw") {
if (!rawText.trim()) {
onErrorChange?.(undefined);
onChange(undefined);
return;
}
try {
onErrorChange?.(undefined);
onChange(JSON.parse(rawText) as JsonValue);
} catch {
onErrorChange?.("Invalid JSON format");
}
return;
}
const nextConditions: JsonValue[] = [];
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index];
if (!row.field.trim()) {
onErrorChange?.(`Condition ${index + 1}: field is required.`);
return;
}
const parsedValue = parseConditionValue(row);
if (parsedValue.error) {
onErrorChange?.(`Condition ${index + 1}: ${parsedValue.error}`);
return;
}
nextConditions.push({
field: row.field.trim(),
operator: row.operator,
value: parsedValue.value ?? null,
});
}
onErrorChange?.(undefined);
onChange(nextConditions.length > 0 ? nextConditions : undefined);
}, [mode, onChange, onErrorChange, rawText, rows]);
const addCondition = () => {
setRows((current) => [...current, createRow()]);
};
const updateRow = (
id: string,
updater: (row: ConditionRow) => ConditionRow,
) => {
setRows((current) =>
current.map((row) => (row.id === id ? updater(row) : row)),
);
};
const removeCondition = (id: string) => {
setRows((current) => current.filter((row) => row.id !== id));
};
const currentError = error;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h4 className="text-sm font-medium text-gray-700">
Match Conditions
</h4>
<p className="mt-1 text-xs text-gray-500">
All conditions must match. Leave this empty to match every event
from the selected trigger.
</p>
</div>
<div className="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-1">
<button
type="button"
onClick={() => setMode("guided")}
className={`inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === "guided"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
<ListFilter className="h-4 w-4" />
Guided
</button>
<button
type="button"
onClick={() => setMode("raw")}
className={`inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === "raw"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
<Braces className="h-4 w-4" />
Raw JSON
</button>
</div>
</div>
{unsupportedMessage && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{unsupportedMessage}
</div>
)}
{mode === "guided" ? (
<div className="space-y-3">
{rows.length === 0 ? (
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-5 text-sm text-gray-500">
No conditions configured.
</div>
) : (
rows.map((row, index) => (
<div
key={row.id}
className="rounded-xl border border-gray-200 bg-gray-50/70 p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm font-medium text-gray-700">
Condition {index + 1}
</span>
<button
type="button"
onClick={() => removeCondition(row.id)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-gray-500 hover:bg-white hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
Remove
</button>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-12">
<div className="xl:col-span-5">
<label
htmlFor={`${fieldId}-${row.id}-field`}
className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500"
>
Event field
</label>
<input
id={`${fieldId}-${row.id}-field`}
type="text"
value={row.field}
onChange={(e) =>
updateRow(row.id, (current) => ({
...current,
field: e.target.value,
}))
}
placeholder="status or nested.path"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="xl:col-span-3">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">
Operator
</label>
<SearchableSelect
value={row.operator}
onChange={(nextValue) =>
updateRow(row.id, (current) => ({
...current,
operator: nextValue as ConditionOperator,
}))
}
options={OPERATOR_OPTIONS}
placeholder="Choose operator"
/>
</div>
<div className="xl:col-span-4">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">
Value type
</label>
<SearchableSelect
value={row.valueType}
onChange={(nextValue) =>
updateRow(row.id, (current) => ({
...current,
valueType: nextValue as ConditionValueType,
valueInput:
nextValue === "boolean"
? "true"
: nextValue === "null"
? ""
: current.valueInput,
}))
}
options={VALUE_TYPE_OPTIONS}
placeholder="Choose type"
/>
</div>
<div className="xl:col-span-12">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">
Expected value
</label>
{row.valueType === "boolean" ? (
<SearchableSelect
value={row.valueInput || "true"}
onChange={(nextValue) =>
updateRow(row.id, (current) => ({
...current,
valueInput: String(nextValue),
}))
}
options={[
{ value: "true", label: "True" },
{ value: "false", label: "False" },
]}
/>
) : row.valueType === "null" ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-3 py-2 text-sm text-gray-500">
This condition matches a null value.
</div>
) : row.valueType === "json" ? (
<textarea
value={row.valueInput}
onChange={(e) =>
updateRow(row.id, (current) => ({
...current,
valueInput: e.target.value,
}))
}
rows={4}
placeholder='{"expected": "value"}'
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<input
type={row.valueType === "number" ? "number" : "text"}
value={row.valueInput}
onChange={(e) =>
updateRow(row.id, (current) => ({
...current,
valueInput: e.target.value,
}))
}
placeholder={
row.valueType === "number" ? "42" : "expected value"
}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
</div>
</div>
</div>
))
)}
<button
type="button"
onClick={addCondition}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Plus className="h-4 w-4" />
Add condition
</button>
</div>
) : (
<textarea
value={rawText}
onChange={(e) => setRawText(e.target.value)}
rows={10}
placeholder={`[\n {\n "field": "status",\n "operator": "equals",\n "value": "error"\n }\n]`}
className="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
{currentError && <p className="text-sm text-red-600">{currentError}</p>}
</div>
);
}

View File

@@ -10,7 +10,7 @@ import {
} from "@/lib/format-utils";
import SchemaBuilder from "@/components/common/SchemaBuilder";
import SearchableSelect from "@/components/common/SearchableSelect";
import { WebhooksService } from "@/api";
import { TriggerStringPatch, WebhooksService } from "@/api";
import type { TriggerResponse, PackSummary } from "@/api";
/** Flat schema format: each key is a parameter name mapped to its definition */
@@ -116,7 +116,6 @@ export default function TriggerForm({
pack_ref: selectedPackData.ref,
ref: fullRef,
label: label.trim(),
description: description.trim() || undefined,
enabled,
param_schema:
Object.keys(paramSchema).length > 0 ? paramSchema : undefined,
@@ -124,9 +123,16 @@ export default function TriggerForm({
};
if (isEditing && initialData?.ref) {
const updateData = {
...formData,
description: description.trim()
? { op: TriggerStringPatch.op.SET, value: description.trim() }
: { op: TriggerStringPatch.op.CLEAR },
};
await updateTrigger.mutateAsync({
ref: initialData.ref,
data: formData,
data: updateData,
});
// Handle webhook enable/disable separately for updates
@@ -152,7 +158,12 @@ export default function TriggerForm({
navigate(`/triggers/${encodeURIComponent(initialData.ref)}`);
return;
} else {
const response = await createTrigger.mutateAsync(formData);
const createData = {
...formData,
description: description.trim() || undefined,
};
const response = await createTrigger.mutateAsync(createData);
const newTrigger = response?.data;
if (newTrigger?.ref) {
// If webhook is enabled, enable it after trigger creation

View File

@@ -1,24 +1,8 @@
import React, { useState, useEffect } from "react";
import { Link, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import {
Package,
ChevronLeft,
ChevronRight,
User,
LogOut,
CirclePlay,
CircleArrowRight,
SquareArrowRight,
SquarePlay,
SquareDot,
CircleDot,
SquareAsterisk,
KeyRound,
Home,
FolderArchive,
TerminalSquare,
} from "lucide-react";
import { ChevronLeft, ChevronRight, User, LogOut } from "lucide-react";
import { navIcons } from "./navIcons";
// Color mappings for navigation items — defined outside component for stable reference
const colorClasses = {
@@ -68,29 +52,36 @@ const colorClasses = {
// Navigation sections with dividers and colors
const navSections = [
{
items: [{ to: "/", label: "Dashboard", icon: Home, color: "gray" }],
items: [
{ to: "/", label: "Dashboard", icon: navIcons.dashboard, color: "gray" },
],
},
{
// Component Management - Cool colors (cyan -> blue -> violet)
items: [
{ to: "/actions", label: "Actions", icon: SquarePlay, color: "cyan" },
{
to: "/actions",
label: "Actions",
icon: navIcons.actions,
color: "cyan",
},
{
to: "/runtimes",
label: "Runtimes",
icon: TerminalSquare,
icon: navIcons.runtimes,
color: "blue",
},
{ to: "/rules", label: "Rules", icon: SquareArrowRight, color: "blue" },
{ to: "/rules", label: "Rules", icon: navIcons.rules, color: "blue" },
{
to: "/triggers",
label: "Triggers",
icon: SquareDot,
icon: navIcons.triggers,
color: "violet",
},
{
to: "/sensors",
label: "Sensors",
icon: SquareAsterisk,
icon: navIcons.sensors,
color: "purple",
},
],
@@ -101,36 +92,47 @@ const navSections = [
{
to: "/executions",
label: "Execution History",
icon: CirclePlay,
icon: navIcons.executions,
color: "fuchsia",
},
{
to: "/enforcements",
label: "Enforcement History",
icon: CircleArrowRight,
icon: navIcons.enforcements,
color: "rose",
},
{
to: "/events",
label: "Event History",
icon: CircleDot,
icon: navIcons.events,
color: "orange",
},
],
},
{
items: [
{ to: "/keys", label: "Keys & Secrets", icon: KeyRound, color: "gray" },
{
to: "/keys",
label: "Keys & Secrets",
icon: navIcons.keys,
color: "gray",
},
{
to: "/artifacts",
label: "Artifacts",
icon: FolderArchive,
icon: navIcons.artifacts,
color: "gray",
},
{
to: "/access-control",
label: "Access Control",
icon: navIcons.accessControl,
color: "gray",
},
{
to: "/packs",
label: "Pack Management",
icon: Package,
icon: navIcons.packs,
color: "gray",
},
],

View File

@@ -0,0 +1,31 @@
import {
CircleArrowRight,
CircleDot,
CirclePlay,
FolderArchive,
Home,
KeyRound,
Package,
ShieldCheck,
SquareArrowRight,
SquareAsterisk,
SquareDot,
SquarePlay,
TerminalSquare,
} from "lucide-react";
export const navIcons = {
dashboard: Home,
actions: SquarePlay,
runtimes: TerminalSquare,
rules: SquareArrowRight,
triggers: SquareDot,
sensors: SquareAsterisk,
executions: CirclePlay,
enforcements: CircleArrowRight,
events: CircleDot,
keys: KeyRound,
artifacts: FolderArchive,
accessControl: ShieldCheck,
packs: Package,
} as const;

View File

@@ -0,0 +1,243 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PermissionsService } from "@/api";
import type {
CreateIdentityRequest,
UpdateIdentityRequest,
CreatePermissionAssignmentRequest,
} from "@/api";
// Fetch all identities with pagination
export function useIdentities(params?: { page?: number; pageSize?: number }) {
return useQuery({
queryKey: ["identities", params],
queryFn: async () => {
return await PermissionsService.listIdentities({
page: params?.page || 1,
pageSize: params?.pageSize || 50,
});
},
staleTime: 30000,
});
}
// Fetch single identity by ID
export function useIdentity(id: number) {
return useQuery({
queryKey: ["identities", id],
queryFn: async () => {
return await PermissionsService.getIdentity({ id });
},
enabled: id > 0,
staleTime: 30000,
});
}
// Create a new identity
export function useCreateIdentity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateIdentityRequest) => {
return await PermissionsService.createIdentity({ requestBody: data });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["identities"] });
},
});
}
// Update an existing identity
export function useUpdateIdentity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
data,
}: {
id: number;
data: UpdateIdentityRequest;
}) => {
return await PermissionsService.updateIdentity({ id, requestBody: data });
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ["identities"] });
queryClient.invalidateQueries({
queryKey: ["identities", variables.id],
});
},
});
}
// Delete an identity
export function useDeleteIdentity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
return await PermissionsService.deleteIdentity({ id });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["identities"] });
},
});
}
// Fetch permission sets
export function usePermissionSets(packRef?: string | null) {
return useQuery({
queryKey: ["permission-sets", packRef],
queryFn: async () => {
return await PermissionsService.listPermissionSets({ packRef });
},
staleTime: 30000,
});
}
// Fetch permission assignments for an identity
export function useIdentityPermissions(id: number) {
return useQuery({
queryKey: ["identity-permissions", id],
queryFn: async () => {
return await PermissionsService.listIdentityPermissions({ id });
},
enabled: id > 0,
staleTime: 30000,
});
}
// Create a permission assignment
export function useCreatePermissionAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreatePermissionAssignmentRequest) => {
return await PermissionsService.createPermissionAssignment({
requestBody: data,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["identities"] });
queryClient.invalidateQueries({ queryKey: ["identity-permissions"] });
queryClient.invalidateQueries({ queryKey: ["permission-sets"] });
},
});
}
// Delete a permission assignment
export function useDeletePermissionAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
return await PermissionsService.deletePermissionAssignment({ id });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["identities"] });
queryClient.invalidateQueries({ queryKey: ["identity-permissions"] });
queryClient.invalidateQueries({ queryKey: ["permission-sets"] });
},
});
}
// Create a role assignment for an identity
export function useCreateIdentityRoleAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
identityId,
role,
}: {
identityId: number;
role: string;
}) => {
return await PermissionsService.createIdentityRoleAssignment({
id: identityId,
requestBody: { role },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["identities"] });
},
});
}
// Delete a role assignment from an identity
export function useDeleteIdentityRoleAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
return await PermissionsService.deleteIdentityRoleAssignment({ id });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["identities"] });
},
});
}
// Create a role assignment for a permission set
export function useCreatePermissionSetRoleAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
permissionSetId,
role,
}: {
permissionSetId: number;
role: string;
}) => {
return await PermissionsService.createPermissionSetRoleAssignment({
id: permissionSetId,
requestBody: { role },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["permission-sets"] });
},
});
}
// Delete a role assignment from a permission set
export function useDeletePermissionSetRoleAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
return await PermissionsService.deletePermissionSetRoleAssignment({ id });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["permission-sets"] });
},
});
}
// Freeze an identity
export function useFreezeIdentity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
return await PermissionsService.freezeIdentity({ id });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["identities"] });
},
});
}
// Unfreeze an identity
export function useUnfreezeIdentity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
return await PermissionsService.unfreezeIdentity({ id });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["identities"] });
},
});
}

View File

@@ -0,0 +1,707 @@
import { useState } from "react";
import { useSearchParams, Link } from "react-router-dom";
import {
Shield,
User,
Users,
Plus,
Search,
ShieldCheck,
X,
Snowflake,
Sun,
Package,
Tag,
} from "lucide-react";
import {
useIdentities,
useCreateIdentity,
usePermissionSets,
useFreezeIdentity,
useUnfreezeIdentity,
} from "@/hooks/usePermissions";
// The backend IdentitySummary includes `frozen` and `roles` but the generated client type doesn't declare them
interface IdentityRow {
id: number;
login: string;
display_name?: string | null;
frozen?: boolean;
roles?: string[];
attributes: Record<string, unknown>;
}
// The backend PermissionSetSummary includes `roles` but the generated client type doesn't declare it
interface PermissionSetRow {
id: number;
ref: string;
pack_ref?: string | null;
label?: string | null;
description?: string | null;
grants: unknown;
roles?: Array<{
id: number;
permission_set_id: number;
permission_set_ref?: string | null;
role: string;
created: string;
}>;
}
function CreateIdentityModal({ onClose }: { onClose: () => void }) {
const [login, setLogin] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const createIdentity = useCreateIdentity();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await createIdentity.mutateAsync({
login,
display_name: displayName || undefined,
password: password || undefined,
});
onClose();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create identity",
);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Create Identity
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div>
<label
htmlFor="login"
className="block text-sm font-medium text-gray-700 mb-1"
>
Login <span className="text-red-500">*</span>
</label>
<input
id="login"
type="text"
value={login}
onChange={(e) => setLogin(e.target.value)}
required
minLength={3}
maxLength={255}
placeholder="e.g. jane.doe"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="display-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Display Name
</label>
<input
id="display-name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. Jane Doe"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
maxLength={128}
placeholder="Min 8 characters (optional)"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={createIdentity.isPending || !login}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createIdentity.isPending ? "Creating\u2026" : "Create Identity"}
</button>
</div>
</form>
</div>
</div>
);
}
function RoleBadges({ roles }: { roles: string[] }) {
const [showTooltip, setShowTooltip] = useState(false);
const visible = roles.slice(0, 3);
const remaining = roles.length - visible.length;
if (roles.length === 0) {
return <span className="text-xs text-gray-400">None</span>;
}
return (
<div className="flex items-center gap-1 flex-wrap">
{visible.map((role) => (
<span
key={role}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
>
{role}
</span>
))}
{remaining > 0 && (
<div
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<span className="text-xs text-gray-500 cursor-default">
and {remaining} more {remaining === 1 ? "role" : "roles"}
</span>
{showTooltip && (
<div className="absolute bottom-full left-0 mb-2 z-20 bg-gray-900 text-white text-xs rounded-lg shadow-lg p-3 whitespace-nowrap">
<p className="font-medium mb-1">All roles:</p>
<ul className="space-y-0.5">
{roles.map((role) => (
<li key={role}>{role}</li>
))}
</ul>
<div className="absolute top-full left-4 w-2 h-2 bg-gray-900 rotate-45 -mt-1" />
</div>
)}
</div>
)}
</div>
);
}
function IdentitiesTab() {
const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const pageSize = 20;
const { data, isLoading, error } = useIdentities({ page, pageSize });
const freezeIdentity = useFreezeIdentity();
const unfreezeIdentity = useUnfreezeIdentity();
const identities: IdentityRow[] = (data?.data as IdentityRow[]) || [];
const total = data?.pagination?.total_items || 0;
const totalPages = total ? Math.ceil(total / pageSize) : 0;
const filteredIdentities = searchTerm
? identities.filter((i) => {
const q = searchTerm.toLowerCase();
return (
i.login.toLowerCase().includes(q) ||
(i.display_name || "").toLowerCase().includes(q) ||
(i.roles || []).some((r) => r.toLowerCase().includes(q))
);
})
: identities;
const handleToggleFreeze = async (identity: IdentityRow) => {
const action = identity.frozen ? "unfreeze" : "freeze";
if (
!window.confirm(
"Are you sure you want to " +
action +
' identity "' +
identity.login +
'"?',
)
)
return;
try {
if (identity.frozen) {
await unfreezeIdentity.mutateAsync(identity.id);
} else {
await freezeIdentity.mutateAsync(identity.id);
}
} catch (err) {
console.error("Failed to " + action + " identity:", err);
}
};
return (
<>
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Users className="w-5 h-5 text-blue-600" />
Identities
</h2>
<p className="mt-1 text-sm text-gray-600">
Manage user and service identities, their roles, and permission
assignments
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
Create Identity
</button>
</div>
</div>
<div className="bg-white rounded-lg shadow mb-6 p-4">
<div className="max-w-md">
<label
htmlFor="identity-search"
className="block text-sm font-medium text-gray-700 mb-1"
>
<div className="flex items-center gap-2">
<Search className="w-4 h-4" />
Search Identities
</div>
</label>
<input
id="identity-search"
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(1);
}}
placeholder="Search by login, display name, or role..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{filteredIdentities.length > 0 && (
<div className="mt-3 text-sm text-gray-600">
Showing {filteredIdentities.length} of {total} identities
{searchTerm && " (filtered)"}
</div>
)}
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="p-12 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading identities...</p>
</div>
) : error ? (
<div className="p-12 text-center">
<p className="text-red-600">Failed to load identities</p>
<p className="text-sm text-gray-600 mt-2">
{error instanceof Error ? error.message : "Unknown error"}
</p>
</div>
) : !filteredIdentities || filteredIdentities.length === 0 ? (
<div className="p-12 text-center">
<Users className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-4 text-gray-600">No identities found</p>
<p className="text-sm text-gray-500 mt-1">
{searchTerm
? "Try adjusting your search"
: "Create your first identity to get started"}
</p>
{!searchTerm && (
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Create Identity
</button>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Login
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Display Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredIdentities.map((identity) => (
<tr key={identity.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<Link
to={"/access-control/identities/" + identity.id}
className="flex items-center gap-2 group"
>
<User className="w-4 h-4 text-gray-400 group-hover:text-blue-500" />
<span className="text-sm font-medium text-blue-600 group-hover:text-blue-800 group-hover:underline">
{identity.login}
</span>
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">
{identity.display_name || "—"}
</span>
</td>
<td className="px-6 py-4">
<RoleBadges roles={identity.roles || []} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
{identity.frozen ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
<Snowflake className="w-3 h-3" />
Frozen
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">
Active
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => handleToggleFreeze(identity)}
disabled={
freezeIdentity.isPending ||
unfreezeIdentity.isPending
}
className={
identity.frozen
? "inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 disabled:opacity-50 transition-colors"
: "inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 border border-blue-200 disabled:opacity-50 transition-colors"
}
title={
identity.frozen
? "Unfreeze identity"
: "Freeze identity"
}
>
{identity.frozen ? (
<>
<Sun className="w-3 h-3" />
Unfreeze
</>
) : (
<>
<Snowflake className="w-3 h-3" />
Freeze
</>
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
<p className="text-sm text-gray-700">
Page <span className="font-medium">{page}</span> of{" "}
<span className="font-medium">{totalPages}</span>
</p>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="relative inline-flex items-center px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
)}
</>
)}
</div>
{showCreateModal && (
<CreateIdentityModal onClose={() => setShowCreateModal(false)} />
)}
</>
);
}
function PermissionSetsTab() {
const [searchTerm, setSearchTerm] = useState("");
const { data: rawData, isLoading, error } = usePermissionSets();
const permissionSets: PermissionSetRow[] =
(rawData as PermissionSetRow[]) || [];
const filteredSets = searchTerm
? permissionSets.filter(
(ps) =>
ps.ref.toLowerCase().includes(searchTerm.toLowerCase()) ||
(ps.label || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(ps.pack_ref || "").toLowerCase().includes(searchTerm.toLowerCase()),
)
: permissionSets;
const grantsCount = (grants: unknown): number => {
if (Array.isArray(grants)) return grants.length;
if (typeof grants === "object" && grants !== null)
return Object.keys(grants).length;
return 0;
};
return (
<>
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-indigo-600" />
Permission Sets
</h2>
<p className="mt-1 text-sm text-gray-600">
Browse permission sets and manage their role assignments
</p>
</div>
<div className="bg-white rounded-lg shadow mb-6 p-4">
<div className="max-w-md">
<label
htmlFor="permset-search"
className="block text-sm font-medium text-gray-700 mb-1"
>
<div className="flex items-center gap-2">
<Search className="w-4 h-4" />
Search Permission Sets
</div>
</label>
<input
id="permset-search"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by ref, label, or pack..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{filteredSets.length > 0 && (
<div className="mt-3 text-sm text-gray-600">
Showing {filteredSets.length} of {permissionSets.length} permission
sets{searchTerm && " (filtered)"}
</div>
)}
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="p-12 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading permission sets...</p>
</div>
) : error ? (
<div className="p-12 text-center">
<p className="text-red-600">Failed to load permission sets</p>
<p className="text-sm text-gray-600 mt-2">
{error instanceof Error ? error.message : "Unknown error"}
</p>
</div>
) : !filteredSets || filteredSets.length === 0 ? (
<div className="p-12 text-center">
<ShieldCheck className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-4 text-gray-600">No permission sets found</p>
<p className="text-sm text-gray-500 mt-1">
{searchTerm
? "Try adjusting your search"
: "Permission sets are defined in packs"}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reference
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Label
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Pack
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Grants
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredSets.map((ps) => (
<tr key={ps.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<Link
to={"/access-control/permission-sets/" + ps.ref}
className="flex items-center gap-2 group"
>
<Shield className="w-4 h-4 text-indigo-400 group-hover:text-indigo-600" />
<span className="text-sm font-mono font-medium text-blue-600 group-hover:text-blue-800 group-hover:underline">
{ps.ref}
</span>
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">
{ps.label || "\u2014"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{ps.pack_ref ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<Package className="w-3 h-3" />
{ps.pack_ref}
</span>
) : (
<span className="text-sm text-gray-400">
{"\u2014"}
</span>
)}
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{ps.roles && ps.roles.length > 0 ? (
ps.roles.map((ra) => (
<span
key={ra.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
>
<Tag className="w-3 h-3" />
{ra.role}
</span>
))
) : (
<span className="text-xs text-gray-400">None</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">
{grantsCount(ps.grants)} grant
{grantsCount(ps.grants) !== 1 ? "s" : ""}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
export default function AccessControlPage() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "identities";
const setTab = (tab: string) => {
setSearchParams({ tab });
};
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<Shield className="w-8 h-8 text-indigo-600" />
Access Control
</h1>
<p className="mt-2 text-gray-600">
Manage identities, permission sets, and role-based access control
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setTab("identities")}
className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === "identities"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
Identities
</div>
</button>
<button
onClick={() => setTab("permission-sets")}
className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === "permission-sets"
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
Permission Sets
</div>
</button>
</nav>
</div>
{activeTab === "identities" ? <IdentitiesTab /> : <PermissionSetsTab />}
</div>
);
}

View File

@@ -0,0 +1,368 @@
import { useState } from "react";
import { useParams, Link } from "react-router-dom";
import {
Shield,
ArrowLeft,
Trash2,
Plus,
Tag,
Snowflake,
Sun,
ShieldCheck,
User,
FileJson,
Search,
} from "lucide-react";
import {
useIdentity,
usePermissionSets,
useCreateIdentityRoleAssignment,
useDeleteIdentityRoleAssignment,
useCreatePermissionAssignment,
useDeletePermissionAssignment,
useFreezeIdentity,
useUnfreezeIdentity,
} from "@/hooks/usePermissions";
interface RoleAssignment {
id: number;
identity_id: number;
role: string;
source: string;
managed: boolean;
created: string;
updated: string;
}
interface DirectPermission {
id: number;
identity_id: number;
permission_set_id: number;
permission_set_ref: string;
created: string;
}
interface IdentityDetail {
id: number;
login: string;
display_name: string | null;
frozen: boolean;
attributes: Record<string, unknown>;
roles: RoleAssignment[];
direct_permissions: DirectPermission[];
}
export default function IdentityDetailPage() {
const { id: idParam } = useParams<{ id: string }>();
const id = Number(idParam) || 0;
const { data: rawData, isLoading, error } = useIdentity(id);
const { data: permissionSets } = usePermissionSets();
const createRoleMutation = useCreateIdentityRoleAssignment();
const deleteRoleMutation = useDeleteIdentityRoleAssignment();
const createPermMutation = useCreatePermissionAssignment();
const deletePermMutation = useDeletePermissionAssignment();
const freezeMutation = useFreezeIdentity();
const unfreezeMutation = useUnfreezeIdentity();
const [showAddRole, setShowAddRole] = useState(false);
const [newRole, setNewRole] = useState("");
const [showAssignPerm, setShowAssignPerm] = useState(false);
const [selectedPermSetRef, setSelectedPermSetRef] = useState("");
const [permSetSearch, setPermSetSearch] = useState("");
const identity = (rawData as unknown as { data: IdentityDetail } | undefined)?.data;
const handleAddRole = async (e: React.FormEvent) => {
e.preventDefault();
if (!newRole.trim()) return;
try {
await createRoleMutation.mutateAsync({ identityId: id, role: newRole.trim() });
setNewRole("");
setShowAddRole(false);
} catch (err) {
console.error("Failed to add role:", err);
}
};
const handleDeleteRole = async (assignmentId: number, role: string) => {
if (window.confirm("Remove role \"" + role + "\" from this identity?")) {
try {
await deleteRoleMutation.mutateAsync(assignmentId);
} catch (err) {
console.error("Failed to delete role assignment:", err);
}
}
};
const handleAssignPermission = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedPermSetRef) return;
try {
await createPermMutation.mutateAsync({ identity_id: id, permission_set_ref: selectedPermSetRef });
setSelectedPermSetRef("");
setPermSetSearch("");
setShowAssignPerm(false);
} catch (err) {
console.error("Failed to assign permission set:", err);
}
};
const handleDeletePermission = async (assignmentId: number, ref: string) => {
if (window.confirm("Remove permission set \"" + ref + "\" from this identity?")) {
try {
await deletePermMutation.mutateAsync(assignmentId);
} catch (err) {
console.error("Failed to remove permission assignment:", err);
}
}
};
const handleToggleFreeze = async () => {
if (!identity) return;
const action = identity.frozen ? "unfreeze" : "freeze";
if (!window.confirm("Are you sure you want to " + action + " identity \"" + identity.login + "\"?")) return;
try {
if (identity.frozen) {
await unfreezeMutation.mutateAsync(id);
} else {
await freezeMutation.mutateAsync(id);
}
} catch (err) {
console.error("Failed to " + action + " identity:", err);
}
};
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
const assignedPermSetRefs = new Set(identity?.direct_permissions?.map((p) => p.permission_set_ref) ?? []);
const availablePermSets = (permissionSets ?? []).filter((ps) => !assignedPermSetRefs.has(ps.ref));
const filteredAvailablePermSets = permSetSearch.trim()
? availablePermSets.filter((ps) =>
ps.ref.toLowerCase().includes(permSetSearch.toLowerCase()) ||
(ps.label ?? "").toLowerCase().includes(permSetSearch.toLowerCase())
)
: availablePermSets;
if (isLoading) {
return (
<div className="p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-3 text-sm text-gray-500">Loading identity...</p>
</div>
</div>
</div>
);
}
if (error || !identity) {
return (
<div className="p-6">
<Link to="/access-control" className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 mb-6">
<ArrowLeft className="w-4 h-4" /> Back to Access Control
</Link>
<div className="bg-white rounded-lg shadow p-12 text-center">
<Shield className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-4 text-red-600">Failed to load identity</p>
<p className="text-sm text-gray-500 mt-1">{error instanceof Error ? error.message : "Identity not found"}</p>
</div>
</div>
);
}
return (
<div className="p-6 max-w-5xl">
<Link to="/access-control" className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 mb-6">
<ArrowLeft className="w-4 h-4" /> Back to Access Control
</Link>
{/* Header */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="bg-blue-100 p-3 rounded-full">
<User className="w-6 h-6 text-blue-600" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{identity.login}</h1>
{identity.frozen && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
<Snowflake className="w-3 h-3" /> Frozen
</span>
)}
</div>
{identity.display_name && <p className="text-gray-600 mt-1">{identity.display_name}</p>}
<p className="text-sm text-gray-400 mt-1">ID: {identity.id}</p>
</div>
</div>
<button onClick={handleToggleFreeze} disabled={freezeMutation.isPending || unfreezeMutation.isPending}
className={"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 " + (identity.frozen ? "bg-green-50 text-green-700 hover:bg-green-100 border border-green-200" : "bg-blue-50 text-blue-700 hover:bg-blue-100 border border-blue-200")}>
{identity.frozen ? (<><Sun className="w-4 h-4" /> Unfreeze</>) : (<><Snowflake className="w-4 h-4" /> Freeze</>)}
</button>
</div>
</div>
{/* Roles Section */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-violet-500" />
<h2 className="text-lg font-semibold text-gray-900">Role Assignments</h2>
<span className="text-sm text-gray-500">({identity.roles?.length || 0})</span>
</div>
<button onClick={() => setShowAddRole(!showAddRole)} className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors">
<Plus className="w-4 h-4" /> Add Role
</button>
</div>
{showAddRole && (
<form onSubmit={handleAddRole} className="flex items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg">
<input type="text" value={newRole} onChange={(e) => setNewRole(e.target.value)} placeholder="Role name (e.g. admin, operator, viewer)" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-500 text-sm" autoFocus />
<button type="submit" disabled={!newRole.trim() || createRoleMutation.isPending} className="px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 disabled:opacity-50 text-sm transition-colors">
{createRoleMutation.isPending ? "Adding..." : "Add"}
</button>
<button type="button" onClick={() => { setShowAddRole(false); setNewRole(""); }} className="px-4 py-2 text-gray-600 hover:text-gray-900 text-sm">Cancel</button>
</form>
)}
{identity.roles && identity.roles.length > 0 ? (
<div className="divide-y divide-gray-100">
{identity.roles.map((ra) => (
<div key={ra.id} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">{ra.role}</span>
<span className="text-xs text-gray-500">Source: {ra.source}</span>
{ra.managed && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">Managed</span>
)}
<span className="text-xs text-gray-400">{formatDate(ra.created)}</span>
</div>
{!ra.managed && (
<button onClick={() => handleDeleteRole(ra.id, ra.role)} className="text-red-400 hover:text-red-600 p-1" title="Remove role">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
) : (
<div className="text-center py-6">
<Tag className="mx-auto h-8 w-8 text-gray-300" />
<p className="mt-2 text-sm text-gray-500">No roles assigned</p>
</div>
)}
</div>
{/* Direct Permission Sets Section */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-indigo-500" />
<h2 className="text-lg font-semibold text-gray-900">Direct Permission Sets</h2>
<span className="text-sm text-gray-500">({identity.direct_permissions?.length || 0})</span>
</div>
<button onClick={() => setShowAssignPerm(!showAssignPerm)} className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
<Plus className="w-4 h-4" /> Assign Permission Set
</button>
</div>
{showAssignPerm && (
<form onSubmit={handleAssignPermission} className="mb-4 p-3 bg-gray-50 rounded-lg space-y-2">
{selectedPermSetRef ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-1.5 bg-indigo-100 text-indigo-800 rounded-md text-sm font-mono flex-1">
<Shield className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{selectedPermSetRef}</span>
<button type="button" onClick={() => { setSelectedPermSetRef(""); setPermSetSearch(""); }} className="ml-auto text-indigo-500 hover:text-indigo-700">
&#x2715;
</button>
</div>
<button type="submit" disabled={createPermMutation.isPending} className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm transition-colors whitespace-nowrap">
{createPermMutation.isPending ? "Assigning..." : "Assign"}
</button>
<button type="button" onClick={() => { setShowAssignPerm(false); setSelectedPermSetRef(""); setPermSetSearch(""); }} className="px-3 py-2 text-gray-600 hover:text-gray-900 text-sm">Cancel</button>
</div>
) : (
<div className="relative">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
<input
type="text"
value={permSetSearch}
onChange={(e) => setPermSetSearch(e.target.value)}
placeholder="Search permission sets by ref or label..."
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
autoFocus
/>
</div>
<button type="button" onClick={() => { setShowAssignPerm(false); setPermSetSearch(""); }} className="px-3 py-2 text-gray-600 hover:text-gray-900 text-sm">Cancel</button>
</div>
{permSetSearch.trim() && (
<div className="mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-52 overflow-y-auto">
{filteredAvailablePermSets.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500">No matching permission sets</div>
) : (
filteredAvailablePermSets.map((ps) => (
<button
key={ps.ref}
type="button"
onClick={() => { setSelectedPermSetRef(ps.ref); setPermSetSearch(""); }}
className="w-full flex items-start gap-3 px-4 py-2.5 text-left hover:bg-indigo-50 transition-colors"
>
<Shield className="w-4 h-4 text-indigo-400 flex-shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-mono font-medium text-gray-900 truncate">{ps.ref}</div>
{ps.label && <div className="text-xs text-gray-500 truncate">{ps.label}</div>}
</div>
</button>
))
)}
</div>
)}
</div>
)}
</form>
)}
{identity.direct_permissions && identity.direct_permissions.length > 0 ? (
<div className="divide-y divide-gray-100">
{identity.direct_permissions.map((dp) => (
<div key={dp.id} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Shield className="w-4 h-4 text-indigo-400" />
<span className="text-sm font-mono font-medium text-gray-900">{dp.permission_set_ref}</span>
<span className="text-xs text-gray-400">Assigned {formatDate(dp.created)}</span>
</div>
<button onClick={() => handleDeletePermission(dp.id, dp.permission_set_ref)} className="text-red-400 hover:text-red-600 p-1" title="Remove permission set">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
) : (
<div className="text-center py-6">
<ShieldCheck className="mx-auto h-8 w-8 text-gray-300" />
<p className="mt-2 text-sm text-gray-500">No direct permission sets assigned</p>
<p className="text-xs text-gray-400 mt-1">Permission sets can also be inherited through roles</p>
</div>
)}
</div>
{/* Attributes Section */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-2 mb-4">
<FileJson className="w-5 h-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Attributes</h2>
</div>
<pre className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto max-h-64 overflow-y-auto font-mono leading-relaxed">
{JSON.stringify(identity.attributes, null, 2)}
</pre>
</div>
</div>
);
}

View File

@@ -0,0 +1,619 @@
import { useState } from "react";
import { useParams, Link } from "react-router-dom";
import {
ArrowLeft,
BarChart3,
Globe,
History,
Key,
MessageSquare,
Package,
Plus,
Shield,
Tag,
Trash2,
Users,
} from "lucide-react";
import {
usePermissionSets,
useCreatePermissionSetRoleAssignment,
useDeletePermissionSetRoleAssignment,
} from "@/hooks/usePermissions";
import { navIcons } from "@/components/layout/navIcons";
// ── Domain interfaces ──────────────────────────────────────────────────────────
interface PermissionSetRoleAssignment {
id: number;
permission_set_id: number;
permission_set_ref: string | null;
role: string;
created: string;
}
interface PermissionSetWithRoles {
id: number;
ref: string;
pack_ref?: string | null;
label?: string | null;
description?: string | null;
grants: unknown;
roles?: PermissionSetRoleAssignment[];
}
// ── Grants model ───────────────────────────────────────────────────────────────
interface GrantConstraints {
pack_refs?: string[];
owner?: string; // "self" | "any" | "none"
owner_types?: string[];
owner_refs?: string[];
visibility?: string[];
execution_scope?: string; // "self" | "descendants" | "any"
refs?: string[];
ids?: number[];
encrypted?: boolean;
attributes?: Record<string, unknown>;
}
interface ParsedGrant {
resource: string;
actions: string[];
constraints?: GrantConstraints;
}
function parseGrants(raw: unknown): ParsedGrant[] {
if (!Array.isArray(raw)) return [];
return raw.filter(
(g): g is ParsedGrant =>
typeof g === "object" &&
g !== null &&
typeof (g as ParsedGrant).resource === "string" &&
Array.isArray((g as ParsedGrant).actions),
);
}
// ── Display metadata ───────────────────────────────────────────────────────────
type ResourceMeta = {
icon: React.ComponentType<{ className?: string }>;
color: string;
label: string;
};
const RESOURCE_META: Record<string, ResourceMeta> = {
packs: { icon: navIcons.packs, color: "text-green-600", label: "Packs" },
actions: {
icon: navIcons.actions,
color: "text-yellow-500",
label: "Actions",
},
rules: { icon: navIcons.rules, color: "text-blue-600", label: "Rules" },
triggers: {
icon: navIcons.triggers,
color: "text-orange-500",
label: "Triggers",
},
executions: {
icon: navIcons.executions,
color: "text-purple-600",
label: "Executions",
},
events: { icon: navIcons.events, color: "text-cyan-600", label: "Events" },
enforcements: {
icon: navIcons.enforcements,
color: "text-red-500",
label: "Enforcements",
},
inquiries: {
icon: MessageSquare,
color: "text-teal-600",
label: "Inquiries",
},
keys: { icon: navIcons.keys, color: "text-amber-600", label: "Keys" },
artifacts: {
icon: navIcons.artifacts,
color: "text-indigo-500",
label: "Artifacts",
},
webhooks: { icon: Globe, color: "text-sky-600", label: "Webhooks" },
analytics: { icon: BarChart3, color: "text-rose-500", label: "Analytics" },
history: { icon: History, color: "text-gray-500", label: "History" },
identities: { icon: Users, color: "text-blue-700", label: "Identities" },
permissions: {
icon: navIcons.accessControl,
color: "text-indigo-600",
label: "Permissions",
},
runtimes: {
icon: navIcons.runtimes,
color: "text-blue-600",
label: "Runtimes",
},
sensors: {
icon: navIcons.sensors,
color: "text-purple-600",
label: "Sensors",
},
};
const ACTION_STYLE: Record<string, string> = {
read: "bg-slate-100 text-slate-700",
create: "bg-emerald-100 text-emerald-800",
update: "bg-amber-100 text-amber-800",
delete: "bg-red-100 text-red-800",
execute: "bg-violet-100 text-violet-800",
cancel: "bg-orange-100 text-orange-800",
respond: "bg-cyan-100 text-cyan-800",
manage: "bg-indigo-100 text-indigo-800",
};
// ── Constraint chips ───────────────────────────────────────────────────────────
function ConstraintChips({ c }: { c: GrantConstraints }) {
const chips: React.ReactNode[] = [];
if (c.pack_refs?.length) {
chips.push(
<span
key="pack_refs"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-green-50 text-green-700 border border-green-200"
>
<Package className="w-3 h-3 shrink-0" />
{c.pack_refs.join(", ")}
</span>,
);
}
if (c.owner) {
const labels: Record<string, string> = {
self: "Own resources",
any: "Any owner",
none: "No owner",
};
chips.push(
<span
key="owner"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-700 border border-blue-200"
>
Owner: {labels[c.owner] ?? c.owner}
</span>,
);
}
if (c.owner_types?.length) {
chips.push(
<span
key="owner_types"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200"
>
Type: {c.owner_types.join(", ")}
</span>,
);
}
if (c.owner_refs?.length) {
chips.push(
<span
key="owner_refs"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200 font-mono"
>
Owner: {c.owner_refs.join(", ")}
</span>,
);
}
if (c.visibility?.length) {
chips.push(
<span
key="visibility"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-sky-50 text-sky-700 border border-sky-200"
>
Visibility: {c.visibility.join(", ")}
</span>,
);
}
if (c.execution_scope) {
const labels: Record<string, string> = {
self: "Own executions",
descendants: "Own + children",
any: "All executions",
};
chips.push(
<span
key="execution_scope"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-purple-50 text-purple-700 border border-purple-200"
>
Scope: {labels[c.execution_scope] ?? c.execution_scope}
</span>,
);
}
if (c.refs?.length) {
chips.push(
<span
key="refs"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200 font-mono"
>
{c.refs.join(", ")}
</span>,
);
}
if (c.ids?.length) {
chips.push(
<span
key="ids"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200"
>
IDs: {c.ids.join(", ")}
</span>,
);
}
if (c.encrypted !== undefined) {
chips.push(
<span
key="encrypted"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-amber-50 text-amber-700 border border-amber-200"
>
<Key className="w-3 h-3 shrink-0" />
{c.encrypted ? "Encrypted only" : "Unencrypted only"}
</span>,
);
}
if (c.attributes && Object.keys(c.attributes).length > 0) {
const text = Object.entries(c.attributes)
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
.join(", ");
chips.push(
<span
key="attributes"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-rose-50 text-rose-700 border border-rose-200 font-mono"
>
{text}
</span>,
);
}
if (chips.length === 0) {
return <span className="text-xs text-gray-300"></span>;
}
return <div className="flex flex-col gap-1">{chips}</div>;
}
// ── Grants table ───────────────────────────────────────────────────────────────
function GrantsView({ grants }: { grants: ParsedGrant[] }) {
if (grants.length === 0) {
return (
<div className="p-8 text-center">
<Shield className="mx-auto h-8 w-8 text-gray-300" />
<p className="mt-2 text-sm text-gray-500">No grants defined</p>
</div>
);
}
const hasConstraints = grants.some(
(g) => g.constraints && Object.keys(g.constraints).length > 0,
);
return (
<div className="overflow-y-auto max-h-[28rem]">
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0 z-10">
<tr>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-36">
Resource
</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Permissions
</th>
{hasConstraints && (
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Conditions
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{grants.map((grant, i) => {
const meta = RESOURCE_META[grant.resource];
const Icon = meta?.icon ?? Shield;
const iconColor = meta?.color ?? "text-gray-400";
const label =
meta?.label ??
grant.resource.charAt(0).toUpperCase() + grant.resource.slice(1);
return (
<tr key={i} className="hover:bg-gray-50">
<td className="px-4 py-2.5 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<Icon className={`w-3.5 h-3.5 shrink-0 ${iconColor}`} />
<span className="text-sm font-medium text-gray-800">
{label}
</span>
</div>
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{grant.actions.map((action) => (
<span
key={action}
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
ACTION_STYLE[action] ?? "bg-gray-100 text-gray-700"
}`}
>
{action}
</span>
))}
</div>
</td>
{hasConstraints && (
<td className="px-4 py-2.5">
{grant.constraints &&
Object.keys(grant.constraints).length > 0 ? (
<ConstraintChips c={grant.constraints} />
) : (
<span className="text-xs text-gray-300"></span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────────
export default function PermissionSetDetailPage() {
const { ref } = useParams<{ ref: string }>();
const { data: permissionSetsRaw, isLoading, error } = usePermissionSets();
const createRoleAssignment = useCreatePermissionSetRoleAssignment();
const deleteRoleAssignment = useDeletePermissionSetRoleAssignment();
const [newRole, setNewRole] = useState("");
const [showAddRole, setShowAddRole] = useState(false);
const permissionSets = permissionSetsRaw as
| PermissionSetWithRoles[]
| undefined;
const permissionSet = permissionSets?.find((ps) => ps.ref === ref);
const handleAddRole = async (e: React.FormEvent) => {
e.preventDefault();
if (!newRole.trim()) return;
try {
await createRoleAssignment.mutateAsync({
permissionSetId: permissionSet!.id,
role: newRole.trim(),
});
setNewRole("");
setShowAddRole(false);
} catch (err) {
console.error("Failed to add role:", err);
}
};
const handleDeleteRole = async (assignmentId: number, roleName: string) => {
if (window.confirm(`Remove role "${roleName}" from this permission set?`)) {
try {
await deleteRoleAssignment.mutateAsync(assignmentId);
} catch (err) {
console.error("Failed to delete role assignment:", err);
}
}
};
if (isLoading) {
return (
<div className="p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-3 text-sm text-gray-500">
Loading permission set
</p>
</div>
</div>
</div>
);
}
if (error || !permissionSet) {
return (
<div className="p-6">
<Link
to="/access-control?tab=permission-sets"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 mb-6"
>
<ArrowLeft className="w-4 h-4" />
Back to Access Control
</Link>
<div className="bg-white rounded-lg shadow p-12 text-center">
<Shield className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-4 text-red-600">
{error
? "Failed to load permission set"
: "Permission set not found"}
</p>
</div>
</div>
);
}
const roles = permissionSet.roles || [];
const parsedGrants = parseGrants(permissionSet.grants);
return (
<div className="p-6">
{/* Back link */}
<Link
to="/access-control?tab=permission-sets"
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 mb-6"
>
<ArrowLeft className="w-4 h-4" />
Back to Access Control
</Link>
{/* Header */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<Shield className="w-6 h-6 text-indigo-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 font-mono">
{permissionSet.ref}
</h1>
{permissionSet.label && (
<p className="text-lg text-gray-700 mt-0.5">
{permissionSet.label}
</p>
)}
{permissionSet.description && (
<p className="text-sm text-gray-500 mt-1">
{permissionSet.description}
</p>
)}
</div>
</div>
{permissionSet.pack_ref && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
<Package className="w-3.5 h-3.5" />
{permissionSet.pack_ref}
</span>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Roles Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">
Role Assignments
</h2>
<span className="text-sm text-gray-500">({roles.length})</span>
</div>
<button
onClick={() => setShowAddRole(!showAddRole)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md transition-colors"
>
<Plus className="w-4 h-4" />
Add Role
</button>
</div>
{showAddRole && (
<form
onSubmit={handleAddRole}
className="px-6 py-3 bg-blue-50 border-b border-blue-100 flex items-center gap-3"
>
<input
type="text"
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
placeholder="Enter role name…"
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
<button
type="submit"
disabled={!newRole.trim() || createRoleAssignment.isPending}
className="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{createRoleAssignment.isPending ? "Adding…" : "Add"}
</button>
<button
type="button"
onClick={() => {
setShowAddRole(false);
setNewRole("");
}}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
>
Cancel
</button>
</form>
)}
{createRoleAssignment.isError && (
<div className="px-6 py-2 bg-red-50 border-b border-red-100">
<p className="text-sm text-red-600">
Failed to add role.{" "}
{createRoleAssignment.error instanceof Error
? createRoleAssignment.error.message
: "Please try again."}
</p>
</div>
)}
<div className="divide-y divide-gray-100">
{roles.length === 0 ? (
<div className="px-6 py-8 text-center">
<Tag className="mx-auto h-8 w-8 text-gray-300" />
<p className="mt-2 text-sm text-gray-500">
No roles assigned to this permission set
</p>
<p className="text-xs text-gray-400 mt-1">
Identities with a matching role will inherit these grants
</p>
</div>
) : (
roles.map((assignment) => (
<div
key={assignment.id}
className="px-6 py-3 flex items-center justify-between hover:bg-gray-50"
>
<div className="flex items-center gap-3">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">
{assignment.role}
</span>
<span className="text-xs text-gray-400">
Added {new Date(assignment.created).toLocaleDateString()}
</span>
</div>
<button
onClick={() =>
handleDeleteRole(assignment.id, assignment.role)
}
className="text-red-400 hover:text-red-600 p-1 rounded transition-colors"
title="Remove role assignment"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</div>
{/* Grants Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
<Shield className="w-5 h-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Grants</h2>
<span className="text-sm text-gray-500">
({parsedGrants.length})
</span>
</div>
<GrantsView grants={parsedGrants} />
</div>
</div>
</div>
);
}