From 243c180094014e06684c6af376191cd3dcd0dcf6 Mon Sep 17 00:00:00 2001
From: enyineer
Date: Sun, 26 Jul 2026 04:55:49 +0200
Subject: [PATCH 01/36] fix(command): show palette actions to team-scoped users
The palette filtered commands by GLOBAL access rules only, so a user whose team
holds a create-capability grant (and no global *.manage rule) never saw "Create
Incident" / "Create Maintenance" or their shortcuts - it hid the actions from
exactly the people authorized to run them.
Commands may now declare `manageCapability`, mirroring the gate routes/nav use.
filterByAccessRules admits an item on the global rules OR a team grant on the
declared type; the backend resolves that per request via hasAnyTypeGrant
(includeCreator, so a would-be creator qualifies before owning an instance) and
fails closed. Incident + maintenance commands declare their types.
useGlobalShortcuts drops its userAccessRules re-check: the server-filtered list
is authoritative, the check tested global rules only, and both call sites already
defeated it by passing ["*"].
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Hccsjzvqr6xfhGicNBjRgB
---
.changeset/command-palette-team-scoped.md | 28 +++++
core/command-backend/src/registry.ts | 9 ++
core/command-backend/src/router.ts | 74 ++++++++++++-
core/command-common/src/filter-access.test.ts | 100 ++++++++++++++++++
core/command-common/src/index.ts | 64 +++++++++--
core/command-frontend/src/index.tsx | 32 +++---
core/frontend/src/App.tsx | 2 +-
core/incident-backend/src/index.ts | 7 ++
core/maintenance-backend/src/index.ts | 7 ++
9 files changed, 296 insertions(+), 27 deletions(-)
create mode 100644 .changeset/command-palette-team-scoped.md
create mode 100644 core/command-common/src/filter-access.test.ts
diff --git a/.changeset/command-palette-team-scoped.md b/.changeset/command-palette-team-scoped.md
new file mode 100644
index 000000000..a6fbf85a8
--- /dev/null
+++ b/.changeset/command-palette-team-scoped.md
@@ -0,0 +1,28 @@
+---
+"@checkstack/command-common": minor
+"@checkstack/command-backend": minor
+"@checkstack/command-frontend": minor
+"@checkstack/incident-backend": patch
+"@checkstack/maintenance-backend": patch
+---
+
+Show command-palette actions to team-scoped users
+
+The palette filtered commands against the caller's GLOBAL access rules only, so a
+user whose team holds a create-capability grant - but who holds no global
+`incident.incident.manage` / `maintenance.maintenance.manage` rule - never saw
+"Create Incident" or "Create Maintenance", nor their keyboard shortcuts. The
+palette hid the actions from exactly the people authorized to run them.
+
+Commands can now declare a `manageCapability` (mirroring the gate routes and nav
+already use). `filterByAccessRules` shows an item when the caller holds the
+global rules OR can create/manage the declared type through a team grant, and the
+command backend resolves that per request via `hasAnyTypeGrant` (with
+`includeCreator`, so a team member who may CREATE the type qualifies before
+owning an instance). It fails closed: an auth error leaves pure global gating.
+The incident and maintenance commands declare their types.
+
+`useGlobalShortcuts` no longer takes `userAccessRules` and no longer re-checks
+access: the server-filtered list is authoritative. That re-check tested the
+global rules only and would have dropped team-scoped users' shortcuts - both call
+sites already defeated it by passing `["*"]`.
diff --git a/core/command-backend/src/registry.ts b/core/command-backend/src/registry.ts
index a1ea3111a..511ce0c03 100644
--- a/core/command-backend/src/registry.ts
+++ b/core/command-backend/src/registry.ts
@@ -35,6 +35,14 @@ export interface CommandDefinition {
route: string;
/** Access rules required (will be auto-qualified with plugin ID) */
requiredAccessRules?: AccessRule[];
+ /**
+ * Team-capability gate, mirroring a route's `manageCapability`. When set, a
+ * team-scoped caller who can create/manage `objectType` (or its `parentType`)
+ * still sees this command even without the global `requiredAccessRules`.
+ * Declare it on any command whose target surface is team-scopable, or the
+ * palette hides the action from the people allowed to run it.
+ */
+ manageCapability?: { objectType: string; parentType?: string };
}
/**
@@ -173,6 +181,7 @@ function createCommandProvider(
requiredAccessRules: cmd.requiredAccessRules?.map((rule) =>
qualifyAccessRuleId(pluginMetadata, rule)
),
+ manageCapability: cmd.manageCapability,
}));
return {
diff --git a/core/command-backend/src/router.ts b/core/command-backend/src/router.ts
index edc3160b4..a150f40ca 100644
--- a/core/command-backend/src/router.ts
+++ b/core/command-backend/src/router.ts
@@ -39,6 +39,63 @@ function getUserAccessRules(context: RpcContext): string[] {
return [];
}
+/**
+ * Resolve which of the `manageCapability` types declared by the candidate items
+ * the caller can actually create/manage through a TEAM grant.
+ *
+ * Without this the palette filters on global rules only, so a team-scoped user
+ * (a create-capability grant, no global `*.manage`) loses the very commands they
+ * are allowed to run. Only the DISTINCT declared types are probed - a handful per
+ * request - and `includeCreator` matches the `typeScoped` middleware, so a team
+ * member who may CREATE the type qualifies before owning an instance.
+ * Fails CLOSED: an auth error yields no team types, leaving global-rule gating.
+ */
+async function resolveManageableTypes({
+ context,
+ items,
+ logger,
+}: {
+ context: RpcContext;
+ items: SearchResult[];
+ logger: Logger;
+}): Promise> {
+ const user = context.user;
+ if (!user || (user.type !== "user" && user.type !== "application")) {
+ return new Set();
+ }
+
+ const declared = new Set();
+ for (const item of items) {
+ const capability = item.manageCapability;
+ if (!capability) continue;
+ declared.add(capability.objectType);
+ if (capability.parentType) declared.add(capability.parentType);
+ }
+ if (declared.size === 0) return new Set();
+
+ const granted = await Promise.all(
+ [...declared].map(async (objectType) => {
+ try {
+ const { hasGrant } = await context.auth.hasAnyTypeGrant({
+ userId: user.id,
+ userType: user.type as "user" | "application",
+ objectType,
+ action: "manage",
+ includeCreator: true,
+ });
+ return hasGrant ? objectType : null;
+ } catch (error) {
+ logger.debug(
+ `command palette: type-grant probe failed for ${objectType}: ${extractErrorMessage(error)}`,
+ );
+ return null;
+ }
+ }),
+ );
+
+ return new Set(granted.filter((t): t is string => t !== null));
+}
+
export const createCommandRouter = ({ logger }: { logger: Logger }) => {
/**
* Search across all registered search providers.
@@ -70,9 +127,15 @@ export const createCommandRouter = ({ logger }: { logger: Logger }) => {
})
);
- // Flatten and filter by access rules
+ // Flatten, then filter by the global rules OR a team grant on a declared
+ // manageCapability type (so team-scoped users keep their commands).
const allResults = providerResults.flat();
- return filterByAccessRules(allResults, userAccessRules);
+ const manageableTypes = await resolveManageableTypes({
+ context,
+ items: allResults,
+ logger,
+ });
+ return filterByAccessRules(allResults, userAccessRules, manageableTypes);
});
/**
@@ -105,7 +168,12 @@ export const createCommandRouter = ({ logger }: { logger: Logger }) => {
);
const allCommands = providerResults.flat();
- return filterByAccessRules(allCommands, userAccessRules);
+ const manageableTypes = await resolveManageableTypes({
+ context,
+ items: allCommands,
+ logger,
+ });
+ return filterByAccessRules(allCommands, userAccessRules, manageableTypes);
});
return os.router({
diff --git a/core/command-common/src/filter-access.test.ts b/core/command-common/src/filter-access.test.ts
new file mode 100644
index 000000000..8495e16c2
--- /dev/null
+++ b/core/command-common/src/filter-access.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect } from "bun:test";
+import { filterByAccessRules } from "./index";
+
+/**
+ * Behavioral guard for command-palette visibility.
+ *
+ * The regression this pins: commands were filtered against the caller's GLOBAL
+ * access rules only, so a team-scoped user holding a create-capability grant
+ * (and no global `*.manage` rule) never saw "Create Incident" / "Create
+ * Maintenance" - the palette hid the actions from exactly the people allowed to
+ * run them. `manageCapability` + `manageableTypes` is the team-grant arm.
+ */
+type Item = {
+ id: string;
+ requiredAccessRules?: string[];
+ manageCapability?: { objectType: string; parentType?: string };
+};
+
+const incidentCreate: Item = {
+ id: "incident.create",
+ requiredAccessRules: ["incident.incident.manage"],
+ manageCapability: { objectType: "incident.incident" },
+};
+
+/** A command with a global rule but NO capability declared (the old shape). */
+const globalOnly: Item = {
+ id: "admin.thing",
+ requiredAccessRules: ["auth.users.manage"],
+};
+
+describe("filterByAccessRules", () => {
+ it("shows items that require no access rules", () => {
+ const open: Item = { id: "open" };
+ expect(filterByAccessRules([open], [])).toEqual([open]);
+ });
+
+ it("shows everything to a wildcard holder", () => {
+ expect(
+ filterByAccessRules([incidentCreate, globalOnly], ["*"]),
+ ).toHaveLength(2);
+ });
+
+ it("shows an item to a caller holding every required global rule", () => {
+ expect(
+ filterByAccessRules([incidentCreate], ["incident.incident.manage"]),
+ ).toEqual([incidentCreate]);
+ });
+
+ it("REGRESSION: shows a create command to a team-scoped user via manageCapability", () => {
+ // No global rule at all - only a team grant on the declared type.
+ const result = filterByAccessRules(
+ [incidentCreate],
+ [],
+ new Set(["incident.incident"]),
+ );
+ expect(result).toEqual([incidentCreate]);
+ });
+
+ it("hides the item from a caller with neither the global rule nor a team grant", () => {
+ expect(filterByAccessRules([incidentCreate], [], new Set())).toEqual([]);
+ });
+
+ it("hides the item when the team grant is for an unrelated type", () => {
+ expect(
+ filterByAccessRules([incidentCreate], [], new Set(["catalog.system"])),
+ ).toEqual([]);
+ });
+
+ it("honors a parentType grant (managing the parent unlocks the child)", () => {
+ const item: Item = {
+ id: "incident.create",
+ requiredAccessRules: ["incident.incident.manage"],
+ manageCapability: {
+ objectType: "incident.incident",
+ parentType: "catalog.system",
+ },
+ };
+ expect(
+ filterByAccessRules([item], [], new Set(["catalog.system"])),
+ ).toEqual([item]);
+ });
+
+ it("does NOT let a team grant rescue an item that declares no capability", () => {
+ // Without an explicit manageCapability the item stays global-only, so a
+ // stray team grant must not widen it.
+ expect(
+ filterByAccessRules([globalOnly], [], new Set(["auth.users"])),
+ ).toEqual([]);
+ });
+
+ it("defaults to pure global-rule gating when no team set is passed", () => {
+ expect(filterByAccessRules([incidentCreate], [])).toEqual([]);
+ });
+
+ it("requires ALL global rules when several are declared", () => {
+ const item: Item = { id: "x", requiredAccessRules: ["a.read", "b.manage"] };
+ expect(filterByAccessRules([item], ["a.read"])).toEqual([]);
+ expect(filterByAccessRules([item], ["a.read", "b.manage"])).toEqual([item]);
+ });
+});
diff --git a/core/command-common/src/index.ts b/core/command-common/src/index.ts
index 28620ca5e..3c01e5de1 100644
--- a/core/command-common/src/index.ts
+++ b/core/command-common/src/index.ts
@@ -34,6 +34,15 @@ export const SearchResultSchema = z.object({
shortcuts: z.array(z.string()).optional(),
/** Access rule IDs required to see this result */
requiredAccessRules: z.array(z.string()).optional(),
+ /**
+ * Team-capability gate. When present, a caller who lacks the global
+ * `requiredAccessRules` is STILL shown this item if a team of theirs can
+ * create/manage `objectType` (or its `parentType`). Keeps team-scoped users
+ * from losing commands they are authorized to run - see `filterByAccessRules`.
+ */
+ manageCapability: z
+ .object({ objectType: z.string(), parentType: z.string().optional() })
+ .optional(),
});
export type SearchResult = z.infer;
@@ -55,6 +64,15 @@ export const CommandSchema = z.object({
route: z.string(),
/** Access rule IDs required to see/execute this command */
requiredAccessRules: z.array(z.string()).optional(),
+ /**
+ * Team-capability gate. When present, a caller who lacks the global
+ * `requiredAccessRules` is STILL shown this item if a team of theirs can
+ * create/manage `objectType` (or its `parentType`). Keeps team-scoped users
+ * from losing commands they are authorized to run - see `filterByAccessRules`.
+ */
+ manageCapability: z
+ .object({ objectType: z.string(), parentType: z.string().optional() })
+ .optional(),
});
export type Command = z.infer;
@@ -107,13 +125,33 @@ export const CommandApi = createClientDefinition(
// =============================================================================
/**
- * Filter items by user access rules.
- * Items without requiredAccessRules are always included.
- * Users with the wildcard "*" access rule can see all items.
+ * Filter items by what the caller may actually do.
+ *
+ * An item is visible when it declares no `requiredAccessRules`, when the caller
+ * holds the wildcard `*`, when the caller holds EVERY required global rule, OR -
+ * crucially - when the item declares a `manageCapability` whose type the caller
+ * can manage through a TEAM grant.
+ *
+ * That last arm is why this is not a plain global-rule check: a team-scoped user
+ * (e.g. a team with a create-capability grant for `incident.incident`) holds no
+ * global `incident.incident.manage` rule, so a global-only filter hid "Create
+ * Incident" from exactly the people allowed to run it. This mirrors the
+ * `manageCapability` gate routes/nav already use (see `.claude/rules/rlac.md`).
+ *
+ * `manageableTypes` is the set of qualified resource types the caller can
+ * create/manage via a team grant; pass an empty set to reduce to pure global-rule
+ * gating.
*/
export function filterByAccessRules<
- T extends { requiredAccessRules?: string[] }
->(items: T[], userAccessRules: string[]): T[] {
+ T extends {
+ requiredAccessRules?: string[];
+ manageCapability?: { objectType: string; parentType?: string };
+ }
+>(
+ items: T[],
+ userAccessRules: string[],
+ manageableTypes: ReadonlySet = new Set()
+): T[] {
// Wildcard access rule means access to everything
const hasWildcard = userAccessRules.includes("*");
@@ -126,9 +164,19 @@ export function filterByAccessRules<
if (hasWildcard) {
return true;
}
- // Check if user has all required access rules
- return item.requiredAccessRules.every((rule) =>
- userAccessRules.includes(rule)
+ // Global path: the caller holds every required rule outright.
+ if (
+ item.requiredAccessRules.every((rule) => userAccessRules.includes(rule))
+ ) {
+ return true;
+ }
+ // Team path: a grant on the declared type (or its parent) authorizes it.
+ const capability = item.manageCapability;
+ if (!capability) return false;
+ return (
+ manageableTypes.has(capability.objectType) ||
+ (capability.parentType !== undefined &&
+ manageableTypes.has(capability.parentType))
);
});
}
diff --git a/core/command-frontend/src/index.tsx b/core/command-frontend/src/index.tsx
index 5963f137d..a3ac9c239 100644
--- a/core/command-frontend/src/index.tsx
+++ b/core/command-frontend/src/index.tsx
@@ -153,19 +153,20 @@ function useDebouncedValue(value: T, delayMs: number): T {
* When a shortcut is triggered, it navigates to the command's route.
* Should be used once at the app root level.
*
- * @param commands - Array of commands with shortcuts
+ * The command list is ALREADY access-filtered by the server (global rules OR a
+ * team grant on the command's `manageCapability` type), so this hook does not
+ * re-check access. It previously took a `userAccessRules` argument and re-tested
+ * the GLOBAL rules - which both call sites defeated by passing `["*"]`, and which
+ * would have dropped team-scoped users' shortcuts had they not.
+ *
+ * @param commands - Array of commands with shortcuts (server-filtered)
* @param navigate - Navigation function to call when a command is triggered
- * @param userAccessRules - Array of access rule IDs the user has
*/
export function useGlobalShortcuts(
commands: SearchResult[],
navigate: (route: string) => void,
- userAccessRules: string[]
): void {
useEffect(() => {
- // Check if user has wildcard access rule (admin)
- const hasWildcard = userAccessRules.includes("*");
-
const handleKeyDown = (event: KeyboardEvent) => {
// Don't trigger in input fields
const target = event.target as HTMLElement;
@@ -181,13 +182,14 @@ export function useGlobalShortcuts(
for (const command of commands) {
if (!command.shortcuts || !command.route) continue;
- // Check access rules (skip if user has wildcard)
- if (!hasWildcard && command.requiredAccessRules?.length) {
- const hasAccess = command.requiredAccessRules.every((rule) =>
- userAccessRules.includes(rule)
- );
- if (!hasAccess) continue;
- }
+ // NO access re-check here. The server already filtered this list by the
+ // caller's global rules OR a team grant on the command's declared
+ // `manageCapability` type. Re-checking the GLOBAL rules only (as this
+ // did) contradicted that and silently dropped the shortcuts of
+ // team-scoped users - who hold no global `*.manage` rule but are
+ // authorized via their team - so "Create Incident" had a visible palette
+ // entry whose keyboard shortcut did nothing. The destination route
+ // carries its own guard regardless.
for (const shortcut of command.shortcuts) {
const parsed = parseShortcut(shortcut);
@@ -202,7 +204,7 @@ export function useGlobalShortcuts(
globalThis.addEventListener("keydown", handleKeyDown);
return () => globalThis.removeEventListener("keydown", handleKeyDown);
- }, [commands, navigate, userAccessRules]);
+ }, [commands, navigate]);
}
/**
@@ -309,7 +311,7 @@ export function GlobalShortcuts(): React.ReactNode {
// For now, pass "*" as access rule since the backend already filters
// The commands returned from getCommands are already filtered
- useGlobalShortcuts(commands, navigate, ["*"]);
+ useGlobalShortcuts(commands, navigate);
// This component renders nothing - it only registers event listeners
return;
diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx
index ac70fd380..d4709fada 100644
--- a/core/frontend/src/App.tsx
+++ b/core/frontend/src/App.tsx
@@ -233,7 +233,7 @@ function GlobalShortcuts() {
const navigate = useNavigate();
// Pass "*" as access since backend already filters by access
- useGlobalShortcuts(commands, navigate, ["*"]);
+ useGlobalShortcuts(commands, navigate);
// This component renders nothing - it only registers event listeners
return <>>;
diff --git a/core/incident-backend/src/index.ts b/core/incident-backend/src/index.ts
index c21c85408..7c91e0f97 100644
--- a/core/incident-backend/src/index.ts
+++ b/core/incident-backend/src/index.ts
@@ -11,6 +11,7 @@ import {
import {
incidentAccessRules,
incidentAccess,
+ incidentResourceTypes,
pluginMetadata,
incidentContract,
incidentRoutes,
@@ -289,6 +290,9 @@ export default createBackendPlugin({
route:
resolveRoute(incidentRoutes.routes.config) + "?action=create",
requiredAccessRules: [incidentAccess.incident.manage],
+ // Team-scoped: a team that may create/manage incidents sees this
+ // even without the global rule (see command-common filterByAccessRules).
+ manageCapability: { objectType: incidentResourceTypes.incident },
},
{
id: "manage",
@@ -298,6 +302,9 @@ export default createBackendPlugin({
shortcuts: ["meta+shift+i", "ctrl+shift+i"],
route: resolveRoute(incidentRoutes.routes.config),
requiredAccessRules: [incidentAccess.incident.manage],
+ // Team-scoped: a team that may create/manage incidents sees this
+ // even without the global rule (see command-common filterByAccessRules).
+ manageCapability: { objectType: incidentResourceTypes.incident },
},
],
});
diff --git a/core/maintenance-backend/src/index.ts b/core/maintenance-backend/src/index.ts
index 1057a7f57..fe0b7aad2 100644
--- a/core/maintenance-backend/src/index.ts
+++ b/core/maintenance-backend/src/index.ts
@@ -3,6 +3,7 @@ import type { SafeDatabase } from "@checkstack/backend-api";
import {
maintenanceAccessRules,
maintenanceAccess,
+ maintenanceResourceTypes,
pluginMetadata,
maintenanceContract,
maintenanceRoutes,
@@ -277,6 +278,9 @@ export default createBackendPlugin({
resolveRoute(maintenanceRoutes.routes.config) +
"?action=create",
requiredAccessRules: [maintenanceAccess.maintenance.manage],
+ // Team-scoped: a team that may create/manage maintenances sees this
+ // even without the global rule (see command-common filterByAccessRules).
+ manageCapability: { objectType: maintenanceResourceTypes.maintenance },
},
{
id: "manage",
@@ -286,6 +290,9 @@ export default createBackendPlugin({
shortcuts: ["meta+shift+m", "ctrl+shift+m"],
route: resolveRoute(maintenanceRoutes.routes.config),
requiredAccessRules: [maintenanceAccess.maintenance.manage],
+ // Team-scoped: a team that may create/manage maintenances sees this
+ // even without the global rule (see command-common filterByAccessRules).
+ manageCapability: { objectType: maintenanceResourceTypes.maintenance },
},
],
});
From 3802d092d5a7c7ccc25ec9c71826754cbfc8a4c1 Mon Sep 17 00:00:00 2001
From: enyineer
Date: Sun, 26 Jul 2026 05:10:28 +0200
Subject: [PATCH 02/36] fix(auth-frontend): clarify team-access editor + guard
self-lockout
From user feedback on the "Who can change this" editor:
- Relabel the "Manage" checkbox to "Can edit" and drop its gear icon. It sets the
team's grant on THIS resource, but the wording + gear read as "manage the
team", so people expected it to open the Teams UI.
- Make the team name a link to /teams?team=, which opens that team's members
dialog. "Go to the team" now has its own affordance instead of being conflated
with the grant checkbox. TeamsTab consumes the param once, then clears it.
- Confirm before a user revokes their OWN team's only edit grant: afterwards they
could neither change the resource nor restore the permission. Global
teams.manage admins are exempt (they can always restore). The decision is
extracted as the pure, unit-tested isSelfRevokingChange.
- Explain the add-member field: it adds a NEW member from the directory (not a
filter over current members), and only users who have signed in at least once
exist there.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Hccsjzvqr6xfhGicNBjRgB
---
.changeset/team-access-editor-clarity.md | 27 ++++
.../src/components/TeamAccessEditor.tsx | 139 ++++++++++++++----
.../auth-frontend/src/components/TeamsTab.tsx | 43 +++++-
.../src/components/UserPickerCombobox.tsx | 5 +-
.../auth-frontend/src/lib/selfLockout.test.ts | 111 ++++++++++++++
core/auth-frontend/src/lib/selfLockout.ts | 41 ++++++
6 files changed, 329 insertions(+), 37 deletions(-)
create mode 100644 .changeset/team-access-editor-clarity.md
create mode 100644 core/auth-frontend/src/lib/selfLockout.test.ts
create mode 100644 core/auth-frontend/src/lib/selfLockout.ts
diff --git a/.changeset/team-access-editor-clarity.md b/.changeset/team-access-editor-clarity.md
new file mode 100644
index 000000000..6eb7dcd3f
--- /dev/null
+++ b/.changeset/team-access-editor-clarity.md
@@ -0,0 +1,27 @@
+---
+"@checkstack/auth-frontend": minor
+---
+
+Clarify the team-access editor and guard against locking yourself out
+
+Four fixes to the "Who can change this" editor and the team member picker, from
+user feedback:
+
+- **The "Manage" checkbox read as "manage the team".** It sets the selected
+ team's grant on THIS resource, but the label plus a gear icon suggested it
+ would open the team itself. It is now labelled **"Can edit"** (with no gear),
+ naming its effect on the resource.
+- **The team name is now a link** to that team (`/teams?team=`), which opens
+ its members dialog directly. That gives "take me to the team" its own
+ affordance instead of overloading the checkbox. The Teams page consumes the
+ `team` query param once and then clears it.
+- **Revoking your own team's access now asks first.** A team-scoped user could
+ remove (or downgrade) their own team's only edit grant and afterwards be unable
+ to change the resource *or* restore the permission. That case now shows a
+ confirmation explaining the consequence. Global `auth.teams.manage` admins are
+ not warned - they can always restore it. The decision is a pure, unit-tested
+ `isSelfRevokingChange`.
+- **The add-member field explained.** Its placeholder ("Add a user by name or
+ email") and new helper text state that it adds a NEW member from the whole
+ directory rather than filtering current members, and that a user is only
+ findable after their first sign-in (SSO/LDAP accounts materialise on login).
diff --git a/core/auth-frontend/src/components/TeamAccessEditor.tsx b/core/auth-frontend/src/components/TeamAccessEditor.tsx
index 1cbc8b89d..fc5c7a59f 100644
--- a/core/auth-frontend/src/components/TeamAccessEditor.tsx
+++ b/core/auth-frontend/src/components/TeamAccessEditor.tsx
@@ -17,19 +17,23 @@ import {
SelectValue,
Checkbox,
Toggle,
+ ConfirmationModal,
} from "@checkstack/ui";
import {
Plus,
Trash2,
Users2,
Shield,
- Settings,
Lock,
AlertCircle,
+ ExternalLink,
} from "lucide-react";
-import { usePluginClient } from "@checkstack/frontend-api";
-import { AuthApi } from "@checkstack/auth-common";
+import { Link } from "react-router";
+import { useApi, usePluginClient, accessApiRef } from "@checkstack/frontend-api";
+import { AuthApi, authAccess, authRoutes } from "@checkstack/auth-common";
+import { resolveRoute } from "@checkstack/common";
import { deriveTeamAccessSummary } from "../lib/deriveTeamAccessSummary";
+import { isSelfRevokingChange } from "../lib/selfLockout";
interface TeamAccess {
teamId: string;
@@ -77,6 +81,7 @@ export const TeamAccessEditor: React.FC = ({
onChange,
}) => {
const authClient = usePluginClient(AuthApi);
+ const accessApi = useApi(accessApiRef);
const toast = useToast();
const [expanded, setExpanded] = useState(initialExpanded);
@@ -113,6 +118,26 @@ export const TeamAccessEditor: React.FC = ({
);
const canEdit = editVerdict?.allowed ?? false;
+ // The caller's OWN teams, to warn before they revoke their own access (below).
+ // `getMyTeams` carries no access rule - a caller may always read their own
+ // memberships - so this is safe for every principal that can open the editor.
+ const { data: myTeamsData } = authClient.getMyTeams.useQuery(undefined, {
+ enabled: expanded && !!resourceId,
+ });
+ const myTeamIds = new Set((myTeamsData?.teams ?? []).map((t) => t.id));
+
+ // A global `auth.teams.manage` admin can always restore a grant they removed,
+ // so the self-lockout warning is for everyone EXCEPT them.
+ const { allowed: isGlobalTeamsAdmin } = accessApi.useAccess(
+ authAccess.teams.manage,
+ );
+
+ /** A confirmation the user must accept before a self-revoking change runs. */
+ const [pendingSelfRevoke, setPendingSelfRevoke] = useState<{
+ teamName: string;
+ apply: () => void;
+ }>();
+
const loading = relationsLoading || teamsLoading;
// An access-control surface MUST distinguish "failed to load" from "no
// restrictions" — otherwise a fetch error makes a restricted resource look open.
@@ -128,6 +153,15 @@ export const TeamAccessEditor: React.FC = ({
}));
const teamOnly = relations ? !relations.isPublic : false;
+ /** See `isSelfRevokingChange` - pure, unit-tested in `selfLockout.test.ts`. */
+ const isSelfLockout = (teamId: string): boolean =>
+ isSelfRevokingChange({
+ teamId,
+ grants: accessList,
+ myTeamIds,
+ isGlobalTeamsAdmin,
+ });
+
// Mutations
const setAccessMutation = authClient.writeRelation.useMutation({
onSuccess: () => {
@@ -178,12 +212,23 @@ export const TeamAccessEditor: React.FC = ({
const handleUpdateAccess = (teamId: string, canManage: boolean) => {
// A team always retains read; the only meaningful distinction is the manage
// bit, which maps to editor (manage) vs viewer (read-only).
- setAccessMutation.mutate({
- objectType: resourceType,
- objectId: resourceId,
- teamId,
- relation: canManage ? "editor" : "viewer",
- });
+ const apply = () =>
+ setAccessMutation.mutate({
+ objectType: resourceType,
+ objectId: resourceId,
+ teamId,
+ relation: canManage ? "editor" : "viewer",
+ });
+
+ // Downgrading YOUR OWN team to read-only can strand you: you would no longer
+ // be able to change this resource, nor restore the grant. Confirm first.
+ if (!canManage && isSelfLockout(teamId)) {
+ const teamName =
+ accessList.find((a) => a.teamId === teamId)?.teamName ?? "your team";
+ setPendingSelfRevoke({ teamName, apply });
+ return;
+ }
+ apply();
};
const handleUpdateSettings = (newTeamOnly: boolean) => {
@@ -195,23 +240,34 @@ export const TeamAccessEditor: React.FC = ({
};
const handleRemoveAccess = (teamId: string) => {
- removeAccessMutation.mutate({
- objectType: resourceType,
- objectId: resourceId,
- teamId,
- });
-
- // If this was the last team, turn off "Private to teams" so the resource
- // doesn't end up locked-down with no team able to reach it. Surface it.
- const remainingTeams = accessList.filter((a) => a.teamId !== teamId);
- if (remainingTeams.length === 0 && teamOnly) {
- setSettingsMutation.mutate({
+ const apply = () => {
+ removeAccessMutation.mutate({
objectType: resourceType,
objectId: resourceId,
- isPublic: true,
+ teamId,
});
- toast.info("Private turned off - no teams remain");
+
+ // If this was the last team, turn off "Private to teams" so the resource
+ // doesn't end up locked-down with no team able to reach it. Surface it.
+ const remainingTeams = accessList.filter((a) => a.teamId !== teamId);
+ if (remainingTeams.length === 0 && teamOnly) {
+ setSettingsMutation.mutate({
+ objectType: resourceType,
+ objectId: resourceId,
+ isPublic: true,
+ });
+ toast.info("Private turned off - no teams remain");
+ }
+ };
+
+ // Removing YOUR OWN team's only Manage grant is a one-way door - confirm.
+ if (isSelfLockout(teamId)) {
+ const teamName =
+ accessList.find((a) => a.teamId === teamId)?.teamName ?? "your team";
+ setPendingSelfRevoke({ teamName, apply });
+ return;
}
+ apply();
};
const typedAccessList = accessList;
@@ -354,7 +410,18 @@ export const TeamAccessEditor: React.FC = ({
>
- {access.teamName}
+ {/* The team NAME navigates to the team itself (members/managers). This
+ is the "take me to the team" affordance - keeping it separate from
+ the checkbox, which only sets this resource's grant level. Before,
+ the checkbox's gear icon made people expect it to open the team. */}
+
+ {access.teamName}
+
+
{!access.canManage && (
(read-only)
@@ -362,6 +429,8 @@ export const TeamAccessEditor: React.FC = ({
)}
+ {/* Labelled by its EFFECT on this resource ("Can edit"), not "Manage" -
+ the old wording plus a gear icon read as "manage the team". */}
{canEdit && (
-
-
-
- View
-
-
+
+ {reportButton}
+
+
+
+ View
+
+
+
);
};
diff --git a/core/incident-frontend/src/pages/IncidentConfigPage.tsx b/core/incident-frontend/src/pages/IncidentConfigPage.tsx
index f4035dcef..56b8091dc 100644
--- a/core/incident-frontend/src/pages/IncidentConfigPage.tsx
+++ b/core/incident-frontend/src/pages/IncidentConfigPage.tsx
@@ -260,13 +260,22 @@ const IncidentConfigPageContent: React.FC = () => {
setSelectedIds(allSelected ? new Set() : new Set(selectableIds));
};
- // Handle ?action=create URL parameter (from command palette)
+ // Systems to pre-select when the create editor is opened by deep link. Set
+ // from `?systemId=` so "Report incident" on a system's overview page lands on
+ // a create form already scoped to that system.
+ const [presetSystemIds, setPresetSystemIds] = useState([]);
+
+ // Handle ?action=create (command palette, or a system's "Report incident"),
+ // optionally carrying ?systemId= to pre-select that system.
useEffect(() => {
if (searchParams.get("action") === "create" && canManage) {
+ const systemId = searchParams.get("systemId");
+ setPresetSystemIds(systemId ? [systemId] : []);
setEditingIncident(undefined);
setEditorOpen(true);
- // Clear the URL param after opening
+ // Clear the URL params after opening
searchParams.delete("action");
+ searchParams.delete("systemId");
setSearchParams(searchParams, { replace: true });
}
}, [searchParams, canManage, setSearchParams]);
@@ -761,6 +770,7 @@ const IncidentConfigPageContent: React.FC = () => {
)}
void;
+ /**
+ * Systems to pre-select when opening in CREATE mode (no `maintenance`). Lets a
+ * caller open the editor already scoped to a system - e.g. "Schedule
+ * maintenance" on that system's overview page. Ignored when editing an
+ * existing maintenance, whose own systems win.
+ */
+ presetSystemIds?: string[];
}
export const MaintenanceEditor: React.FC = ({
@@ -58,6 +65,7 @@ export const MaintenanceEditor: React.FC = ({
maintenance,
systems,
onSave,
+ presetSystemIds,
}) => {
const maintenanceClient = usePluginClient(MaintenanceApi);
const accessApi = useApi(accessApiRef);
@@ -159,12 +167,12 @@ export const MaintenanceEditor: React.FC = ({
setDescription("");
setStartAt(start);
setEndAt(end);
- setSelectedSystemIds(new Set());
+ setSelectedSystemIds(new Set(presetSystemIds));
setSuppressNotifications(false);
setOwnerTeamId(null);
setOwnerTeamError(null);
}
- }, [maintenance, open]);
+ }, [maintenance, open, presetSystemIds]);
// Per-field error map (single source of truth for inline errors + validity).
const fieldErrors = deriveMaintenanceFieldErrors({
@@ -203,11 +211,18 @@ export const MaintenanceEditor: React.FC = ({
systemsChanged
);
}
+ // Create mode: the baseline is whatever was PRE-SELECTED (e.g. opened from a
+ // system's "Schedule maintenance"), not an empty set - otherwise the form
+ // counts as dirty the moment it opens and closing it nags to discard.
+ const preset = [...(presetSystemIds ?? [])].toSorted();
+ const systemsChanged =
+ preset.length !== currentSystemIds.length ||
+ preset.some((id, i) => id !== currentSystemIds[i]);
return (
title.trim() !== "" ||
description.trim() !== "" ||
suppressNotifications ||
- currentSystemIds.length > 0 ||
+ systemsChanged ||
ownerTeamId !== null
);
})();
diff --git a/core/maintenance-frontend/src/components/SystemMaintenancePanel.tsx b/core/maintenance-frontend/src/components/SystemMaintenancePanel.tsx
index 53565f646..7bc11143f 100644
--- a/core/maintenance-frontend/src/components/SystemMaintenancePanel.tsx
+++ b/core/maintenance-frontend/src/components/SystemMaintenancePanel.tsx
@@ -1,6 +1,11 @@
import React, { useEffect } from "react";
import { Link } from "react-router";
-import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
+import {
+ usePluginClient,
+ useApi,
+ accessApiRef,
+ type SlotContext,
+} from "@checkstack/frontend-api";
import { resolveRoute } from "@checkstack/common";
import { SystemDetailsSlot } from "@checkstack/catalog-common";
import { MaintenanceApi } from "../api";
@@ -11,7 +16,7 @@ import {
DetailCard,
pillToneStyles,
} from "@checkstack/ui";
-import { Wrench, History } from "lucide-react";
+import { Wrench, History, Plus } from "lucide-react";
import { getMaintenanceStatusTone } from "../utils/badges";
type Props = SlotContext;
@@ -25,6 +30,29 @@ export const SystemMaintenancePanel: React.FC = ({
onLoadingChange,
}) => {
const maintenanceClient = usePluginClient(MaintenanceApi);
+ const accessApi = useApi(accessApiRef);
+
+ // Derived from the CREATE procedure's contract, so it is true for a global
+ // maintenance manager AND for someone who can manage this system via a team
+ // (the `create.parent` gate) - exactly who the backend will accept.
+ const { allowed: canScheduleMaintenance } = accessApi.useProcedureAccess(
+ MaintenanceApi.contract.createMaintenance,
+ );
+
+ /**
+ * Open the maintenance editor already scoped to this system. Deep-links to the
+ * maintenance page, which consumes `action`/`systemId` and pre-selects it.
+ */
+ const scheduleButton = canScheduleMaintenance && system?.id && (
+
+
+
+ Schedule maintenance
+
+
+ );
// Fetch maintenances with useQuery — kept fresh via SignalAutoInvalidator.
const { data: maintenances = [], isLoading: loading } =
@@ -54,16 +82,19 @@ export const SystemMaintenancePanel: React.FC = ({
No planned maintenances
-
-
-
- History
-
-
+
);
};
diff --git a/core/maintenance-frontend/src/pages/MaintenanceConfigPage.tsx b/core/maintenance-frontend/src/pages/MaintenanceConfigPage.tsx
index d620c0128..f24bad9a5 100644
--- a/core/maintenance-frontend/src/pages/MaintenanceConfigPage.tsx
+++ b/core/maintenance-frontend/src/pages/MaintenanceConfigPage.tsx
@@ -210,13 +210,22 @@ const MaintenanceConfigPageContent: React.FC = () => {
setSelectedIds(allSelected ? new Set() : new Set(selectableIds));
};
- // Handle ?action=create URL parameter (from command palette)
+ // Systems to pre-select when the create editor is opened by deep link. Set
+ // from `?systemId=` so "Schedule maintenance" on a system's overview page
+ // lands on a create form already scoped to that system.
+ const [presetSystemIds, setPresetSystemIds] = useState([]);
+
+ // Handle ?action=create (command palette, or a system's "Schedule
+ // maintenance"), optionally carrying ?systemId= to pre-select that system.
useEffect(() => {
if (searchParams.get("action") === "create" && canManage) {
+ const systemId = searchParams.get("systemId");
+ setPresetSystemIds(systemId ? [systemId] : []);
setEditingMaintenance(undefined);
setEditorOpen(true);
- // Clear the URL param after opening
+ // Clear the URL params after opening
searchParams.delete("action");
+ searchParams.delete("systemId");
setSearchParams(searchParams, { replace: true });
}
}, [searchParams, canManage, setSearchParams]);
@@ -679,6 +688,7 @@ const MaintenanceConfigPageContent: React.FC = () => {
)}
Date: Wed, 29 Jul 2026 06:10:37 +0200
Subject: [PATCH 04/36] feat(auth-frontend): alphabetise, bulk-select and clone
roles
Access-rule categories now sort by their rendered label instead of plugin
registration order, at both the category and rule level.
Each category gained Select all / Clear, routed through the same guard the
individual checkboxes use so the bulk action cannot grant a rule the anonymous
role may not hold.
Roles can be cloned. The dialog takes an explicit mode rather than inferring
'editing' from a role being present, which is what made the third state
expressible.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012bdCuhhRWywMnaCMQmjzv1
---
.changeset/auth-role-editor-ergonomics.md | 23 ++
.../src/components/RoleDialog.tsx | 183 +++++++++++++--
.../auth-frontend/src/components/RolesTab.tsx | 43 +++-
.../src/components/role-rules.logic.test.ts | 215 ++++++++++++++++++
.../src/components/role-rules.logic.ts | 111 +++++++++
core/common/src/clone-naming.test.ts | 24 ++
core/common/src/clone-naming.ts | 23 ++
7 files changed, 597 insertions(+), 25 deletions(-)
create mode 100644 .changeset/auth-role-editor-ergonomics.md
create mode 100644 core/auth-frontend/src/components/role-rules.logic.test.ts
create mode 100644 core/auth-frontend/src/components/role-rules.logic.ts
create mode 100644 core/common/src/clone-naming.test.ts
create mode 100644 core/common/src/clone-naming.ts
diff --git a/.changeset/auth-role-editor-ergonomics.md b/.changeset/auth-role-editor-ergonomics.md
new file mode 100644
index 000000000..6ba8ffe4d
--- /dev/null
+++ b/.changeset/auth-role-editor-ergonomics.md
@@ -0,0 +1,23 @@
+---
+"@checkstack/auth-frontend": minor
+"@checkstack/common": minor
+---
+
+Role editor: alphabetised categories, bulk select, and role cloning
+
+Access-rule categories in the role dialog are now sorted alphabetically (by their
+rendered label, at both the category and the rule level) instead of following
+plugin registration order, so a category can be found by scanning rather than by
+reading the whole list.
+
+Each category gained **Select all** / **Clear** actions. They respect the same
+guards the individual checkboxes do - the anonymous role still cannot be granted
+rules no public endpoint uses, and a locked role stays read-only.
+
+Roles can be **cloned**: a new role seeded from an existing one's access rules,
+saved as a create. The dialog now takes an explicit `mode` rather than inferring
+"editing" from the presence of a role, which is what made the third state
+expressible at all.
+
+Adds a shared `buildClonedName` helper to `@checkstack/common` so every clone
+affordance in the product produces the same name shape.
diff --git a/core/auth-frontend/src/components/RoleDialog.tsx b/core/auth-frontend/src/components/RoleDialog.tsx
index 837a82543..8f561d883 100644
--- a/core/auth-frontend/src/components/RoleDialog.tsx
+++ b/core/auth-frontend/src/components/RoleDialog.tsx
@@ -22,11 +22,28 @@ import {
import { Check } from "lucide-react";
import type { Role, AccessRuleEntry } from "../api";
import { useAccessRules } from "../hooks/useAccessRules";
+import { buildClonedName } from "@checkstack/common";
+import {
+ getCategorySelectionState,
+ groupAccessRulesByCategory,
+ setCategorySelection,
+} from "./role-rules.logic";
+
+/**
+ * What the dialog is doing with `role`.
+ *
+ * `clone` seeds every field from `role` but SAVES AS A CREATE, so the mode has
+ * to be explicit - inferring "editing" from `role` being present (as this
+ * dialog used to) cannot express "seeded from a role, but new".
+ */
+export type RoleDialogMode = "create" | "edit" | "clone";
interface RoleDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
+ /** The role being edited, or the role a clone is seeded from. */
role?: Role;
+ mode?: RoleDialogMode;
accessRulesList: AccessRuleEntry[];
/** Whether current user has this role (prevents access elevation) */
isUserRole?: boolean;
@@ -42,11 +59,19 @@ export const RoleDialog: React.FC = ({
open,
onOpenChange,
role,
+ mode = role ? "edit" : "create",
accessRulesList,
isUserRole = false,
onSave,
}) => {
- const [name, setName] = useState(role?.name || "");
+ const isCloning = mode === "clone";
+ const seededName = role
+ ? isCloning
+ ? buildClonedName({ name: role.name })
+ : role.name
+ : "";
+
+ const [name, setName] = useState(seededName);
const [description, setDescription] = useState(role?.description || "");
const [selectedAccessRules, setSelectedAccessRules] = useState>(
new Set(role?.accessRules || []),
@@ -56,13 +81,16 @@ export const RoleDialog: React.FC = ({
// Seed form state once per dialog open (not on every `role` reference
// change) so a background refetch of the role list can't wipe in-progress edits.
useSeedFormOnOpen(open, () => {
- setName(role?.name || "");
+ setName(seededName);
setDescription(role?.description || "");
setSelectedAccessRules(new Set(role?.accessRules || []));
});
- const isEditing = !!role;
- const isAdminRole = role?.id === "admin";
+ const isEditing = mode === "edit";
+ // A clone always starts from a blank slate identity-wise: the admin and
+ // anonymous roles are special because of their ID, and a copy of one is just
+ // an ordinary role, so none of their restrictions carry over.
+ const isAdminRole = !isCloning && role?.id === "admin";
// A platform admin (wildcard `*`) already holds every access rule, so editing
// a role they belong to cannot elevate them - exempt them from the own-role
// lock so they can configure roles they were automatically added to (the
@@ -75,21 +103,15 @@ export const RoleDialog: React.FC = ({
// The anonymous role may only hold rules that a PUBLIC endpoint actually uses;
// granting an authenticated-only rule to it is inert (the server rejects
// unauthenticated callers before checking rules). Mirrors the backend guardrail.
- const isAnonymousRole = role?.id === "anonymous";
+ const isAnonymousRole = !isCloning && role?.id === "anonymous";
const isBlockedForAnonymous = (perm: AccessRuleEntry): boolean =>
isAnonymousRole &&
perm.anonymousUsable === false &&
!selectedAccessRules.has(perm.id);
- // Group access rules by plugin
- const accessRulesByPlugin: Record = {};
- for (const perm of accessRulesList) {
- const [plugin] = perm.id.split(".");
- if (!accessRulesByPlugin[plugin]) {
- accessRulesByPlugin[plugin] = [];
- }
- accessRulesByPlugin[plugin].push(perm);
- }
+ // Categories (one per plugin), alphabetised at both levels so a reader can
+ // jump to "Satellite" or "Dependency" instead of scanning registration order.
+ const categories = groupAccessRulesByCategory({ rules: accessRulesList });
const handleToggleAccessRule = (accessRuleId: string) => {
const newSelected = new Set(selectedAccessRules);
@@ -105,12 +127,36 @@ export const RoleDialog: React.FC = ({
setSelectedAccessRules(newSelected);
};
+ /**
+ * Bulk-select or clear one category. Routed through the same
+ * `isBlockedForAnonymous` guard the single checkbox uses, so the bulk action
+ * can never be a way around a restriction the per-rule toggle enforces.
+ */
+ const handleSetCategorySelection = (props: {
+ rules: AccessRuleEntry[];
+ select: boolean;
+ }) => {
+ const { rules, select } = props;
+ setSelectedAccessRules(
+ setCategorySelection({
+ selected: selectedAccessRules,
+ selectableIds: rules
+ .filter((perm) => !isBlockedForAnonymous(perm))
+ .map((perm) => perm.id),
+ categoryIds: rules.map((perm) => perm.id),
+ select,
+ }),
+ );
+ };
+
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setSaving(true);
onSave({
- ...(isEditing && { id: role.id }),
+ // Only a genuine edit carries an id. A clone is seeded from `role` but
+ // must save as a CREATE, so its id is deliberately dropped here.
+ ...(isEditing && role ? { id: role.id } : {}),
name,
description: description || undefined,
accessRules: [...selectedAccessRules],
@@ -132,11 +178,19 @@ export const RoleDialog: React.FC = ({
+ {isCloning && role && (
+
+
+ Starting from {role.name}. This creates a
+ new role - the original is left untouched, and the two are
+ not linked afterwards.
+
+
+ )}
{isAdminRole && (
@@ -200,16 +263,16 @@ export const RoleDialog: React.FC = ({
+ {/* Bulk actions live INSIDE the content, not in the
+ header: AccordionTrigger renders a , and a
+ nested button is invalid markup that also swallows
+ the trigger's keyboard behaviour. All categories
+ start expanded, so this is no less reachable. */}
+ {!accessRulesDisabled && (
+ !isBlockedForAnonymous(p))
+ .map((p) => p.id),
+ })}
+ onSelectAll={() =>
+ handleSetCategorySelection({
+ rules: perms,
+ select: true,
+ })
+ }
+ onClear={() =>
+ handleSetCategorySelection({
+ rules: perms,
+ select: false,
+ })
+ }
+ />
+ )}
= ({
);
};
+
+interface CategoryBulkActionsProps {
+ /** Category name, used to keep the accessible labels distinguishable. */
+ label: string;
+ state: ReturnType;
+ onSelectAll: () => void;
+ onClear: () => void;
+}
+
+/**
+ * "Select all" / "Clear" for one access-rule category.
+ *
+ * Both actions are always offered rather than toggling one button between two
+ * meanings: from a partial selection the author usually knows which way they
+ * want to go, and a single toggle would make them guess. Each is disabled when
+ * it would be a no-op, so the current state is readable from the controls.
+ */
+const CategoryBulkActions: React.FC = ({
+ label,
+ state,
+ onSelectAll,
+ onClear,
+}) => (
+
+
+ Select all
+
+
+ Clear
+
+
+);
diff --git a/core/auth-frontend/src/components/RolesTab.tsx b/core/auth-frontend/src/components/RolesTab.tsx
index e7c84e8f2..cdbab9dd6 100644
--- a/core/auth-frontend/src/components/RolesTab.tsx
+++ b/core/auth-frontend/src/components/RolesTab.tsx
@@ -14,11 +14,11 @@ import {
useToast,
toastError,
} from "@checkstack/ui";
-import { Plus, Edit, Trash2 } from "lucide-react";
+import { Plus, Edit, Copy, Trash2 } from "lucide-react";
import { usePluginClient } from "@checkstack/frontend-api";
import { AuthApi } from "@checkstack/auth-common";
import type { Role, AccessRuleEntry } from "../api";
-import { RoleDialog } from "./RoleDialog";
+import { RoleDialog, type RoleDialogMode } from "./RoleDialog";
export interface RolesTabProps {
roles: Role[];
@@ -47,6 +47,7 @@ export const RolesTab: React.FC = ({
const [roleToDelete, setRoleToDelete] = useState();
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
const [editingRole, setEditingRole] = useState();
+ const [dialogMode, setDialogMode] = useState("create");
// Mutations
const createRoleMutation = authClient.createRole.useMutation({
@@ -82,11 +83,23 @@ export const RolesTab: React.FC = ({
const handleCreateRole = () => {
setEditingRole(undefined);
+ setDialogMode("create");
setRoleDialogOpen(true);
};
const handleEditRole = (role: Role) => {
setEditingRole(role);
+ setDialogMode("edit");
+ setRoleDialogOpen(true);
+ };
+
+ /**
+ * Open the dialog seeded from `role` but saving as a CREATE. The source role
+ * is never mutated, and the copy carries no link back to it.
+ */
+ const handleCloneRole = (role: Role) => {
+ setEditingRole(role);
+ setDialogMode("clone");
setRoleDialogOpen(true);
};
@@ -166,9 +179,11 @@ export const RolesTab: React.FC = ({
role={role}
isUserRole={userRoleIds.includes(role.id)}
isSystem={role.isSystem}
+ canCreateRoles={canCreateRoles}
canUpdateRoles={canUpdateRoles}
canDeleteRoles={canDeleteRoles}
onEdit={handleEditRole}
+ onClone={handleCloneRole}
onDelete={setRoleToDelete}
/>
),
@@ -246,9 +261,11 @@ export const RolesTab: React.FC = ({
role={role}
isUserRole={isUserRole}
isSystem={role.isSystem}
+ canCreateRoles={canCreateRoles}
canUpdateRoles={canUpdateRoles}
canDeleteRoles={canDeleteRoles}
onEdit={handleEditRole}
+ onClone={handleCloneRole}
onDelete={setRoleToDelete}
/>
@@ -269,8 +286,15 @@ export const RolesTab: React.FC = ({
open={roleDialogOpen}
onOpenChange={setRoleDialogOpen}
role={editingRole}
+ mode={dialogMode}
accessRulesList={accessRulesList}
- isUserRole={editingRole ? userRoleIds.includes(editingRole.id) : false}
+ // A clone is a NEW role the viewer does not hold, so the own-role
+ // elevation lock never applies to it - only to a genuine edit.
+ isUserRole={
+ dialogMode === "edit" && editingRole
+ ? userRoleIds.includes(editingRole.id)
+ : false
+ }
onSave={handleSaveRole}
/>
@@ -309,9 +333,11 @@ interface RoleActionsProps {
role: Role;
isUserRole: boolean;
isSystem: boolean | undefined;
+ canCreateRoles: boolean;
canUpdateRoles: boolean;
canDeleteRoles: boolean;
onEdit: (role: Role) => void;
+ onClone: (role: Role) => void;
onDelete: (roleId: string) => void;
}
@@ -323,9 +349,11 @@ const RoleActions: React.FC = ({
role,
isUserRole,
isSystem,
+ canCreateRoles,
canUpdateRoles,
canDeleteRoles,
onEdit,
+ onClone,
onDelete,
}) => (
@@ -335,6 +363,15 @@ const RoleActions: React.FC = ({
onClick={() => onEdit(role)}
disabled={!canUpdateRoles}
/>
+ {/* Cloning writes a NEW role, so it is gated on create - not update. A
+ system role can be cloned even though it cannot be edited: the copy is
+ an ordinary role and the original is untouched. */}
+ onClone(role)}
+ disabled={!canCreateRoles}
+ />
= {}) =>
+ ({ id, ...extra }) satisfies AccessRuleEntry;
+
+describe("groupAccessRulesByCategory", () => {
+ test("sorts categories alphabetically regardless of registration order", () => {
+ const categories = groupAccessRulesByCategory({
+ rules: [
+ rule("satellite.read"),
+ rule("auth.roles.manage"),
+ rule("dependency.read"),
+ ],
+ });
+
+ expect(categories.map((c) => c.pluginId)).toEqual([
+ "auth",
+ "dependency",
+ "satellite",
+ ]);
+ });
+
+ test("sorts rules inside a category by id", () => {
+ const categories = groupAccessRulesByCategory({
+ rules: [rule("auth.users.manage"), rule("auth.roles.manage")],
+ });
+
+ expect(categories[0].rules.map((r) => r.id)).toEqual([
+ "auth.roles.manage",
+ "auth.users.manage",
+ ]);
+ });
+
+ test("sorts on the rendered label, not the raw plugin id", () => {
+ // `auth-github` renders as "auth github", which sorts BEFORE "authz" -
+ // the opposite of raw id order, where "-" (0x2D) precedes "z" anyway but
+ // the space form is what the reader scans.
+ const categories = groupAccessRulesByCategory({
+ rules: [rule("authz.read"), rule("auth-github.read"), rule("auth.read")],
+ });
+
+ expect(categories.map((c) => c.label)).toEqual([
+ "auth",
+ "auth github",
+ "authz",
+ ]);
+ });
+
+ test("keeps every rule and never drops a category", () => {
+ const rules = [
+ rule("b.one"),
+ rule("a.one"),
+ rule("b.two"),
+ rule("c.one"),
+ ];
+
+ const categories = groupAccessRulesByCategory({ rules });
+
+ expect(categories).toHaveLength(3);
+ expect(categories.flatMap((c) => c.rules)).toHaveLength(rules.length);
+ });
+
+ test("treats an id with no dot as its own category", () => {
+ const categories = groupAccessRulesByCategory({ rules: [rule("*")] });
+
+ expect(categories).toEqual([
+ { pluginId: "*", label: "*", rules: [rule("*")] },
+ ]);
+ });
+
+ test("returns an empty list for no rules", () => {
+ expect(groupAccessRulesByCategory({ rules: [] })).toEqual([]);
+ });
+});
+
+describe("toCategoryLabel", () => {
+ test("replaces every hyphen, not just the first", () => {
+ expect(toCategoryLabel({ pluginId: "healthcheck-http-backend" })).toBe(
+ "healthcheck http backend",
+ );
+ });
+});
+
+describe("getCategorySelectionState", () => {
+ const selectableIds = ["a.one", "a.two", "a.three"];
+
+ test("reports none when nothing is selected", () => {
+ expect(
+ getCategorySelectionState({ selected: new Set(), selectableIds }),
+ ).toBe("none");
+ });
+
+ test("reports some for a partial selection", () => {
+ expect(
+ getCategorySelectionState({
+ selected: new Set(["a.two"]),
+ selectableIds,
+ }),
+ ).toBe("some");
+ });
+
+ test("reports all when every selectable id is selected", () => {
+ expect(
+ getCategorySelectionState({
+ selected: new Set(selectableIds),
+ selectableIds,
+ }),
+ ).toBe("all");
+ });
+
+ test("ignores selected ids from other categories", () => {
+ expect(
+ getCategorySelectionState({
+ selected: new Set([...selectableIds, "b.one"]),
+ selectableIds,
+ }),
+ ).toBe("all");
+ });
+
+ test("reports none when the category has nothing selectable", () => {
+ expect(
+ getCategorySelectionState({
+ selected: new Set(["a.one"]),
+ selectableIds: [],
+ }),
+ ).toBe("none");
+ });
+});
+
+describe("setCategorySelection", () => {
+ test("selecting adds every selectable id", () => {
+ const next = setCategorySelection({
+ selected: new Set(),
+ selectableIds: ["a.one", "a.two"],
+ categoryIds: ["a.one", "a.two"],
+ select: true,
+ });
+
+ expect([...next].toSorted()).toEqual(["a.one", "a.two"]);
+ });
+
+ test("selecting never adds a blocked rule", () => {
+ // `a.blocked` is in the category but not selectable (anonymous role).
+ const next = setCategorySelection({
+ selected: new Set(),
+ selectableIds: ["a.one"],
+ categoryIds: ["a.one", "a.blocked"],
+ select: true,
+ });
+
+ expect(next.has("a.blocked")).toBe(false);
+ expect(next.has("a.one")).toBe(true);
+ });
+
+ test("selecting leaves other categories untouched", () => {
+ const next = setCategorySelection({
+ selected: new Set(["b.one"]),
+ selectableIds: ["a.one"],
+ categoryIds: ["a.one"],
+ select: true,
+ });
+
+ expect(next.has("b.one")).toBe(true);
+ });
+
+ test("clearing removes every id in the category, including blocked ones", () => {
+ // A blocked rule that is ALREADY selected (a pre-existing grant) must still
+ // be clearable - otherwise the category can never reach an empty state.
+ const next = setCategorySelection({
+ selected: new Set(["a.one", "a.blocked", "b.one"]),
+ selectableIds: ["a.one"],
+ categoryIds: ["a.one", "a.blocked"],
+ select: false,
+ });
+
+ expect([...next]).toEqual(["b.one"]);
+ });
+
+ test("returns a new set and does not mutate the input", () => {
+ const selected = new Set(["a.one"]);
+ const next = setCategorySelection({
+ selected,
+ selectableIds: ["a.two"],
+ categoryIds: ["a.two"],
+ select: true,
+ });
+
+ expect(next).not.toBe(selected);
+ expect([...selected]).toEqual(["a.one"]);
+ });
+
+ test("is idempotent", () => {
+ const once = setCategorySelection({
+ selected: new Set(),
+ selectableIds: ["a.one"],
+ categoryIds: ["a.one"],
+ select: true,
+ });
+ const twice = setCategorySelection({
+ selected: once,
+ selectableIds: ["a.one"],
+ categoryIds: ["a.one"],
+ select: true,
+ });
+
+ expect([...twice]).toEqual([...once]);
+ });
+});
diff --git a/core/auth-frontend/src/components/role-rules.logic.ts b/core/auth-frontend/src/components/role-rules.logic.ts
new file mode 100644
index 000000000..0793a926a
--- /dev/null
+++ b/core/auth-frontend/src/components/role-rules.logic.ts
@@ -0,0 +1,111 @@
+import type { AccessRuleEntry } from "../api";
+
+/**
+ * One plugin's access rules, ready to render as an accordion category.
+ *
+ * `label` is the HUMAN-READABLE category name (the plugin id with hyphens
+ * turned into spaces). Categories sort on it rather than on the raw plugin id
+ * so the order matches what the reader actually sees.
+ */
+export interface AccessRuleCategory {
+ /** The plugin id the rules belong to (the accordion item's stable value). */
+ pluginId: string;
+ /** Display name shown in the accordion header. */
+ label: string;
+ rules: AccessRuleEntry[];
+}
+
+/**
+ * Group access rules by their owning plugin and sort BOTH levels
+ * alphabetically.
+ *
+ * Rules arrive in plugin-REGISTRATION order, which is effectively arbitrary to
+ * a reader looking for "Satellite" or "Dependency" in a long list. Sorting is
+ * by the rendered label (and, within a category, by rule id) using
+ * `localeCompare` so the order matches the reading order rather than raw
+ * code-point order.
+ */
+export function groupAccessRulesByCategory({
+ rules,
+}: {
+ rules: readonly AccessRuleEntry[];
+}): AccessRuleCategory[] {
+ const byPlugin = new Map();
+
+ for (const rule of rules) {
+ const [pluginId = rule.id] = rule.id.split(".");
+ const existing = byPlugin.get(pluginId);
+ if (existing) {
+ existing.push(rule);
+ } else {
+ byPlugin.set(pluginId, [rule]);
+ }
+ }
+
+ return [...byPlugin.entries()]
+ .map(([pluginId, pluginRules]) => ({
+ pluginId,
+ label: toCategoryLabel({ pluginId }),
+ rules: pluginRules.toSorted((a, b) => a.id.localeCompare(b.id)),
+ }))
+ .toSorted((a, b) => a.label.localeCompare(b.label));
+}
+
+/** The accordion header text for a plugin id (`auth-github` -> `auth github`). */
+export function toCategoryLabel({ pluginId }: { pluginId: string }): string {
+ return pluginId.replaceAll("-", " ");
+}
+
+/**
+ * How much of a category is currently selected. Drives which bulk action the
+ * header offers ("Select all" until everything selectable is on, then "Clear").
+ */
+export type CategorySelectionState = "none" | "some" | "all";
+
+export function getCategorySelectionState({
+ selected,
+ selectableIds,
+}: {
+ selected: ReadonlySet;
+ selectableIds: readonly string[];
+}): CategorySelectionState {
+ if (selectableIds.length === 0) return "none";
+ const count = selectableIds.filter((id) => selected.has(id)).length;
+ if (count === 0) return "none";
+ return count === selectableIds.length ? "all" : "some";
+}
+
+/**
+ * Select or clear a whole category, returning a NEW set.
+ *
+ * `selectableIds` is the category's ids MINUS anything the caller has ruled out
+ * (for the anonymous role, rules no public endpoint uses). Selecting therefore
+ * can never add a blocked rule, mirroring the per-checkbox guard - the bulk
+ * action must not be a way around a restriction the single toggle enforces.
+ *
+ * Clearing removes only the category's own ids, so a bulk clear never disturbs
+ * a selection in another category. Ids already selected but no longer
+ * selectable (e.g. a legacy grant) are left alone on select and removed on
+ * clear, so the action is always a no-worse-off operation.
+ */
+export function setCategorySelection({
+ selected,
+ selectableIds,
+ categoryIds,
+ select,
+}: {
+ selected: ReadonlySet;
+ selectableIds: readonly string[];
+ categoryIds: readonly string[];
+ select: boolean;
+}): Set {
+ const next = new Set(selected);
+
+ if (select) {
+ for (const id of selectableIds) next.add(id);
+ return next;
+ }
+
+ for (const id of categoryIds) next.delete(id);
+ return next;
+}
diff --git a/core/common/src/clone-naming.test.ts b/core/common/src/clone-naming.test.ts
new file mode 100644
index 000000000..53259bb63
--- /dev/null
+++ b/core/common/src/clone-naming.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from "bun:test";
+import { CLONE_NAME_SUFFIX, buildClonedName } from "./clone-naming";
+
+describe("buildClonedName", () => {
+ test("appends the suffix", () => {
+ expect(buildClonedName({ name: "Payments API" })).toBe(
+ "Payments API (copy)",
+ );
+ });
+
+ test("appends again when cloning a clone, so two copies never collide", () => {
+ expect(buildClonedName({ name: "Developer (copy)" })).toBe(
+ "Developer (copy) (copy)",
+ );
+ });
+
+ test("trims the source name so the suffix is never double-spaced", () => {
+ expect(buildClonedName({ name: "Staging " })).toBe("Staging (copy)");
+ });
+
+ test("handles an empty name without producing a leading space", () => {
+ expect(buildClonedName({ name: "" })).toBe(CLONE_NAME_SUFFIX);
+ });
+});
diff --git a/core/common/src/clone-naming.ts b/core/common/src/clone-naming.ts
new file mode 100644
index 000000000..cebc99136
--- /dev/null
+++ b/core/common/src/clone-naming.ts
@@ -0,0 +1,23 @@
+/**
+ * Naming for "clone this" flows (roles, systems, environments, ...).
+ *
+ * Shared so every clone affordance in the product produces the same shape of
+ * name. The suffix matters for two reasons: the copy cannot be mistaken for the
+ * original in a list before the author renames it, and a name-unique resource
+ * cannot fail to save merely because the seeded name collided.
+ */
+
+/** The suffix appended to a cloned resource's name. */
+export const CLONE_NAME_SUFFIX = "(copy)";
+
+/**
+ * The name a cloned resource opens with.
+ *
+ * Deliberately NOT idempotent: cloning a clone yields `Foo (copy) (copy)`.
+ * Collapsing repeats would make two different copies of the same source share a
+ * name, which is exactly the collision the suffix exists to avoid.
+ */
+export function buildClonedName({ name }: { name: string }): string {
+ const trimmed = name.trim();
+ return trimmed ? `${trimmed} ${CLONE_NAME_SUFFIX}` : CLONE_NAME_SUFFIX;
+}
From 8d0e924d1f39773ffb977647d3f1346486788994 Mon Sep 17 00:00:00 2001
From: enyineer
Date: Wed, 29 Jul 2026 06:12:19 +0200
Subject: [PATCH 05/36] feat(catalog-frontend): clone systems and environments
A Clone row action opens the editor seeded from the source and saves as a
create.
The clone is deliberately SHALLOW: name (suffixed), description and custom
fields only. Memberships, links, team grants and health-check assignments are
not copied, and the dialog says so - duplicating check assignments would
silently multiply probe volume with every clone.
Gated on CREATE rather than manage of the source, and a GitOps lock on the
source does not block it: the copy is a new, unmanaged record.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012bdCuhhRWywMnaCMQmjzv1
---
.../catalog-clone-systems-environments.md | 17 ++++
.../src/components/CatalogConfigPage.tsx | 36 +++++++-
.../src/components/EnvironmentEditor.tsx | 76 ++++++++++++++---
.../src/components/SystemEditor.tsx | 82 +++++++++++++++----
.../components/catalog-clone.logic.test.ts | 36 ++++++++
.../src/components/catalog-clone.logic.ts | 54 ++++++++++++
.../src/components/manage/EnvironmentsTab.tsx | 25 +++++-
.../src/components/manage/SystemsTab.tsx | 35 +++++++-
8 files changed, 330 insertions(+), 31 deletions(-)
create mode 100644 .changeset/catalog-clone-systems-environments.md
create mode 100644 core/catalog-frontend/src/components/catalog-clone.logic.test.ts
create mode 100644 core/catalog-frontend/src/components/catalog-clone.logic.ts
diff --git a/.changeset/catalog-clone-systems-environments.md b/.changeset/catalog-clone-systems-environments.md
new file mode 100644
index 000000000..622e093fe
--- /dev/null
+++ b/.changeset/catalog-clone-systems-environments.md
@@ -0,0 +1,17 @@
+---
+"@checkstack/catalog-frontend": minor
+---
+
+Clone systems and environments
+
+Systems and environments gained a **Clone** row action, which opens the editor
+pre-seeded from the source record and saves as a create.
+
+The clone is deliberately SHALLOW: name (suffixed), description and custom
+fields only. Group and environment memberships, tags, contacts, links, team
+grants and health-check assignments are NOT copied, and the dialog says so.
+Duplicating health-check assignments in particular would silently multiply probe
+volume and notification noise with every clone.
+
+Cloning is gated on CREATE, not on manage of the source, and a GitOps lock on
+the source does not block it - the copy is a new, unmanaged record.
diff --git a/core/catalog-frontend/src/components/CatalogConfigPage.tsx b/core/catalog-frontend/src/components/CatalogConfigPage.tsx
index 7265855a2..460b7244a 100644
--- a/core/catalog-frontend/src/components/CatalogConfigPage.tsx
+++ b/core/catalog-frontend/src/components/CatalogConfigPage.tsx
@@ -44,6 +44,7 @@ const CatalogLearnMore = () => (
);
import { SystemEditor } from "./SystemEditor";
+import type { CatalogEditorMode } from "./catalog-clone.logic";
import { GroupEditor } from "./GroupEditor";
import { EnvironmentEditor } from "./EnvironmentEditor";
import { useCatalogBrowseState } from "../hooks/useCatalogBrowseState";
@@ -85,9 +86,16 @@ export const CatalogConfigPage = () => {
// Dialog state
const [isSystemEditorOpen, setIsSystemEditorOpen] = useState(false);
const [editingSystem, setEditingSystem] = useState();
+ // `editingSystem` alone cannot distinguish an edit from a clone - both carry a
+ // source record - so the mode is tracked explicitly and decides which
+ // mutation the save handler runs.
+ const [systemEditorMode, setSystemEditorMode] =
+ useState("create");
const [isGroupEditorOpen, setIsGroupEditorOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState();
const [isEnvironmentEditorOpen, setIsEnvironmentEditorOpen] = useState(false);
+ const [environmentEditorMode, setEnvironmentEditorMode] =
+ useState("create");
const [editingEnvironment, setEditingEnvironment] = useState<
Environment | undefined
>();
@@ -362,7 +370,8 @@ export const CatalogConfigPage = () => {
teamId?: string;
metadata?: Record;
}) => {
- if (editingSystem) {
+ // A clone has an `editingSystem` (its source) but must CREATE.
+ if (systemEditorMode === "edit" && editingSystem) {
updateSystemMutation.mutate({
id: editingSystem.id,
data: {
@@ -456,7 +465,8 @@ export const CatalogConfigPage = () => {
teamId?: string;
metadata?: Record;
}) => {
- if (editingEnvironment) {
+ // A clone has an `editingEnvironment` (its source) but must CREATE.
+ if (environmentEditorMode === "edit" && editingEnvironment) {
// Edit never carries teamId (create-only); strip it defensively.
const { teamId: _teamId, ...updateData } = data;
updateEnvironmentMutation.mutate({
@@ -605,10 +615,17 @@ export const CatalogConfigPage = () => {
onRemoveFromEnvironment={handleRemoveSystemEnvironment}
onAddSystem={() => {
setEditingSystem(undefined);
+ setSystemEditorMode("create");
setIsSystemEditorOpen(true);
}}
onEditSystem={(s) => {
setEditingSystem(s);
+ setSystemEditorMode("edit");
+ setIsSystemEditorOpen(true);
+ }}
+ onCloneSystem={(s) => {
+ setEditingSystem(s);
+ setSystemEditorMode("clone");
setIsSystemEditorOpen(true);
}}
onDeleteSystem={handleDeleteSystem}
@@ -655,10 +672,17 @@ export const CatalogConfigPage = () => {
onAttachSystemToEnvironments={handleAttachSystemToEnvironments}
onAddEnvironment={() => {
setEditingEnvironment(undefined);
+ setEnvironmentEditorMode("create");
setIsEnvironmentEditorOpen(true);
}}
onEditEnvironment={(env) => {
setEditingEnvironment(env);
+ setEnvironmentEditorMode("edit");
+ setIsEnvironmentEditorOpen(true);
+ }}
+ onCloneEnvironment={(env) => {
+ setEditingEnvironment(env);
+ setEnvironmentEditorMode("clone");
setIsEnvironmentEditorOpen(true);
}}
onDeleteEnvironment={handleDeleteEnvironment}
@@ -673,8 +697,14 @@ export const CatalogConfigPage = () => {
onClose={() => {
setIsSystemEditorOpen(false);
setEditingSystem(undefined);
+ // Reset the mode too. Every open path sets it today, so a stale
+ // "clone" is currently unreachable - but leaving it set means a
+ // future open path that forgets would silently render a Clone dialog
+ // with no source record.
+ setSystemEditorMode("create");
}}
onSave={handleSaveSystem}
+ mode={systemEditorMode}
initialData={
editingSystem
? {
@@ -706,8 +736,10 @@ export const CatalogConfigPage = () => {
onClose={() => {
setIsEnvironmentEditorOpen(false);
setEditingEnvironment(undefined);
+ setEnvironmentEditorMode("create");
}}
onSave={handleSaveEnvironment}
+ mode={environmentEditorMode}
initialData={
editingEnvironment
? {
diff --git a/core/catalog-frontend/src/components/EnvironmentEditor.tsx b/core/catalog-frontend/src/components/EnvironmentEditor.tsx
index 2c74675c9..ff01f6630 100644
--- a/core/catalog-frontend/src/components/EnvironmentEditor.tsx
+++ b/core/catalog-frontend/src/components/EnvironmentEditor.tsx
@@ -9,6 +9,11 @@ import {
DialogHeader,
DialogTitle,
DialogFooter,
+ Alert,
+ AlertContent,
+ AlertDescription,
+ AlertIcon,
+ AlertTitle,
useToast,
toastError,
useSeedFormOnOpen,
@@ -19,13 +24,21 @@ import {
teamCreateErrorMessage,
} from "@checkstack/auth-frontend";
import { useApi, accessApiRef } from "@checkstack/frontend-api";
+import { buildClonedName } from "@checkstack/common";
import { catalogAccess, catalogResourceTypes } from "@checkstack/catalog-common";
+import {
+ CLONE_SCOPE_NOTE,
+ isCreateMode,
+ resolveEditorMode,
+ type CatalogEditorMode,
+} from "./catalog-clone.logic";
import {
metadataToRows,
rowsToMetadata,
hasDuplicateKeys,
type CustomFieldRow,
} from "./environment-fields.logic";
+import { Copy } from "lucide-react";
import { CustomFieldsEditor } from "./CustomFieldsEditor";
export interface EnvironmentEditorInitialData {
@@ -44,7 +57,13 @@ interface EnvironmentEditorProps {
teamId?: string;
metadata?: Record;
}) => Promise;
+ /**
+ * The environment being edited, or - in `clone` mode - the one the new
+ * environment is seeded from.
+ */
initialData?: EnvironmentEditorInitialData;
+ /** Defaults to `edit` when `initialData` is present, else `create`. */
+ mode?: CatalogEditorMode;
}
/**
@@ -58,8 +77,23 @@ export const EnvironmentEditor: React.FC = ({
onClose,
onSave,
initialData,
+ mode,
}) => {
- const [name, setName] = useState(initialData?.name ?? "");
+ const editorMode = resolveEditorMode({
+ mode,
+ hasInitialData: Boolean(initialData),
+ });
+ const isCloning = editorMode === "clone";
+ const creating = isCreateMode({ mode: editorMode });
+ // A clone opens with a suffixed name so it cannot be confused with - or saved
+ // over - the environment it was copied from.
+ const seededName = initialData
+ ? isCloning
+ ? buildClonedName({ name: initialData.name })
+ : initialData.name
+ : "";
+
+ const [name, setName] = useState(seededName);
const [description, setDescription] = useState(
initialData?.description ?? "",
);
@@ -83,7 +117,7 @@ export const EnvironmentEditor: React.FC = ({
// environment while open, so a `useEffect([open, initialData])` would re-seed
// on refetch and wipe in-progress edits.
useSeedFormOnOpen(open, () => {
- setName(initialData?.name ?? "");
+ setName(seededName);
setDescription(initialData?.description ?? "");
setFields(metadataToRows(initialData?.metadata));
setOwnerTeamId(null);
@@ -105,7 +139,9 @@ export const EnvironmentEditor: React.FC = ({
name: name.trim(),
description: description.trim() || undefined,
metadata: rowsToMetadata(fields),
- ...(initialData ? {} : { teamId: ownerTeamId ?? undefined }),
+ // A clone saves through the CREATE path, so it must carry the owning
+ // team like any other new environment - not be treated as an update.
+ ...(creating ? { teamId: ownerTeamId ?? undefined } : {}),
});
onClose();
} catch (error) {
@@ -126,12 +162,18 @@ export const EnvironmentEditor: React.FC = ({