diff --git a/frontend/src/lib/components/DevWorkspaceSetting.svelte b/frontend/src/lib/components/DevWorkspaceSetting.svelte index e6295d24cd..2537105c72 100644 --- a/frontend/src/lib/components/DevWorkspaceSetting.svelte +++ b/frontend/src/lib/components/DevWorkspaceSetting.svelte @@ -10,7 +10,11 @@ import { base } from '$lib/base' import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy' import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel' - import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte' + import { + loadProtectionRules, + fetchProtectionRulesForWorkspace, + isRuleUnconditionallyActiveInRulesets + } from '$lib/workspaceProtectionRules.svelte' import { GitFork, ExternalLink } from 'lucide-svelte' import { resource } from 'runed' @@ -55,6 +59,48 @@ let busy = $state(false) let labelBusy = $state(false) + // If this workspace already blocks direct deploy / forking through an existing protection rule, keep + // the matching lock toggle on but locked: attaching only manages its own reserved dev-workspace rule, + // so turning it "off" here couldn't lift a separately-defined block. Fetched only while the attach form + // is on screen; a failed fetch falls back to the editable default-on toggle (real rules still enforce). + const rootProtectionRules = resource( + () => (!parentId && !pairedDev ? $workspaceStore : undefined), + async (ws, _prev, { signal }) => { + if (!ws) return undefined + const rules = await fetchProtectionRulesForWorkspace(ws) + // The generated client can't take an abort signal, so drop a superseded response here: a late + // result for a previously selected workspace must not overwrite the current one's rules. + if (signal.aborted) throw new DOMException('superseded', 'AbortError') + return { ws, rules } + } + ) + // Only trust a result that belongs to the current workspace (guards the in-flight window and any + // out-of-order response); undefined means "not known yet" and is treated as locked below. + let rootRules = $derived.by(() => { + const current = rootProtectionRules.current + return current && current.ws === $workspaceStore ? current.rules : undefined + }) + // Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would create; a + // bypassable rule stays editable, otherwise forcing the lock on would revoke the bypassed users' + // direct-deploy / forking access. + let alreadyBlocksDeploy = $derived( + isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableDirectDeployment') + ) + let alreadyBlocksForking = $derived( + isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableWorkspaceForking') + ) + // Until the fetch resolves for the current workspace its rules are unknown. Treat each lock as + // engaged during that window so the toggle is locked on and the effective value stays true: + // otherwise a user could turn a lock off and attach before an existing rule is detected, sending + // false and omitting the reserved rule — leaving prod unprotected if that rule is later removed. + let rulesUnknown = $derived(rootProtectionRules.loading || rootRules === undefined) + let deployLocked = $derived(alreadyBlocksDeploy || rulesUnknown) + let forkingLocked = $derived(alreadyBlocksForking || rulesUnknown) + // Sent to the backend: a locked restriction (enforced or not-yet-known) stays on regardless of the + // toggle's raw state, keeping the request consistent with what the locked toggle shows. + let effectiveLockProdDeploy = $derived(deployLocked || lockProdDeploy) + let effectiveLockProdForking = $derived(forkingLocked || lockProdForking) + // A standalone root workspace, or an existing fork of this prod (same family), can be attached. // A fork parented to a different workspace can't (the backend rejects a parent that isn't this // prod), so it's excluded here. @@ -92,8 +138,8 @@ workspace: $workspaceStore, requestBody: { dev_workspace_id: selectedDevId, - lock_prod_deploy: lockProdDeploy, - lock_prod_forking: lockProdForking, + lock_prod_deploy: effectiveLockProdDeploy, + lock_prod_forking: effectiveLockProdForking, dev_workspace_label: attachLabel } }) @@ -217,13 +263,44 @@ Change to {attachLabel === 'staging' ? 'dev' : 'staging'} - - + {#if deployLocked} +
+ + {#if alreadyBlocksDeploy} + Already enforced by an existing protection rule + {/if} +
+ {:else} + + {/if} + {#if forkingLocked} +
+ + {#if alreadyBlocksForking} + Already enforced by an existing protection rule + {/if} +
+ {:else} + + {/if}
- - + {#if deployLocked} +
+ + {#if rootAlreadyBlocksDeploy} + Already enforced by an existing protection rule + {/if} +
+ {:else} + + {/if} + {#if forkingLocked} +
+ + {#if rootAlreadyBlocksForking} + Already enforced by an existing protection rule + {/if} +
+ {:else} + + {/if} {/if} diff --git a/frontend/src/lib/workspaceProtectionRules.svelte.ts b/frontend/src/lib/workspaceProtectionRules.svelte.ts index 1774ed60e2..9bca16005f 100644 --- a/frontend/src/lib/workspaceProtectionRules.svelte.ts +++ b/frontend/src/lib/workspaceProtectionRules.svelte.ts @@ -182,6 +182,23 @@ export function isRuleActiveInRulesets( return rulesets.some((ruleset) => ruleset.rules.includes(ruleKind)) } +/** + * Whether a rule kind is enforced with no bypass users/groups in at least one ruleset, the only case + * that matches the empty-bypass reserved dev-workspace lock. A bypassable rule does not, since adding + * the unconditional lock would revoke those users' access; callers keep such a toggle editable. + */ +export function isRuleUnconditionallyActiveInRulesets( + rulesets: ProtectionRuleset[], + ruleKind: ProtectionRuleKind +): boolean { + return rulesets.some( + (ruleset) => + ruleset.rules.includes(ruleKind) && + ruleset.bypass_users.length === 0 && + ruleset.bypass_groups.length === 0 + ) +} + /** * Checks if user can bypass a rule kind in given rulesets (workspace-agnostic version) * @param rulesets Array of protection rulesets to check