mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
* feat(frontend): chat-scoped unified session changes bar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(frontend): drop diff-baseline toggle, show natural per-row diffs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): carry Draft marker to expanded raw-app file rows Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): show raw-app Draft badge once at tree root, not per file Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(frontend): reuse shared DraftBadge in session diff drawer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): show draft-author avatars in session diff badge, icon-only in sidebar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): keep badge pill around avatar in icon-only DraftBadge Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): small draft marker = indigo pen + avatar; correct itemKind label Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(frontend): drop package-lock churn from merge (match origin/main) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): dedup diff-button count for legacy fork sessions; test mask helper Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): collapsible session diff panel + per-row open-diff action Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): shared sessionDeployModel for review & deploy (S1) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): model-driven session review drawer, deploy inert (S2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): wire session deploy + on-behalf/conflict gating (S3) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): behind banner + Update fork + deployment request (S4) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): session changes dock opens drawer by filter (S5) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): session review UI polish (badge, dock, In parent, tree width) - draft rows show only the avatar DraftBadge, not a duplicate state pill - drop redundant dock Review button (same as "N to review") - rename Done -> In parent with a "deployed in parent workspace" tooltip - widen the file tree Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): existence-gate In parent rows; Badge filters; badge hover - drop discarded mask-only items from the In-parent segment (existence check) - use the Badge component for the drawer filter segments and the changes dock - soften the blue Badge hover (blue-50 base was jumping to blue-200) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): polish session diff drawer (layout, badges, actions) - remove empty fork-banner gap; uniform sidebar tree padding - full-bleed diff list: drop card borders/side padding, separators between items - clamp tree x-overflow; right-align tree badges (min-w-0 on the row button) - brand-compliant selected filter badges; smaller draft badge - hide per-row open-diff button when the panel is open - rename "Delete draft" to "Discard draft" (destructive); remove header Review button - larger sm deploy/discard action buttons; remove per-item diff-content collapse Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): session Edits dock — deploy gating + change-op tracking Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(frontend): session bar per-status badges; drop change-op tracking Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): unwrap raw apps into per-file tree in session diff sidebar Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): dot-parcours pipeline (badge-derived, melt tooltip) + discard confirm Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): behind-only session item reads as deployed, not bare Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(frontend): even sidebar tree margins; gutter-aware right padding Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): pass chat id as from_session; wire deploying flag Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): session diff drawer scroll-to-flush, ordering, spacer, deploy gating Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): preserve chat mask on compact; guard stale existence checks; clear poll timers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): refresh bar after drawer deploys; conflict hint over chip; plain conflict badge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(frontend): diff drawer card layout with flash ring and aligned insets Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): hide stale deployed chip once row status badge reads deployed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(frontend): session dock to two states; drop parent deploy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): staged deploy animation in session edits drawer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): stale-draft warning in session edits drawer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): keep chat mask honest on deploy and discard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): map trigger_email deploy kind; serialize mask persists Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): reset mask on new chat; close review-flagged races Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(frontend): rename session drawer title to Edited during session Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): keep mask persist queue alive after a failed save Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): sync session chatId on chat rotation; gate deploy on canWrite Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(frontend): keep compare handoff for deletion-only session edits Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(frontend): hold deploy success beat across re-keyed rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
6.5 KiB
TypeScript
225 lines
6.5 KiB
TypeScript
import { WorkspaceService, type ProtectionRuleset, type ProtectionRuleKind } from './gen'
|
|
import type { UserExt } from './stores'
|
|
|
|
// The slice of the user identity the bypass checks read — structural, so
|
|
// callers can pass a whoami response (normalised groups) as well as the
|
|
// UserExt store value.
|
|
export type RuleBypassUser = Pick<UserExt, 'is_admin' | 'username' | 'groups'>
|
|
|
|
/**
|
|
* Internal reactive state using Svelte 5 $state rune
|
|
*/
|
|
let state = $state<{
|
|
rulesets: ProtectionRuleset[] | undefined
|
|
loading: boolean
|
|
error: string | undefined
|
|
workspace: string | undefined
|
|
}>({
|
|
rulesets: undefined,
|
|
loading: false,
|
|
error: undefined,
|
|
workspace: undefined
|
|
})
|
|
|
|
/**
|
|
* Exported reactive state object with readonly getters
|
|
*/
|
|
export const protectionRulesState = {
|
|
get rulesets() {
|
|
return state.rulesets
|
|
},
|
|
get loading() {
|
|
return state.loading
|
|
},
|
|
get error() {
|
|
return state.error
|
|
},
|
|
get workspace() {
|
|
return state.workspace
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal function to reset state (used by storeUtils)
|
|
*/
|
|
export function resetProtectionRules() {
|
|
state.rulesets = undefined
|
|
state.loading = false
|
|
state.error = undefined
|
|
state.workspace = undefined
|
|
}
|
|
|
|
/**
|
|
* Loads protection rules for a workspace from the API and updates the state
|
|
* Early returns if already loading the same workspace to prevent duplicate requests
|
|
*/
|
|
export async function loadProtectionRules(workspace: string): Promise<void> {
|
|
// Early return if already loading for this workspace
|
|
if (state.loading && state.workspace === workspace) {
|
|
return
|
|
}
|
|
|
|
state.loading = true
|
|
state.workspace = workspace
|
|
|
|
try {
|
|
const rulesets = await WorkspaceService.listProtectionRules({ workspace })
|
|
state.rulesets = rulesets
|
|
state.loading = false
|
|
state.error = undefined
|
|
} catch (error) {
|
|
console.error('Failed to load protection rulesets:', error)
|
|
// Fail open: set empty array to allow operations
|
|
state.rulesets = []
|
|
state.loading = false
|
|
state.error = error instanceof Error ? error.message : 'Unknown error'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches protection rules for a specific workspace without updating the state
|
|
* @param workspace The workspace ID to fetch rules for
|
|
* @returns Array of protection rulesets, or empty array on error
|
|
*/
|
|
export async function fetchProtectionRulesForWorkspace(
|
|
workspace: string
|
|
): Promise<ProtectionRuleset[]> {
|
|
try {
|
|
const rulesets = await WorkspaceService.listProtectionRules({ workspace })
|
|
return rulesets
|
|
} catch (error) {
|
|
console.error(`Failed to fetch protection rules for workspace ${workspace}:`, error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a user can bypass a specific ruleset
|
|
* @param ruleset The protection ruleset to check
|
|
* @param userInfo The user information
|
|
* @returns true if user can bypass (is admin, in bypass_users, or has group in bypass_groups)
|
|
*/
|
|
export function canUserBypassRule(ruleset: ProtectionRuleset, userInfo: RuleBypassUser): boolean {
|
|
// Admin always bypasses
|
|
if (userInfo.is_admin) {
|
|
return true
|
|
}
|
|
|
|
if (ruleset.bypass_users.includes(userInfo.username)) {
|
|
return true
|
|
}
|
|
|
|
if (ruleset.bypass_groups.some((bg) => userInfo.groups.includes(bg))) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Checks if a specific rule type is active in ANY ruleset
|
|
* FIXED: No longer uses await without async context
|
|
* @param ruleKind The rule type to check
|
|
* @returns true if the rule is active in at least one ruleset, false if not loaded or not active
|
|
*/
|
|
export function isRuleActive(ruleKind: ProtectionRuleKind): boolean {
|
|
// Safe default: return false if rules not loaded yet
|
|
if (!state.rulesets) {
|
|
return false
|
|
}
|
|
|
|
return state.rulesets.some((ruleset) => ruleset.rules.includes(ruleKind))
|
|
}
|
|
|
|
/**
|
|
* Checks if a user can bypass a specific rule type
|
|
* @param ruleKind The rule type to check
|
|
* @param userInfo The user information
|
|
* @returns true if the rule is not active OR user can bypass ALL rulesets containing it
|
|
*/
|
|
export function canUserBypassRuleKind(
|
|
ruleKind: ProtectionRuleKind,
|
|
userInfo: RuleBypassUser | undefined
|
|
): boolean {
|
|
// If no user info, default to permissive
|
|
if (!userInfo) {
|
|
return false
|
|
}
|
|
|
|
if (!state.rulesets) {
|
|
return true // No rules loaded, allow
|
|
}
|
|
|
|
// Find all rulesets containing this rule
|
|
const rulesetsWithThisRule = state.rulesets.filter((rs) => rs.rules.includes(ruleKind))
|
|
|
|
if (rulesetsWithThisRule.length === 0) {
|
|
return true // Rule not active
|
|
}
|
|
|
|
// User must be able to bypass ALL rulesets containing this rule
|
|
return rulesetsWithThisRule.every((rs) => canUserBypassRule(rs, userInfo))
|
|
}
|
|
|
|
/**
|
|
* Returns all rulesets that contain a specific rule kind
|
|
*/
|
|
export function getActiveRulesetsForKind(ruleKind: ProtectionRuleKind): ProtectionRuleset[] {
|
|
if (!state.rulesets) return []
|
|
return state.rulesets.filter((rs) => rs.rules.includes(ruleKind))
|
|
}
|
|
|
|
/**
|
|
* Checks if a specific rule kind is active in given rulesets (workspace-agnostic version)
|
|
* @param rulesets Array of protection rulesets to check
|
|
* @param ruleKind The rule type to check
|
|
* @returns true if the rule is active in at least one ruleset
|
|
*/
|
|
export function isRuleActiveInRulesets(
|
|
rulesets: ProtectionRuleset[],
|
|
ruleKind: ProtectionRuleKind
|
|
): boolean {
|
|
return rulesets.some((ruleset) => ruleset.rules.includes(ruleKind))
|
|
}
|
|
|
|
/**
|
|
* Checks if user can bypass a rule kind in given rulesets (workspace-agnostic version)
|
|
* @param rulesets Array of protection rulesets to check
|
|
* @param ruleKind The rule type to check
|
|
* @param userInfo The user information
|
|
* @returns true if the rule is not active OR user can bypass ALL rulesets containing it
|
|
*/
|
|
export function canUserBypassRuleKindInRulesets(
|
|
rulesets: ProtectionRuleset[],
|
|
ruleKind: ProtectionRuleKind,
|
|
userInfo: RuleBypassUser | undefined
|
|
): boolean {
|
|
// If no user info, default to not allowing bypass
|
|
if (!userInfo) {
|
|
return false
|
|
}
|
|
|
|
// Find all rulesets containing this rule
|
|
const rulesetsWithThisRule = rulesets.filter((rs) => rs.rules.includes(ruleKind))
|
|
|
|
if (rulesetsWithThisRule.length === 0) {
|
|
return true // Rule not active
|
|
}
|
|
|
|
// User must be able to bypass ALL rulesets containing this rule
|
|
return rulesetsWithThisRule.every((rs) => canUserBypassRule(rs, userInfo))
|
|
}
|
|
|
|
/**
|
|
* Returns rulesets that contain a specific rule kind from given rulesets (workspace-agnostic version)
|
|
* @param rulesets Array of protection rulesets to filter
|
|
* @param ruleKind The rule type to filter by
|
|
* @returns Array of rulesets containing the specified rule
|
|
*/
|
|
export function getActiveRulesetsForKindInRulesets(
|
|
rulesets: ProtectionRuleset[],
|
|
ruleKind: ProtectionRuleKind
|
|
): ProtectionRuleset[] {
|
|
return rulesets.filter((rs) => rs.rules.includes(ruleKind))
|
|
}
|