Files
windmill/frontend/src/lib/components/ShareModal.svelte
T
GuilhemandClaude Opus 5 5d5ad4e897 feat: edit folders and groups in a drawer that saves once (#10873)
* 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>
2026-09-01 23:22:55 +02:00

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>