Files
windmill/frontend/src/lib/components/GroupEditor.svelte
T
6186a0645d feat: edit variables, resources and triggers in their own session tab (#11206)
* feat: edit variables, resources and triggers in their own session tab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: take a trigger's flow kind from its config and name the written trigger

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: one operating-workspace context for editors acting on a session's workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reload page item tabs only in sessions acting on the tool's workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve open in workspace for every preview tab kind in one place

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: guard every component under a session editor against the navigation workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show a page item tab whose item is gone instead of mounting its editor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: follow a saved page item in the tab's own workspace and restore in-frame rows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: close a page item tab from its editor header

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: judge trigger permissions by the user acting in the session workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: judge every session-editor permission by the user acting in that workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep session permissions reactive to the acting role and restore tabs as last seen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: default Path's owner controls to the user acting in its workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-19 23:29:14 +02:00

608 lines
20 KiB
Svelte

<script lang="ts">
import {
GranularAclService,
GroupService,
UserService,
type Group,
type InstanceGroup
} from '$lib/gen'
import { userStore } from '$lib/stores'
import { onMount, tick, untrack } from 'svelte'
import { Button } from './common'
import Skeleton from './common/skeleton/Skeleton.svelte'
import DataTable from './table/DataTable.svelte'
import Head from './table/Head.svelte'
import Row from './table/Row.svelte'
import Cell from './table/Cell.svelte'
import { sendUserToast } from '$lib/toast'
import { canWrite } from '$lib/utils'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import Label from './Label.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
import TextInput from './text_input/TextInput.svelte'
import { Plus, Trash } from 'lucide-svelte'
import PermissionHistory from './PermissionHistory.svelte'
import Alert from './common/alert/Alert.svelte'
import InputError from './InputError.svelte'
import Popover from './meltComponents/Popover.svelte'
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
import {
groupMemberDiff,
isGroupDraftDirty,
type GroupDraft,
type GroupRole
} from '$lib/groupDraft'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
const ROLE_TOOLTIPS = {
member:
'A Member of a group can see everything the group can see, write to everything the group can write, and generally act on behalf of the group',
manager:
'A manager of a group can manage the group, adding and removing users and change their roles. Being a manager does not make you a member',
admin:
'An admin of a group is a member of a group that can also add and remove members to the group, or make them admin.'
}
const MEMBERS_EXPLAINER =
'A member is a user with a role on this group. Members act on behalf of the group and see everything it can see; admins can additionally add and remove members.'
// Edits mutate `draft` only; `save()` is the sole writer to the backend, and `baseline` is
// what the group held when it was loaded, so comparing the two gives both the dirty state
// and the member calls to replay. Both live in `groupDraft.ts`, with tests.
interface Props {
/** In `new` mode this is the name being typed, hence bindable. */
name: string
mode?: 'edit' | 'new'
/** Drives the parent drawer's Save button, which lives above this component. */
onCanSaveChange?: (canSave: boolean) => void
/** Drives the parent drawer's discard confirmation on close. Unlike `canSave` this
* stays true for edits that cannot be saved yet (a name already taken) — closing
* would still throw them away. */
onUnsavedChange?: (unsaved: boolean) => void
/** Turns true once the group exists on the server, which a `new` drawer reaches
* mid-save. The drawer stops calling itself Create from that point. */
onExistsChange?: (exists: boolean) => void
}
let {
name = $bindable(),
mode = 'edit',
onCanSaveChange,
onUnsavedChange,
onExistsChange
}: Props = $props()
const restricted = $derived(
isDemoWorkspaceRestricted($operatingWorkspace, actingUser?.is_admin, actingUser?.is_super_admin)
)
let groupPerms = $state<Record<string, boolean> | undefined>(undefined)
// A verdict that does not come from a loaded group: a new one is writable, one that failed
// to load is not.
let writeVerdict = $state<boolean | undefined>(undefined)
// Derived for the same reason as the trigger editors: the acting user's role can land after
// the group does, and a snapshot taken first would stay read-only.
const can_write = $derived(
writeVerdict ?? (groupPerms !== undefined && canWrite(name, groupPerms, actingUser))
)
let group: Group | undefined
let instance_group: InstanceGroup | undefined = $state()
let usernames: string[] = $state([])
let groupNames: string[] = $state([])
let loaded = $state(false)
let reloadHistory = $state(0)
let nameInput: TextInput | undefined = $state(undefined)
let baseline: GroupDraft | undefined = $state(undefined)
// Empty, not `emptyDraft()`: that one seeds the caller as an admin, which is true of a
// group being created and a lie about one whose read failed. Every path that wants the
// seeded row calls `emptyDraft()` itself.
let draft: GroupDraft = $state({ summary: '', members: [] })
let memberToAdd: string = $state('')
let newMemberRole: GroupRole = $state('member')
// `create_group` puts the caller in the group and gives them the write entry, so a save
// on this branch has already happened once the request lands: a retry after a later
// member call fails must take the edit path or it recreates a group that now exists.
let alreadyCreated = $state(false)
const isNew = $derived(mode === 'new' && !alreadyCreated)
function emptyDraft(): GroupDraft {
return {
summary: '',
// The backend makes the creator an admin whatever we send, so the table shows that
// from the start rather than after the first reload.
members: $userStore ? [{ member_name: $userStore.username, role: 'admin' as GroupRole }] : []
}
}
function setDraft(value: GroupDraft) {
baseline = structuredClone(value)
draft = structuredClone(value)
}
/** Fills a picker or a validation list. The editor is usable before these land, so they
* run alongside the group read — but a rejection has to be reported: unhandled, it
* leaves the list silently empty and duplicate names stop being caught. */
function loadAside(load: () => Promise<void>): void {
load().catch((e) => sendUserToast(e?.body ?? String(e), true))
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
}
async function loadGroupNames(): Promise<void> {
groupNames = (await GroupService.listGroupNames({ workspace: $operatingWorkspace! })) ?? []
}
async function loadInstanceGroup(): Promise<void> {
try {
instance_group = await GroupService.getInstanceGroup({ name })
} catch (e) {
instance_group = undefined
}
}
async function load() {
loadAside(loadUsernames)
if (isNew) {
loadAside(loadGroupNames)
writeVerdict = true
setDraft(emptyDraft())
loaded = true
} else {
loadAside(loadInstanceGroup)
await loadGroup()
}
}
/** `baselineOnly` re-reads the group without touching the draft: after a save that
* committed some of its calls and then failed, the baseline must become what the server
* actually holds while the draft stays the user's intent — the applied changes then stop
* counting as dirty, and the ones still missing stay dirty and retryable. */
async function loadGroup(opts?: { baselineOnly?: boolean }): Promise<void> {
const apply = (value: GroupDraft) =>
opts?.baselineOnly ? (baseline = structuredClone(value)) : setDraft(value)
try {
group = await GroupService.getGroup({ workspace: $operatingWorkspace!, name })
groupPerms = group.extra_perms ?? {}
writeVerdict = undefined
apply({
summary: group.summary ?? '',
members: Array.from(
new Set(
Object.entries(group?.extra_perms ?? {})
.filter(([k, v]) => k.startsWith('u/') && v)
.map(([k, _]) => k.split('/')[1])
.concat(group?.members ?? [])
)
).map((x) => ({ member_name: x, role: getRole(x) }))
})
reloadHistory++
} catch (e) {
// The draft must survive a failed read: overwriting it here would discard the
// user's edits and clear `unsaved` with them.
sendUserToast(e?.body ?? String(e), true)
// Only the opening read decides this. Revoking it on a failed reconcile would
// disable Save against a draft that is still dirty, with nothing left to reload.
if (!opts?.baselineOnly) writeVerdict = false
} finally {
loaded = true
}
}
function getRole(x: string): GroupRole {
const manages = 'u/' + x in (group?.extra_perms ?? {}) && (group?.extra_perms ?? {})['u/' + x]
const belongs = group?.members?.includes(x)
if (manages && belongs) {
return 'admin'
} else if (manages) {
return 'manager'
} else {
return 'member'
}
}
// Guarded on `isNew`, not `mode`: once the group exists the name is frozen, so there is
// nothing left to validate.
const nameError = $derived(
!isNew
? ''
: !name
? ''
: groupNames.includes(name)
? 'A group with this name already exists'
: ''
)
const dirty = $derived(isGroupDraftDirty(draft, baseline))
// A typed name is progress too, even before any other field is touched.
const unsaved = $derived(dirty || (mode === 'new' && !!name))
$effect(() => {
onCanSaveChange?.(isNew ? loaded && !!name && !nameError && !restricted : can_write && dirty)
})
$effect(() => {
onUnsavedChange?.(unsaved)
})
$effect(() => {
onExistsChange?.(!isNew)
})
// `create_group` folds the caller into the group as an admin whatever the payload says, so
// on create their own row is fixed: offering to demote or remove it would be a change the
// backend silently discards.
function isFixedCreatorRow(member: string): boolean {
return isNew && member === $userStore?.username
}
function addMember(close: () => void) {
if (!draft.members.some((m) => m.member_name === memberToAdd)) {
draft.members.push({ member_name: memberToAdd, role: newMemberRole })
}
memberToAdd = ''
close()
}
/** Replays the member rows the user changed. `updateGroup` writes the summary only, so
* membership goes through the endpoints that name who was added or promoted — which is
* what the permission history reads back. The diff itself is in `groupDraft.ts`. */
async function applyMemberChanges(next: GroupDraft['members'], prev: GroupDraft['members']) {
const workspace = $operatingWorkspace ?? ''
for (const call of groupMemberDiff(prev, next, $userStore?.username)) {
switch (call.kind) {
case 'addUser':
await GroupService.addUserToGroup({
workspace,
name,
requestBody: { username: call.username }
})
break
case 'removeUser':
await GroupService.removeUserToGroup({
workspace,
name,
requestBody: { username: call.username }
})
break
case 'setAcl':
await GranularAclService.addGranularAcls({
workspace,
path: name,
kind: 'group_',
requestBody: { owner: 'u/' + call.username, write: true }
})
break
case 'removeAcl':
await GranularAclService.removeGranularAcls({
workspace,
path: name,
kind: 'group_',
requestBody: { owner: 'u/' + call.username }
})
break
}
}
}
export async function save(): Promise<{ name: string; created: boolean } | undefined> {
const next = $state.snapshot(draft) as GroupDraft
const prev = baseline as GroupDraft
const created = isNew
try {
if (created) {
await GroupService.createGroup({
workspace: $operatingWorkspace ?? '',
requestBody: { name, summary: next.summary }
})
alreadyCreated = true
// The members the caller added, on top of the admin row `create_group` wrote.
await applyMemberChanges(next.members, emptyDraft().members)
sendUserToast(`Group ${name} created`)
} else {
if (next.summary !== prev.summary) {
await GroupService.updateGroup({
workspace: $operatingWorkspace ?? '',
name,
requestBody: { summary: next.summary }
})
}
await applyMemberChanges(next.members, prev.members)
await loadGroup()
sendUserToast('Group updated')
}
return { name, created }
} catch (e) {
sendUserToast(e.body ?? String(e), true)
// A failed create is not proof the group is absent: `create_group` commits before a
// git-sync step that can still fail the request, including with a 4xx. Only the name
// conflict says it was never written. Report rather than resolve — a group found by
// name may be someone else's, and adopting it would send this draft's writes there.
const nameTaken = String(e?.body ?? '').includes('already exists')
if (created && !alreadyCreated && !nameTaken) {
sendUserToast(`Group ${name} may have been created anyway — reopen it to check`, true)
}
// Reconcile after any edit-path failure: the post-commit window means a rejection is
// not proof nothing was written. The baseline moves to server truth and the draft
// stays, so a retry re-sends only what is missing. `isNew` is read after the create,
// so a group that now exists reconciles too.
if (!isNew) await loadGroup({ baselineOnly: true })
return undefined
}
}
// The stores are read only to wait until they are populated, and the load runs once: this
// editor holds an unsaved draft, and the layout re-`set`s `$userStore` periodically — a
// second `load()` would overwrite the draft with the server's state and lose the edits
// silently, `unsaved` included. The drawer remounts this component per opening.
let loadStarted = false
$effect.pre(() => {
if (loadStarted) return
if ($operatingWorkspace && $userStore) {
loadStarted = true
untrack(() => {
load()
})
}
})
onMount(async () => {
if (mode !== 'new') return
// The editor is remounted per drawer opening, so mount is the moment the create form
// appears; the input only exists after the first render.
await tick()
nameInput?.focus()
})
</script>
<div class="flex flex-col gap-6">
{#if name === 'wm_deployers'}
<Alert type="info" title="Deployer permissions">
Members of this group can preserve the original author (on_behalf_of / permissioned_as) when
deploying scripts, flows, apps, triggers, and schedules to this workspace. Without this
permission, deployed items will be reassigned to the deploying user.
</Alert>
{/if}
{#if mode === 'new'}
<Label label="Group name">
<!-- Frozen once the group exists: `createGroup` lands before the member calls, so a
save can fail with the group already created under this name. Retyping it would
point the remaining member calls at a different group — one that may not exist,
or worse, one that does. -->
<TextInput
bind:this={nameInput}
bind:value={name}
error={!!nameError}
size="md"
inputProps={{ placeholder: 'group_name', disabled: !isNew }}
/>
<InputError error={nameError} />
</Label>
{/if}
<Label label="Summary" for="summary">
<TextInput
inputProps={{
placeholder: 'Short summary to be displayed when listed',
id: 'summary',
disabled: !can_write
}}
bind:value={draft.summary}
size="md"
/>
</Label>
<Label label={`Members (${draft.members.length})`} tooltip={MEMBERS_EXPLAINER}>
{#snippet action()}
{#if can_write && !restricted}
<Popover
placement="bottom-end"
onClose={() => {
memberToAdd = ''
newMemberRole = 'member'
}}
>
{#snippet trigger()}
<Button
variant="default"
unifiedSize="sm"
nonCaptureEvent={true}
startIcon={{ icon: Plus }}
>
Add member
</Button>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col w-72 p-4 gap-4">
<span class="text-sm leading-6 font-semibold">Add a member</span>
<Label label="User">
<Select
items={safeSelectItems(
usernames.filter((x) => !draft.members.some((m) => m.member_name === x))
)}
bind:value={memberToAdd}
size="sm"
class="grow min-w-0"
/>
</Label>
<Label label="Role">
<ToggleButtonGroup bind:selected={newMemberRole}>
{#snippet children({ item })}
<ToggleButton
value="member"
label="Member"
tooltip={ROLE_TOOLTIPS.member}
{item}
size="sm"
/>
<ToggleButton
value="admin"
label="Admin"
tooltip={ROLE_TOOLTIPS.admin}
{item}
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
</Label>
<Button
variant="accent"
unifiedSize="sm"
disabled={memberToAdd == ''}
onClick={() => addMember(close)}
>
Add
</Button>
</div>
{/snippet}
</Popover>
{/if}
{/snippet}
<div class="flex flex-col gap-2">
{#if can_write && restricted}
<Alert type="info" title="Sharing disabled">{DEMO_RESTRICTION_HINT}</Alert>
{/if}
{#if loaded}
<DataTable size="sm">
<Head>
<tr>
<Cell head first class="text-secondary">Name</Cell>
<Cell head class="text-secondary">Role</Cell>
<Cell head last actions class="text-secondary">Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each draft.members as member, idx (member.member_name)}
<Row>
<Cell first>
<span class="text-emphasis font-medium">{member.member_name}</span>
</Cell>
<Cell>
{#if can_write && !restricted}
<div>
<ToggleButtonGroup
disabled={isFixedCreatorRow(member.member_name)}
selected={member.role}
on:selected={(e) => {
draft.members[idx].role = e.detail
}}
>
{#snippet children({ item })}
<ToggleButton
value="member"
label="Member"
tooltip={ROLE_TOOLTIPS.member}
{item}
size="sm"
/>
<ToggleButton
value="admin"
label="Admin"
tooltip={ROLE_TOOLTIPS.admin}
{item}
size="sm"
/>
<!-- Manager is a state the UI can leave but not enter: it is a
write entry without membership, which only older grants hold. -->
{#if member.role === 'manager'}
<ToggleButton
value="manager"
label="Manager"
tooltip={ROLE_TOOLTIPS.manager}
{item}
size="sm"
/>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
{:else}
{member.role}
{/if}
</Cell>
<Cell last actions>
<div class="flex items-center justify-end">
{#if can_write && !isFixedCreatorRow(member.member_name)}
<Button
variant="subtle"
destructive
unifiedSize="sm"
startIcon={{ icon: Trash }}
iconOnly
onclick={() => {
draft.members = draft.members.filter(
(m) => m.member_name !== member.member_name
)
}}
/>
{:else if isFixedCreatorRow(member.member_name)}
<span class="text-2xs text-hint">admin as the creator</span>
{/if}
</div>
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{:else}
<div class="flex flex-col">
{#each new Array(6) as _}
<Skeleton layout={[[2], 0.7]} />
{/each}
</div>
{/if}
</div>
</Label>
{#if instance_group?.emails}
<Label label="Members from the instance group">
<DataTable size="sm">
<Head>
<tr>
<Cell head first last class="text-secondary">Email</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each instance_group?.emails ?? [] as email}
<Row>
<Cell first last>{email}</Cell>
</Row>
{/each}
</tbody>
</DataTable>
</Label>
{/if}
{#if reloadHistory > 0}
{#key reloadHistory}
<PermissionHistory
{name}
fetchHistory={async (workspace, groupName, page, perPage) => {
return await GroupService.getGroupPermissionHistory({
workspace,
name: groupName,
page,
perPage
})
}}
/>
{/key}
{/if}
</div>