Files
windmill/frontend/src/lib/components/settings/ScopeSelector.svelte
T
Guilhem a12c2788ec fix(frontend): add transparency to color palette (#6947)
* fix input padding

* fix surface hover on surface hover

* fix metadata gen

* fix border selected darkmode
2025-10-27 21:21:21 +00:00

738 lines
22 KiB
Svelte

<script lang="ts">
import { type ScopeDomain, type ScopeDefinition, TokenService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { ChevronRight, Loader2, X } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import Popover from '../meltComponents/Popover.svelte'
import Tooltip from '../Tooltip.svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
selectedScopes?: string[]
disabled?: boolean
class?: string
}
interface ScopeState {
isSelected: boolean
resourcePaths: string[]
currentInputValue: string
pathError?: string
}
interface DomainState {
isExpanded: boolean
hasFullAccess: boolean
scopes: Record<string, ScopeState>
}
interface ComponentState {
domains: Record<string, DomainState>
}
let { selectedScopes = $bindable([]), disabled = false, class: className = '' }: Props = $props()
let scopeDomains = $state<ScopeDomain[] | null>(null)
let loading = $state(false)
let error = $state<string | null>(null)
let componentState = $state<ComponentState>({ domains: {} })
function createScopeState(scope: ScopeDefinition): ScopeState {
return {
isSelected: false,
resourcePaths: [],
currentInputValue: '',
pathError: undefined
}
}
function createDomainState(domain: ScopeDomain): DomainState {
const scopes: Record<string, ScopeState> = {}
for (const scope of domain.scopes) {
scopes[scope.value] = createScopeState(scope)
}
return {
isExpanded: false,
hasFullAccess: false,
scopes
}
}
function getScopeState(scopeValue: string): ScopeState | undefined {
for (const domainState of Object.values(componentState.domains)) {
if (domainState.scopes[scopeValue]) {
return domainState.scopes[scopeValue]
}
}
return undefined
}
function isScopeDisabled(scope: ScopeDefinition, domain: ScopeDomain): boolean {
const domainState = getDomainState(domain.name)
if (!domainState) return false
if (domainState.hasFullAccess && scope.value.endsWith(':read')) {
return true
}
if (scope.value.endsWith(':read')) {
const writeScope = scope.value.replace(':read', ':write')
const writeScopeState = domainState.scopes[writeScope]
if (writeScopeState?.isSelected) {
return true
}
}
return false
}
function getDomainState(domainName: string): DomainState | undefined {
return componentState.domains[domainName]
}
async function fetchScopeDomains(): Promise<void> {
loading = true
error = null
try {
scopeDomains = await TokenService.listAvailableScopes()
initializeDomainStates()
} catch (err) {
console.error('Error fetching scope domains:', err)
sendUserToast('Failed to load scope options', true)
error = 'Failed to load scope options'
} finally {
loading = false
}
}
function initializeDomainStates() {
if (!scopeDomains) return
const newDomains: Record<string, DomainState> = {}
for (const domain of scopeDomains) {
const domainState = createDomainState(domain)
const writeScopeValue = getWriteScopeForDomain(domain)
const hasWriteSelected =
writeScopeValue &&
selectedScopes.some(
(scope) => scope === writeScopeValue || scope.startsWith(writeScopeValue + ':')
)
const runScopes = domain.scopes.filter((scope) => scope.value.includes(':run:'))
const hasRunScopesSelected =
runScopes.length === 0 ||
runScopes.every((runScope) =>
selectedScopes.some(
(scope) => scope === runScope.value || scope.startsWith(runScope.value + ':')
)
)
domainState.hasFullAccess = Boolean(hasWriteSelected && hasRunScopesSelected)
// Initialize individual scope states
for (const scope of domain.scopes) {
const scopeState = domainState.scopes[scope.value]
const isSelected = selectedScopes.some((selected) => {
if (scope.requires_resource_path && selected.startsWith(scope.value + ':')) {
const resourcePath = selected.substring(scope.value.length + 1)
const paths = resourcePath.split(',').map((p) => p.trim())
scopeState.resourcePaths = [...scopeState.resourcePaths, ...paths]
return true
}
return selected === scope.value
})
scopeState.isSelected = isSelected
}
newDomains[domain.name] = domainState
}
componentState = { domains: newDomains }
}
function getWriteScopeForDomain(domain: ScopeDomain): string | null {
const writeScope = domain.scopes.find((scope) => scope.value.endsWith(':write'))
return writeScope?.value || null
}
function toggleDomainExpansion(domainName: string) {
const domainState = getDomainState(domainName)
if (domainState) {
domainState.isExpanded = !domainState.isExpanded
}
}
function handleDomainCheckboxChange(domain: ScopeDomain, checked: boolean) {
const writeScopeValue = getWriteScopeForDomain(domain)
if (!writeScopeValue) return
const domainState = getDomainState(domain.name)
if (!domainState) return
domainState.hasFullAccess = checked
if (checked) {
selectedScopes = selectedScopes.filter(
(scope) =>
!domain.scopes.some(
(domainScope) =>
scope === domainScope.value || scope.startsWith(domainScope.value + ':')
)
)
const writeScope = domain.scopes.find((s) => s.value === writeScopeValue)
if (writeScope?.requires_resource_path) {
selectedScopes = [...selectedScopes, `${writeScopeValue}`]
} else {
selectedScopes = [...selectedScopes, writeScopeValue]
}
const runScopes = domain.scopes.filter((scope) => scope.value.includes(':run:'))
for (const runScope of runScopes) {
if (runScope.requires_resource_path) {
selectedScopes = [...selectedScopes, `${runScope.value}`]
} else {
selectedScopes = [...selectedScopes, runScope.value]
}
}
for (const scope of domain.scopes) {
const scopeState = domainState.scopes[scope.value]
if (scopeState) {
if (scope.value === writeScopeValue || runScopes.some((rs) => rs.value === scope.value)) {
scopeState.isSelected = true
}
}
}
} else {
// Remove all scopes for this domain
selectedScopes = selectedScopes.filter(
(scope) =>
!domain.scopes.some(
(domainScope) =>
scope === domainScope.value || scope.startsWith(domainScope.value + ':')
)
)
}
}
function handleIndividualScopeChange(scope: ScopeDefinition, checked: boolean) {
const scopeState = getScopeState(scope.value)
if (!scopeState) return
scopeState.isSelected = checked
if (scope.requires_resource_path) {
if (!checked) {
scopeState.resourcePaths = []
updateSelectedScopesForResourcePaths(scope.value, [])
} else {
const currentPaths = scopeState.resourcePaths || []
updateSelectedScopesForResourcePaths(scope.value, currentPaths, false)
}
} else {
selectedScopes = selectedScopes.filter(
(s) => !s.startsWith(scope.value + ':') && s !== scope.value
)
if (checked) {
selectedScopes = [...selectedScopes, scope.value]
}
}
updateDomainCheckboxState(scope)
}
function updateDomainCheckboxState(changedScope: ScopeDefinition) {
if (!scopeDomains) return
const domain = scopeDomains.find((d) => d.scopes.some((s) => s.value === changedScope.value))
if (!domain) return
const domainState = getDomainState(domain.name)
if (!domainState) return
const writeScope = getWriteScopeForDomain(domain)
const hasWriteSelected = writeScope && domainState.scopes[writeScope]?.isSelected
const runScopes = domain.scopes.filter((scope) => scope.value.includes(':run:'))
const hasRunScopesSelected =
runScopes.length === 0 ||
runScopes.every((runScope) => domainState.scopes[runScope.value]?.isSelected)
const isDomainFullySelected = hasWriteSelected && hasRunScopesSelected
domainState.hasFullAccess = Boolean(isDomainFullySelected)
}
function getSelectedScopesForDomain(domain: ScopeDomain): string[] {
const domainState = getDomainState(domain.name)
if (!domainState) return []
return domain.scopes
.filter((scope) => domainState.scopes[scope.value]?.isSelected)
.map((scope) => {
const scopeState = domainState.scopes[scope.value]
const resourcePaths = scopeState?.resourcePaths || []
return resourcePaths.length > 0 ? `${scope.value}:${resourcePaths.join(',')}` : scope.value
})
}
function removeSelectedScope(scopeToRemove: string) {
selectedScopes = selectedScopes.filter((scope) => scope !== scopeToRemove)
const baseScopeValue =
scopeToRemove.includes(':') && scopeToRemove.split(':').length > 2
? scopeToRemove.split(':').slice(0, 2).join(':')
: scopeToRemove
const scopeState = getScopeState(baseScopeValue)
if (scopeState) {
if (scopeToRemove.includes(':') && scopeToRemove.split(':').length > 2) {
const pathPart = scopeToRemove.substring(baseScopeValue.length + 1)
const pathsToRemove = pathPart.split(',').map((p) => p.trim())
scopeState.resourcePaths = scopeState.resourcePaths.filter(
(path) => !pathsToRemove.includes(path)
)
if (scopeState.resourcePaths.length === 0) {
scopeState.isSelected = false
}
} else {
scopeState.isSelected = false
scopeState.resourcePaths = []
}
updateDomainCheckboxState({ value: baseScopeValue } as ScopeDefinition)
}
}
function clearAllScopes() {
selectedScopes = []
for (const domainState of Object.values(componentState.domains)) {
domainState.hasFullAccess = false
domainState.isExpanded = false
for (const scopeState of Object.values(domainState.scopes)) {
scopeState.isSelected = false
scopeState.resourcePaths = []
scopeState.currentInputValue = ''
scopeState.pathError = undefined
}
}
}
const hasAdministratorScope = $derived(selectedScopes.includes('*'))
$effect(() => {
if (scopeDomains && componentState.domains) {
syncSelectedScopesWithState()
}
})
function validateResourcePath(path: string): string | null {
if (!path.trim()) return 'Path cannot be empty'
const trimmedPath = path.trim()
if (trimmedPath === '*') return null
if (trimmedPath === 'u/*' || trimmedPath === 'f/*') return null
if (!trimmedPath.startsWith('u/') && !trimmedPath.startsWith('f/')) {
return 'Path must start with u/ or f/'
}
const parts = trimmedPath.split('/')
if (parts.length < 3) {
return 'Expected path format: u/{user}/{resource} or f/{folder}/{resource}'
}
if (parts[1] === '') {
return 'Username/folder name cannot be empty'
}
const last = parts[parts.length - 1]
if (last.length === 0) {
return 'Resource name cannot be empty'
}
if (last === '*') return null
if (last.includes('*')) {
return 'Wildcards can only be used as the full resource name (*)'
}
return null
}
function addResourcePath(scopeValue: string, path: string) {
const scopeState = getScopeState(scopeValue)
if (!scopeState) return false
const error = validateResourcePath(path)
if (error) {
scopeState.pathError = error
return false
}
scopeState.pathError = undefined
if (scopeState.resourcePaths.includes(path.trim())) {
scopeState.pathError = 'Path already exists'
return false
}
const newPaths = [...scopeState.resourcePaths, path.trim()]
scopeState.resourcePaths = newPaths
scopeState.currentInputValue = ''
updateSelectedScopesForResourcePaths(scopeValue, newPaths)
return true
}
function removeResourcePath(scopeValue: string, pathToRemove: string) {
const scopeState = getScopeState(scopeValue)
if (!scopeState) return
const newPaths = scopeState.resourcePaths.filter((p) => p !== pathToRemove)
scopeState.resourcePaths = newPaths
scopeState.pathError = undefined
updateSelectedScopesForResourcePaths(scopeValue, newPaths, false)
}
function updateSelectedScopesForResourcePaths(
scopeValue: string,
paths: string[],
removeScope = true
) {
selectedScopes = selectedScopes.filter(
(s) => !s.startsWith(scopeValue + ':') && s !== scopeValue
)
const scopeState = getScopeState(scopeValue)
if (!scopeState) return
if (paths.length > 0 || !removeScope) {
selectedScopes = [
...selectedScopes,
paths.length > 0 ? `${scopeValue}:${paths.join(',')}` : scopeValue
]
scopeState.isSelected = true
} else {
scopeState.isSelected = false
}
updateDomainCheckboxState({ value: scopeValue } as ScopeDefinition)
}
function syncSelectedScopesWithState() {
if (!scopeDomains) return
for (const domain of scopeDomains) {
const domainState = getDomainState(domain.name)
if (!domainState) continue
const writeScopeValue = getWriteScopeForDomain(domain)
const hasWriteSelected =
writeScopeValue &&
selectedScopes.some(
(scope) => scope === writeScopeValue || scope.startsWith(writeScopeValue + ':')
)
const runScopes = domain.scopes.filter((scope) => scope.value.includes(':run:'))
const hasRunScopesSelected =
runScopes.length === 0 ||
runScopes.every((runScope) =>
selectedScopes.some(
(scope) => scope === runScope.value || scope.startsWith(runScope.value + ':')
)
)
domainState.hasFullAccess = Boolean(hasWriteSelected && hasRunScopesSelected)
for (const scope of domain.scopes) {
const scopeState = domainState.scopes[scope.value]
if (!scopeState) continue
scopeState.resourcePaths = []
const isSelected = selectedScopes.some((selected) => {
if (scope.requires_resource_path && selected.startsWith(scope.value + ':')) {
const resourcePath = selected.substring(scope.value.length + 1)
const paths = resourcePath.split(',').map((p) => p.trim())
scopeState.resourcePaths = [...paths]
return true
}
return selected === scope.value
})
scopeState.isSelected = isSelected
}
}
}
fetchScopeDomains()
</script>
<div class="w-full {className} p-2">
{#if loading}
<div class="flex items-center justify-center py-12">
<Loader2 size={32} class="animate-spin text-primary" />
</div>
{:else if error}
<div class="p-4 bg-surface-tertiary border border-red-200 rounded-lg">
<p class="text-xs text-red-600 mb-3">{error}</p>
<Button onclick={fetchScopeDomains} variant="accent" size="sm">Try again</Button>
</div>
{:else if scopeDomains}
<div class="mb-6 p-4 bg-surface-tertiary border rounded-md">
<div class="flex items-center justify-between mb-3">
<h4 class="text-xs font-semibold text-emphasis">
Selected Scopes ({selectedScopes.length})
</h4>
<Button onclick={clearAllScopes} {disabled} size="xs" variant="subtle">Clear All</Button>
</div>
{#if selectedScopes.length === 0}
<p class="text-xs text-secondary">No scopes selected. Token will have full access.</p>
{:else if hasAdministratorScope}
<p class="text-xs text-secondary"
>Administrator scope grants full access to all resources.</p
>
{:else}
<div class="flex flex-wrap gap-2">
{#each selectedScopes.slice(0, 10) as scope}
<span
class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded font-mono"
>
{scope}
<button
type="button"
onclick={() => removeSelectedScope(scope)}
class="text-blue-600 hover:text-blue-800 flex-shrink-0"
title="Remove scope"
{disabled}
>
<X size={10} />
</button>
</span>
{/each}
{#if selectedScopes.length > 10}
<span
class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium bg-surface text-secondary rounded"
>
+{selectedScopes.length - 10} more
</span>
{/if}
</div>
{/if}
</div>
<div class="max-h-96 overflow-y-auto border rounded-md">
{#each scopeDomains as domain}
{@const domainState = getDomainState(domain.name)}
{@const isExpanded = domainState?.isExpanded || false}
{@const isDomainSelected = domainState?.hasFullAccess || false}
{@const selectedScopes = getSelectedScopesForDomain(domain)}
<div class="overflow-hidden">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="p-4 cursor-pointer hover:bg-surface-hover transition-colors"
onclick={() => toggleDomainExpansion(domain.name)}
>
<div class="flex items-center gap-2">
<div class="flex-shrink-0">
<ChevronRight
size={16}
class="text-secondary transition-transform duration-200 {isExpanded
? 'rotate-90'
: ''}"
/>
</div>
<div class="flex-shrink-0">
<input
type="checkbox"
id={`domain-${domain.name}`}
checked={isDomainSelected}
{disabled}
onchange={(e) => handleDomainCheckboxChange(domain, e.currentTarget.checked)}
onclick={(e) => e.stopPropagation()}
class="!w-4 !h-4 cursor-pointer"
/>
</div>
<div class="flex-1 min-w-0 flex flex-col gap-1">
<div class="flex items-center gap-x-2 gap-y-1 flex-wrap">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<label
for={`domain-${domain.name}`}
class="text-xs font-medium text-emphasis cursor-pointer"
onclick={(e) => e.stopPropagation()}
>
{domain.name}
</label>
{#each selectedScopes as scope}
<span
class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded font-mono"
>
{scope}
<button
type="button"
onclick={(e) => {
e.stopPropagation()
removeSelectedScope(scope)
}}
class="text-blue-600 hover:text-blue-800 flex-shrink-0"
title="Remove scope"
{disabled}
>
<X size={10} />
</button>
</span>
{/each}
</div>
{#if domain.description}
<p class="text-2xs text-secondary">{domain.description}</p>
{/if}
</div>
</div>
</div>
{#if isExpanded}
<div class="p-2 pl-12">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each domain.scopes as scope}
{@const scopeState = domainState?.scopes[scope.value]}
{@const isSelected = scopeState?.isSelected || false}
{@const resourcePathArray = scopeState?.resourcePaths || []}
{@const currentInput = scopeState?.currentInputValue || ''}
{@const pathError = scopeState?.pathError}
{@const isDisabled = disabled || isScopeDisabled(scope, domain)}
<div
class="p-2 border rounded-lg w-full {isDisabled
? 'bg-surface-disabled opacity-60'
: 'bg-surface-tertiary'}"
>
<div class="flex justify-between items-center">
<label
class={twMerge(
'flex items-center gap-2 flex-1 min-w-0',
isDisabled ? 'cursor-not-allowed' : 'cursor-pointer'
)}
>
<input
type="checkbox"
checked={isSelected}
disabled={isDisabled}
onchange={(e) =>
handleIndividualScopeChange(scope, e.currentTarget.checked)}
class="!w-4 !h-4 flex-shrink-0"
/>
<span
class={twMerge(
'font-normal text-xs truncate cursor-pointer',
isDisabled ? 'text-disabled' : 'text-primary'
)}
>
{scope.label}
</span>
</label>
<div class="flex-shrink-0">
{#if scope.requires_resource_path}
<Popover
disabled={isDisabled}
closeOnOtherPopoverOpen
contentClasses="p-3"
>
{#snippet trigger()}
<Button size="xs" disabled={isDisabled} variant="default">
Restrict paths
<Tooltip light>
Restrict this scope to specific resource paths. If no paths are
specified, the scope gives full access.
</Tooltip>
</Button>
{/snippet}
{#snippet content()}
<div class="w-80">
<div class="flex gap-2">
<input
type="text"
value={currentInput}
{disabled}
oninput={(e) => {
if (scopeState) {
scopeState.currentInputValue = e.currentTarget.value
scopeState.pathError = undefined
}
}}
placeholder="e.g. f/folder/*, u/user/path"
onkeydown={(e) => {
if (e.key === 'Enter' && currentInput.trim()) {
e.preventDefault()
addResourcePath(scope.value, currentInput)
}
}}
/>
<Button
onclick={() => {
addResourcePath(scope.value, currentInput)
}}
size="xs"
disabled={!currentInput.trim()}
>
Add
</Button>
</div>
{#if pathError}
<p class="text-2xs text-red-600 mt-1">{pathError}</p>
{/if}
</div>
{/snippet}
</Popover>
{/if}
</div>
</div>
{#if scope.requires_resource_path && resourcePathArray.length > 0}
<div class="flex flex-wrap gap-1 mt-2">
{#each resourcePathArray as path}
<span
class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded font-mono"
>
{path}
<button
type="button"
onclick={() => removeResourcePath(scope.value, path)}
class="text-blue-600 hover:text-blue-800 flex-shrink-0"
title="Remove path"
>
<X size={10} />
</button>
</span>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>