diff --git a/src/components/Tokens/Add/CreateTokenForm.tsx b/src/components/Tokens/Add/CreateTokenForm.tsx index 34bcf9073..04420f9d4 100644 --- a/src/components/Tokens/Add/CreateTokenForm.tsx +++ b/src/components/Tokens/Add/CreateTokenForm.tsx @@ -12,13 +12,13 @@ import { Button } from "../../../ui/Buttons/Button"; import { Modal } from "../../../ui/Modal"; import FormikTextInput from "../../Forms/Formik/FormikTextInput"; import FormikSelectDropdown from "../../Forms/Formik/FormikSelectDropdown"; -import FormikCheckbox from "../../Forms/Formik/FormikCheckbox"; import { toastError, toastSuccess } from "../../Toast/toast"; import { OBJECTS, getActionsForObject, getAllObjectActions } from "../tokenUtils"; +import TokenScopeFieldsGroup from "./TokenScopeFieldsGroup"; export type TokenFormValues = CreateTokenRequest & { objectActions: Record; @@ -60,21 +60,36 @@ export default function CreateTokenForm({ ); if (isMcpSetup) { + // For MCP setup, pre-select mcp:* (will be shown in Custom mode) allObjectActions .filter((objAction) => objAction.startsWith("mcp:")) .forEach((mcpAction) => { initialActions[mcpAction] = true; }); + } else { + // For regular token creation, apply "Read" preset by default + // This matches the default selected scope in TokenScopeFieldsGroup + OBJECTS.forEach((object) => { + const actions = getActionsForObject(object); + if (actions.includes("read")) { + initialActions[`${object}:read`] = true; + } else if (object === "mcp" && actions.includes("*")) { + initialActions[`${object}:*`] = true; + } + }); } return initialActions; }; - const handleSubmit = (values: TokenFormValues) => { + const handleFormSubmit = (values: TokenFormValues) => { const selectedScopes: Permission[] = Object.entries(values.objectActions) - .filter(([_, isChecked]) => isChecked) + .filter(([_, isChecked]) => isChecked === true) .map(([scopeId]) => { - const [object, action] = scopeId.split(":"); + // Split by first colon only to handle cases like "playbooks:playbook:run" + const colonIndex = scopeId.indexOf(":"); + const object = scopeId.substring(0, colonIndex); + const action = scopeId.substring(colonIndex + 1); return { subject: "", // subject is handled at server object, @@ -109,7 +124,7 @@ export default function CreateTokenForm({ objectActions: getInitialObjectActions() }} enableReinitialize - onSubmit={handleSubmit} + onSubmit={handleFormSubmit} validate={(values) => { const errors: any = {}; if (!values.name.trim()) { @@ -139,37 +154,7 @@ export default function CreateTokenForm({ hint="When this token should expire" /> -
-
- Scopes -

- Select the permissions to grant to this token -

-
-
- {OBJECTS.map((object) => ( -
-
- {object} -
-
- {getActionsForObject(object).map((action) => { - const scopeKey = `${object}:${action}`; - return ( - - ); - })} -
-
- ))} -
-
+ diff --git a/src/components/Tokens/Add/TokenScopeFieldsGroup.tsx b/src/components/Tokens/Add/TokenScopeFieldsGroup.tsx new file mode 100644 index 000000000..7c8144246 --- /dev/null +++ b/src/components/Tokens/Add/TokenScopeFieldsGroup.tsx @@ -0,0 +1,420 @@ +import { useFormikContext } from "formik"; +import { useCallback, useState, useEffect } from "react"; +import { QuestionMarkCircleIcon } from "@heroicons/react/solid"; +import { Switch } from "../../../ui/FormControls/Switch"; +import { OBJECTS, getActionsForObject } from "../tokenUtils"; +import { TokenFormValues } from "./CreateTokenForm"; + +const ScopeOptions = ["Read", "Write", "Admin", "Custom"] as const; +type ScopeType = (typeof ScopeOptions)[number]; + +// Scope level descriptions +const SCOPE_DESCRIPTIONS: Record, string> = { + Read: `Grant read-only access to all resources (${OBJECTS.join(", ")})`, + Write: `Grant read and create permissions for all resources (${OBJECTS.join(", ")})`, + Admin: `Grant full access (read, create, update, delete) to all resources (${OBJECTS.join(", ")})` +}; + +// Permission level descriptions for each object type +const PERMISSION_DESCRIPTIONS: Record> = { + configs: { + None: "No access to configuration items", + Read: "View configuration items and their details", + Write: "View and create configuration items", + Admin: "Full control: view, create, update, and delete configs" + }, + canaries: { + None: "No access to health checks", + Read: "View health checks and their status", + Write: "View and create health checks", + Admin: "Full control: view, create, update, and delete health checks" + }, + components: { + None: "No access to topology components", + Read: "View topology components and relationships", + Write: "View and create topology components", + Admin: "Full control: view, create, update, and delete components" + }, + incidents: { + None: "No access to incidents", + Read: "View incidents and their details", + Write: "View and create incidents", + Admin: "Full control: view, create, update, and delete incidents" + }, + playbooks: { + None: "No access to playbooks", + Read: "View playbook definitions", + Run: "View and execute playbooks", + Approve: "View, run, and approve playbook executions", + Write: "Full control: view, run, approve, create, and delete playbooks" + }, + people: { + None: "No access to people/team members", + Read: "View people and team member details", + Write: "View and add people", + Admin: "Full control: view, add, update, and remove people" + }, + teams: { + None: "No access to teams", + Read: "View teams and their members", + Write: "View and create teams", + Admin: "Full control: view, create, update, and delete teams" + }, + connections: { + None: "No access to connections", + Read: "View connection configurations", + Write: "View and create connections", + Admin: "Full control: view, create, update, and delete connections" + }, + notifications: { + None: "No access to notifications", + Read: "View notification settings", + Write: "View and create notifications", + Admin: "Full control: view, create, update, and delete notifications" + }, + config_items: { + None: "No access to catalog items", + Read: "View catalog items", + Write: "View and create catalog items", + Admin: "Full control: view, create, update, and delete catalog items" + }, + logs: { + None: "No access to logs", + Read: "View and search logs", + Write: "View and export logs", + Admin: "Full control: view, export, and manage logs" + }, + mcp: { + None: "No access to MCP (Model Context Protocol)", + Admin: "Full access to MCP servers and tools" + } +}; + +const ObjectPermissionOptions = ["None", "Read", "Write", "Admin"] as const; +const McpPermissionOptions = ["None", "Admin"] as const; +const PlaybookPermissionOptions = [ + "None", + "Read", + "Run", + "Approve", + "Write" +] as const; +type ObjectPermissionType = (typeof ObjectPermissionOptions)[number]; +type McpPermissionType = (typeof McpPermissionOptions)[number]; +type PlaybookPermissionType = (typeof PlaybookPermissionOptions)[number]; + +// Component for individual object permission selection +type ObjectPermissionSwitchProps = { + object: string; + isMcpSetup?: boolean; +}; + +function ObjectPermissionSwitch({ + object, + isMcpSetup +}: ObjectPermissionSwitchProps) { + const { setFieldValue, values } = useFormikContext(); + + // Determine current permission level for this object + const getCurrentPermission = (): + | ObjectPermissionType + | McpPermissionType + | PlaybookPermissionType => { + const objectActions = values.objectActions; + + // Special handling for MCP setup mode + if (isMcpSetup && object === "mcp") { + return "Admin"; // Pre-select Admin (which maps to mcp:*) for MCP setup + } + + if (object === "mcp") { + return objectActions["mcp:*"] ? "Admin" : "None"; + } + + if (object === "playbooks") { + // Write: read + create + delete + playbook:run + playbook:approve + const hasWrite = + objectActions[`${object}:read`] && + objectActions[`${object}:create`] && + objectActions[`${object}:delete`] && + objectActions[`${object}:playbook:run`] && + objectActions[`${object}:playbook:approve`]; + + // Approve: read + playbook:run + playbook:approve + const hasApprove = + objectActions[`${object}:read`] && + objectActions[`${object}:playbook:run`] && + objectActions[`${object}:playbook:approve`]; + + // Run: read + playbook:run + playbook:cancel + const hasRun = + objectActions[`${object}:read`] && + objectActions[`${object}:playbook:run`]; + + // Read: just read + const hasRead = objectActions[`${object}:read`]; + + if (hasWrite) return "Write"; + if (hasApprove) return "Approve"; + if (hasRun) return "Run"; + if (hasRead) return "Read"; + return "None"; + } + + // For other objects (non-mcp, non-playbooks) + const hasCrud = ["read", "create", "update", "delete"].every( + (action) => objectActions[`${object}:${action}`] + ); + if (hasCrud) return "Admin"; + if (objectActions[`${object}:read`] && objectActions[`${object}:create`]) + return "Write"; + if (objectActions[`${object}:read`]) return "Read"; + return "None"; + }; + + const [selectedPermission, setSelectedPermission] = useState(() => + getCurrentPermission() + ); + + const handlePermissionChange = useCallback( + (permission: string) => { + const newPermission = permission as + | ObjectPermissionType + | McpPermissionType + | PlaybookPermissionType; + setSelectedPermission(newPermission); + + // Get current objectActions and create a new copy + const newObjectActions = { ...values.objectActions }; + const actions = getActionsForObject(object); + + // Reset all actions for this object first + actions.forEach((action) => { + newObjectActions[`${object}:${action}`] = false; + }); + + // Apply the selected permission level + if (newPermission !== "None") { + if (object === "mcp") { + // MCP only has Admin option (maps to *) + if (newPermission === "Admin") { + newObjectActions["mcp:*"] = true; + } + } else if (object === "playbooks") { + // Each level includes all previous levels + if (newPermission === "Read") { + newObjectActions[`${object}:read`] = true; + } else if (newPermission === "Run") { + // Run = Read + playbook:run + newObjectActions[`${object}:read`] = true; + newObjectActions[`${object}:playbook:run`] = true; + } else if (newPermission === "Approve") { + // Approve = Run + playbook:approve + newObjectActions[`${object}:read`] = true; + newObjectActions[`${object}:playbook:run`] = true; + newObjectActions[`${object}:playbook:approve`] = true; + } else if (newPermission === "Write") { + // Write = Approve + create + delete + newObjectActions[`${object}:read`] = true; + newObjectActions[`${object}:playbook:run`] = true; + newObjectActions[`${object}:playbook:approve`] = true; + newObjectActions[`${object}:create`] = true; + newObjectActions[`${object}:delete`] = true; + } + } else { + // For other objects + if (newPermission === "Read") { + newObjectActions[`${object}:read`] = true; + } else if (newPermission === "Write") { + newObjectActions[`${object}:read`] = true; + newObjectActions[`${object}:create`] = true; + } else if (newPermission === "Admin") { + ["read", "create", "update", "delete"].forEach((action) => { + newObjectActions[`${object}:${action}`] = true; + }); + } + } + } + + // Update form state with the new object + setFieldValue("objectActions", newObjectActions); + }, + [object, setFieldValue, values.objectActions] + ); + + // Get the appropriate options based on object type + const getOptionsForObject = () => { + if (object === "mcp") { + return McpPermissionOptions as unknown as string[]; + } + if (object === "playbooks") { + return PlaybookPermissionOptions as unknown as string[]; + } + return ObjectPermissionOptions as unknown as string[]; + }; + + return ( +
+ +
+ +
+ +
+
+ {getOptionsForObject().map((option) => ( +
+ {option}:{" "} + {PERMISSION_DESCRIPTIONS[object]?.[option] || + "Permission level"} +
+ ))} +
+
+
+
+
+ ); +} + +// Pre-calculate scope mappings outside component to avoid recalculation +const SCOPE_MAPPINGS = { + Read: (() => { + const scopes: Record = {}; + OBJECTS.forEach((object) => { + const actions = getActionsForObject(object); + if (actions.includes("read")) { + scopes[`${object}:read`] = true; + } else if (object === "mcp" && actions.includes("*")) { + scopes[`${object}:*`] = true; + } + }); + return scopes; + })(), + Write: (() => { + const scopes: Record = {}; + OBJECTS.forEach((object) => { + const actions = getActionsForObject(object); + actions.forEach((action) => { + if ( + ["read", "create"].includes(action) || + (object === "mcp" && action === "*") + ) { + scopes[`${object}:${action}`] = true; + } + }); + }); + return scopes; + })(), + Admin: (() => { + const scopes: Record = {}; + OBJECTS.forEach((object) => { + const actions = getActionsForObject(object); + actions.forEach((action) => { + if (object === "playbooks") { + scopes[`${object}:${action}`] = true; + } else if (object === "mcp" && action === "*") { + scopes[`${object}:${action}`] = true; + } else if (!action.startsWith("playbook:")) { + scopes[`${object}:${action}`] = true; + } + }); + }); + return scopes; + })() +}; + +type TokenScopeFieldsGroupProps = { + isMcpSetup?: boolean; +}; + +export default function TokenScopeFieldsGroup({ + isMcpSetup = false +}: TokenScopeFieldsGroupProps) { + const { setFieldValue, values } = useFormikContext(); + + const [selectedScope, setSelectedScope] = useState(() => { + if (isMcpSetup) { + return "Custom"; + } + return "Read"; + }); + + const handleScopeChange = useCallback( + (scope: string) => { + const newScope = scope as ScopeType; + setSelectedScope(newScope); + + // Start with all current keys from form values + const newObjectActions: Record = { + ...values.objectActions + }; + + // Reset all scopes to false + Object.keys(newObjectActions).forEach((key) => { + newObjectActions[key] = false; + }); + + if (newScope !== "Custom") { + // Apply the selected preset (set specific keys to true) + Object.keys(SCOPE_MAPPINGS[newScope]).forEach((key) => { + newObjectActions[key] = SCOPE_MAPPINGS[newScope][key]; + }); + } + // For Custom mode, all values are set to false (None) + // Exception: MCP setup pre-selects mcp:* which is handled by ObjectPermissionSwitch + + setFieldValue("objectActions", newObjectActions); + }, + [setFieldValue, values.objectActions] + ); + + return ( +
+
+ Scopes +

+ Select the permissions to grant to this token +

+
+ +
+
+ +
+ {selectedScope !== "Custom" && ( +
+ {SCOPE_DESCRIPTIONS[selectedScope]} +
+ )} +
+ + {selectedScope === "Custom" && ( +
+ {OBJECTS.map((object) => ( + + ))} +
+ )} +
+ ); +}