mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 00:06:14 +00:00
* fix: portal the confirmation modal so drawers cannot cover it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: log a folder acl grant under the permission it granted Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep a table's actions column at its right edge Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * feat: edit a folder in a drawer that saves once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: call the people on a folder or item members Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: edit a folder against the workspace the drawer targets Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: drop the now-unused sticky actions column Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * docs: correct the script editor drawer's modal placement note Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: pin the actions column without losing the row's hover tint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * feat: show the pinned column's seam only while the table overflows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: draw the pinned column's seam as a shadow so it does not scroll away Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: fade the pinned column's tint in step with its row Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: address review nits on the folder editor and pinned cell Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep the folder draft across a user-store refresh Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: extract and test the folder draft's dirty check and permission diff Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: stop the folder editor showing state the server refused Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep a folder draft that no request ever reached the server Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep unapplied folder edits dirty when a save partially fails * fix: block folder form edits while a save is in flight * fix: commit a typed folder label before save snapshots the draft * fix: count a typed folder label as an unsaved change * fix: keep escape in the label input from closing what encloses it * fix: capitalize folder table headers and drop a dead portal target * refactor: make the confirmation modal portal opt-in per call site * docs: name the stacking context that actually traps the discard dialog * fix: report a half-landed member removal so the baseline reconciles * feat: edit a group in a drawer that saves once * fix: freeze the group name once the group exists * fix: revoke the caller's own group acl last so the rest of the save is authorized * docs: state the group call-ordering invariant once * fix: report a failing post-save reload instead of dropping the rejection * fix: hand the folder list reload back so a failure is reported * fix: treat a rejected group create as inconclusive and catch a throwing onSaved * revert: stop inferring a group was created from its name being taken * fix: say when a failed group create may have saved the group anyway * fix: key the may-have-been-created hint on the name conflict, not the status * fix: skip the may-have-been-created hint when the group is known to exist * feat: open a folder's group member from its row * fix: stop showing the caller as an admin when the read failed * fix: give up the caller's own folder admin last, and label a create as one * fix: drop a folder member's acl before its owner entry * fix: remove a folder owner before their acl, and correct the rls rationale * docs: say the refusal is on the caller's last admin handle * fix: defer only the folder rows the caller is an admin through * docs: describe callerOwners as what the caller passes in * docs: drop the call-site restatement of the diff's own invariant * docs: record manager as a legacy group role * fix: treat a sent request as possibly committed when reconciling * fix: reconcile on any failed edit, and compare members as a set * fix: keep write access when only the reconcile read fails --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
365 lines
10 KiB
Svelte
365 lines
10 KiB
Svelte
<script lang="ts">
|
|
import TableCustom from './TableCustom.svelte'
|
|
|
|
import {
|
|
GroupService,
|
|
UserService,
|
|
GranularAclService,
|
|
ResourceService,
|
|
FolderService
|
|
} from '$lib/gen'
|
|
import type { Folder } from '$lib/gen/types.gen'
|
|
import { createEventDispatcher } from 'svelte'
|
|
import { userStore, workspaceStore } from '$lib/stores'
|
|
import { Alert, Button, Drawer } from './common'
|
|
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { isOwner } from '$lib/utils'
|
|
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
|
import Select from './select/Select.svelte'
|
|
import { safeSelectItems } from './select/utils.svelte'
|
|
import Toggle from './Toggle.svelte'
|
|
import { Trash } from 'lucide-svelte'
|
|
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
let restricted = $derived(
|
|
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
|
|
)
|
|
|
|
type Kind =
|
|
| 'script'
|
|
| 'group_'
|
|
| 'resource'
|
|
| 'schedule'
|
|
| 'variable'
|
|
| 'flow'
|
|
| 'app'
|
|
| 'raw_app'
|
|
| 'http_trigger'
|
|
| 'websocket_trigger'
|
|
| 'kafka_trigger'
|
|
| 'nats_trigger'
|
|
| 'mqtt_trigger'
|
|
| 'sqs_trigger'
|
|
| 'postgres_trigger'
|
|
| 'gcp_trigger'
|
|
| 'azure_trigger'
|
|
| 'amqp_trigger'
|
|
| 'email_trigger'
|
|
| 'volume'
|
|
let kind: Kind
|
|
|
|
let path: string = $state('')
|
|
|
|
let ownerKind: 'user' | 'group' = $state('user')
|
|
let owner: string = $state('')
|
|
|
|
let newOwner: string = $derived.by(
|
|
() => owner && [ownerKind === 'group' ? 'g' : 'u', owner].join('/')
|
|
)
|
|
let write: boolean = false
|
|
let acls: [string, boolean][] = $state([])
|
|
let groups: String[] = $state([])
|
|
let usernames: string[] = $state([])
|
|
|
|
let drawer: Drawer | undefined = $state()
|
|
|
|
let linkedVarPaths: string[] = $state([])
|
|
let alsoApplyToLinked: boolean = $state(true)
|
|
|
|
let defaultPerms: { label: string; write: boolean }[] = $state([])
|
|
let defaultPermsLabel: string = $state('')
|
|
|
|
async function loadDefaultPerms() {
|
|
const currentPath = path
|
|
const parts = currentPath.split('/')
|
|
if (parts[0] === 'f' && parts.length >= 2) {
|
|
const folderName = parts[1]
|
|
defaultPermsLabel = `Folder f/${folderName} permissions`
|
|
try {
|
|
const folder: Folder = await FolderService.getFolder({
|
|
workspace: $workspaceStore!,
|
|
name: folderName
|
|
})
|
|
if (path !== currentPath) return
|
|
const perms: { label: string; write: boolean }[] = []
|
|
for (const owner of folder.owners) {
|
|
perms.push({ label: owner, write: true })
|
|
}
|
|
for (const [owner, write] of Object.entries(folder.extra_perms ?? {})) {
|
|
if (!folder.owners.includes(owner)) {
|
|
perms.push({ label: owner, write })
|
|
}
|
|
}
|
|
defaultPerms = perms
|
|
} catch {
|
|
if (path !== currentPath) return
|
|
defaultPerms = []
|
|
}
|
|
} else if (parts[0] === 'u' && parts.length >= 2) {
|
|
defaultPermsLabel = `User u/${parts[1]} permissions`
|
|
defaultPerms = [{ label: `u/${parts[1]}`, write: true }]
|
|
} else if (parts[0] === 'g' && parts.length >= 2) {
|
|
defaultPermsLabel = `Group g/${parts[1]} permissions`
|
|
defaultPerms = [{ label: `g/${parts[1]}`, write: true }]
|
|
} else {
|
|
defaultPerms = []
|
|
defaultPermsLabel = ''
|
|
}
|
|
}
|
|
|
|
function collectVarRefs(value: unknown): string[] {
|
|
const paths: string[] = []
|
|
function walk(v: unknown) {
|
|
if (typeof v === 'string' && v.startsWith('$var:')) {
|
|
paths.push(v.substring('$var:'.length))
|
|
} else if (Array.isArray(v)) {
|
|
v.forEach(walk)
|
|
} else if (v && typeof v === 'object') {
|
|
Object.values(v).forEach(walk)
|
|
}
|
|
}
|
|
walk(value)
|
|
return [...new Set(paths)]
|
|
}
|
|
|
|
async function loadLinkedVarPaths() {
|
|
if (kind !== 'resource') {
|
|
linkedVarPaths = []
|
|
return
|
|
}
|
|
const currentPath = path
|
|
try {
|
|
const resource = await ResourceService.getResource({
|
|
workspace: $workspaceStore!,
|
|
path: currentPath
|
|
})
|
|
if (path !== currentPath) return
|
|
linkedVarPaths = collectVarRefs(resource.value)
|
|
} catch {
|
|
if (path !== currentPath) return
|
|
linkedVarPaths = []
|
|
}
|
|
}
|
|
|
|
let own = $state(false)
|
|
export async function openDrawer(newPath: string, kind_l: Kind, isOwnerOverride?: boolean) {
|
|
path = newPath
|
|
kind = kind_l
|
|
alsoApplyToLinked = true
|
|
loadAcls()
|
|
loadGroups()
|
|
loadUsernames()
|
|
loadLinkedVarPaths()
|
|
loadDefaultPerms()
|
|
if (isOwnerOverride !== undefined) {
|
|
own = isOwnerOverride
|
|
} else {
|
|
loadOwner()
|
|
}
|
|
drawer?.openDrawer()
|
|
}
|
|
|
|
async function loadOwner() {
|
|
own = isOwner(path, $userStore!, $workspaceStore!)
|
|
}
|
|
|
|
async function loadAcls() {
|
|
acls = Object.entries(
|
|
await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind })
|
|
)
|
|
}
|
|
|
|
async function loadGroups(): Promise<void> {
|
|
groups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
|
|
}
|
|
|
|
async function loadUsernames(): Promise<void> {
|
|
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
|
|
}
|
|
|
|
async function deleteAcl(owner: string) {
|
|
try {
|
|
await GranularAclService.removeGranularAcls({
|
|
workspace: $workspaceStore!,
|
|
path,
|
|
kind,
|
|
requestBody: { owner }
|
|
})
|
|
if (alsoApplyToLinked) {
|
|
for (const varPath of linkedVarPaths) {
|
|
try {
|
|
await GranularAclService.removeGranularAcls({
|
|
workspace: $workspaceStore!,
|
|
path: varPath,
|
|
kind: 'variable',
|
|
requestBody: { owner }
|
|
})
|
|
} catch (err) {
|
|
sendUserToast(`Failed to update variable ${varPath}: ${err}`, true)
|
|
}
|
|
}
|
|
}
|
|
loadAcls()
|
|
dispatch('change', { path, kind })
|
|
} catch (err) {
|
|
sendUserToast(err.toString(), true)
|
|
}
|
|
}
|
|
|
|
async function addAcl(owner: string, write: boolean) {
|
|
await GranularAclService.addGranularAcls({
|
|
workspace: $workspaceStore!,
|
|
path,
|
|
kind,
|
|
requestBody: { owner, write }
|
|
})
|
|
if (alsoApplyToLinked) {
|
|
for (const varPath of linkedVarPaths) {
|
|
try {
|
|
await GranularAclService.addGranularAcls({
|
|
workspace: $workspaceStore!,
|
|
path: varPath,
|
|
kind: 'variable',
|
|
requestBody: { owner, write }
|
|
})
|
|
} catch (err) {
|
|
sendUserToast(`Failed to update variable ${varPath}: ${err}`, true)
|
|
}
|
|
}
|
|
}
|
|
loadAcls()
|
|
dispatch('change', { path, kind })
|
|
}
|
|
</script>
|
|
|
|
<Drawer bind:this={drawer}>
|
|
<DrawerContent title="Permissions for {path}" on:close={drawer?.closeDrawer}>
|
|
<div class="flex flex-col gap-4">
|
|
{#if defaultPerms.length > 0}
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-sm font-semibold text-emphasis">{defaultPermsLabel}</span>
|
|
{#each defaultPerms as perm (perm.label)}
|
|
<div class="flex items-center justify-between text-xs text-primary">
|
|
<span>{perm.label}</span>
|
|
<span class="text-tertiary">{perm.write ? 'Writer' : 'Viewer'}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
<div class="flex flex-col gap-2">
|
|
<span class="text-sm font-semibold text-emphasis"
|
|
>Extra members ({acls?.length ?? 0})</span
|
|
>
|
|
{#if linkedVarPaths.length > 0}
|
|
<div class="flex flex-col gap-1.5 p-3 border rounded bg-surface-secondary text-xs">
|
|
<Toggle
|
|
size="xs"
|
|
bind:checked={alsoApplyToLinked}
|
|
options={{ right: 'Also apply to linked variables' }}
|
|
/>
|
|
<ul class="text-2xs text-secondary list-disc ml-4">
|
|
{#each linkedVarPaths as varPath (varPath)}
|
|
<li>{varPath}</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
{#if !own}
|
|
<Alert type="warning" title="Not owner"
|
|
>Since you do not own this item, you cannot modify its permission</Alert
|
|
>
|
|
{/if}
|
|
<div>
|
|
{#if own && restricted}
|
|
<Alert type="info" title="Sharing disabled">{DEMO_RESTRICTION_HINT}</Alert>
|
|
{:else if own}
|
|
<div class="flex flex-row flex-wrap gap-2 items-center">
|
|
<div>
|
|
<ToggleButtonGroup bind:selected={ownerKind} on:selected={() => (owner = '')}>
|
|
{#snippet children({ item })}
|
|
<ToggleButton value="user" label="User" {item} />
|
|
<ToggleButton value="group" label="Group" {item} />
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
{#key ownerKind}
|
|
<Select
|
|
items={safeSelectItems(
|
|
(ownerKind === 'user' ? usernames : groups).map((x) => x.toString())
|
|
)}
|
|
bind:value={owner}
|
|
class="grow min-w-48"
|
|
/>
|
|
{/key}
|
|
<Button
|
|
size="lg"
|
|
variant="accent"
|
|
disabled={!newOwner}
|
|
on:click={() => addAcl(newOwner, write)}>Add member</Button
|
|
>
|
|
</div>
|
|
{/if}
|
|
{#if acls?.length > 0}
|
|
<TableCustom>
|
|
{#snippet headerRow()}
|
|
<tr>
|
|
<th>member</th>
|
|
<th></th>
|
|
<th></th>
|
|
</tr>
|
|
{/snippet}
|
|
{#snippet body()}
|
|
<tbody>
|
|
{#each acls as [owner, write]}
|
|
<tr>
|
|
<td>{owner}</td>
|
|
<td
|
|
>{#if own && !restricted}
|
|
<div>
|
|
<ToggleButtonGroup
|
|
selected={write ? 'writer' : 'viewer'}
|
|
on:selected={async (e) => {
|
|
const role = e.detail
|
|
if (role == 'writer') {
|
|
await addAcl(owner, true)
|
|
} else {
|
|
await addAcl(owner, false)
|
|
}
|
|
loadAcls()
|
|
}}
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton value="viewer" small label="Viewer" {item} />
|
|
<ToggleButton value="writer" small label="Writer" {item} />
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
{:else}{write ? 'Writer' : 'Viewer'}{/if}</td
|
|
>
|
|
<td>
|
|
{#if own}
|
|
<Button
|
|
variant="default"
|
|
destructive
|
|
size="xs"
|
|
on:click={() => deleteAcl(owner)}
|
|
startIcon={{ icon: Trash }}
|
|
/>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
{/snippet}
|
|
</TableCustom>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div></DrawerContent
|
|
>
|
|
</Drawer>
|