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>
This commit is contained in:
Guilhem
2026-09-01 23:22:55 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 816dc9dcd2
commit 5d5ad4e897
22 changed files with 2253 additions and 990 deletions
+19
View File
@@ -36,3 +36,22 @@ _Avoid_: argument field, param
**Expression input**:
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
_Avoid_: JS field, code input
### Permissions
**Member**:
A user or group granted a role on a folder, a group, or an item's extra ACL. The list of them is
"Members (n)" everywhere it is shown, and one is added with "Add member".
_Avoid_: participant, collaborator, owner, ACL entry, permission (that names the concept, not the people)
**Role**:
The access level a member holds: viewer, writer or admin on a folder; member or admin on a group.
Viewers read, writers also edit, admins also manage the members. A group role of **manager**
manages the group without belonging to it — is a legacy state the UI shows and can leave, but
offers no way to enter.
_Avoid_: permission level, access level, rank
**Owner**:
Reserved for the path prefix that says where an item lives — `u/alice` or `f/team`. A folder's
`owners` column in the database is its admin members; call those admins, never owners, in the UI.
_Avoid_: using "owner" for a folder admin
@@ -183,9 +183,9 @@ async fn add_granular_acl(
if kind == "folder" {
let change_type = if write.unwrap_or(false) {
"grant_read"
} else {
"grant_write"
} else {
"grant_read"
};
crate::folders::log_folder_permission_change(
&mut *tx,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
<script lang="ts">
import { Button, Drawer, DrawerContent } from './common'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import FolderEditor from './FolderEditor.svelte'
import { Save } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
let {
offset = 0,
disableChatOffset = false,
onSaved = undefined,
workspace = undefined
}: {
offset?: number
disableChatOffset?: boolean
onSaved?: (name: string, created: boolean) => void | Promise<void>
/** Edit a folder of this workspace rather than the active one. */
workspace?: string
} = $props()
let drawer: Drawer | undefined = $state()
let mode: 'edit' | 'new' = $state('edit')
let name: string = $state('')
let canSave = $state(false)
let unsaved = $state(false)
// An `edit` drawer whose folder turns out not to exist saves by creating it. Calling that
// Save would promise an update the click does not perform.
let exists = $state(false)
let saving = $state(false)
let confirmDiscardOpen = $state(false)
let discarding = $state(false)
let editor: { save: () => Promise<{ name: string; created: boolean } | undefined> } | undefined =
$state()
// Bumped per open so the editor reloads its draft from the folder it is now
// pointed at. Keying on `name` instead would remount on every keystroke of the
// name field in `new` mode.
let instance = $state(0)
function open(nextMode: 'edit' | 'new', folderName: string): void {
mode = nextMode
name = folderName
discarding = false
confirmDiscardOpen = false
exists = nextMode === 'edit'
// The remounted editor reports these on its first effect, which is a tick away. Until
// then the header would carry the last folder's answers.
canSave = false
unsaved = false
instance++
drawer?.openDrawer()
}
export function initEdit(folderName: string): void {
open('edit', folderName)
}
export function initNew(initialName: string = ''): void {
open('new', initialName)
}
/** The editor keeps its draft in memory only, so closing throws it away. */
function requestClose() {
// A save is already writing. `unsaved` only clears once it reloads, so closing here
// would offer to discard changes the in-flight requests are busy persisting — and
// confirming would close on that lie. Saving is the shorter wait; ignore the close.
if (saving) return
if (discarding || !unsaved) {
drawer?.closeDrawer()
return
}
confirmDiscardOpen = true
}
async function save() {
saving = true
try {
// `created` comes from the editor, not from `mode`: a folder whose row turned out
// not to exist is created from an `initEdit` drawer.
const saved = await editor?.save()
if (saved) {
// Callers reload a list here. Called from inside the chain, not before it, so a
// synchronous throw is caught too — thrown out of `save()` it would skip the
// close below and strand the drawer open on a folder that did save.
void Promise.resolve()
.then(() => onSaved?.(saved.name, saved.created))
.catch((e) => sendUserToast(e?.body ?? String(e), true))
// The editor reloads its baseline after saving, but that lands a tick
// later; close on our own authority rather than racing it.
discarding = true
// Belt and braces with the `saving` guard on the close paths: nothing that
// asked to discard may outlive a save that then succeeded.
confirmDiscardOpen = false
drawer?.closeDrawer()
}
} finally {
saving = false
}
}
</script>
<Drawer
bind:this={drawer}
{offset}
{disableChatOffset}
on:close={() => {
// Escape and click-away close the drawer before asking. Reopening in the same
// tick is how the flow's script editor drawer handles this too: the close
// transition has not started, so nothing flickers.
if (saving) {
drawer?.openDrawer()
return
}
if (!discarding && unsaved) {
drawer?.openDrawer()
confirmDiscardOpen = true
}
}}
>
<DrawerContent title={exists ? `Folder ${name}` : 'Create folder'} on:close={requestClose}>
<!-- `save()` snapshots the draft and then awaits several requests. An edit landing in
that window would not be in the snapshot, and the drawer closes on success — so
it would be lost without ever being offered as unsaved. `inert` keeps the form
from taking one. -->
<div inert={saving} class={saving ? 'opacity-60 transition-opacity' : 'transition-opacity'}>
{#key instance}
<FolderEditor
bind:this={editor}
bind:name
{mode}
{workspace}
onCanSaveChange={(v) => (canSave = v)}
onUnsavedChange={(v) => (unsaved = v)}
onExistsChange={(v) => (exists = v)}
/>
{/key}
</div>
{#snippet actions()}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Save }}
disabled={!canSave}
loading={saving}
on:click={save}
>
{exists ? 'Save' : 'Create'}
</Button>
{/snippet}
</DrawerContent>
</Drawer>
<!-- `alwaysPortal`: this drawer is opened from inside other drawers (the folder picker of a
resource or variable form), and that outer drawer is a stacking context this dialog
cannot climb out of on z-index alone. Left in place it paints under the drawer whose
unsaved changes it is asking about, which leaves that drawer impossible to close. -->
<ConfirmationModal
alwaysPortal
open={confirmDiscardOpen}
title="Unsaved changes detected"
confirmationText="Discard changes"
onCanceled={() => (confirmDiscardOpen = false)}
onConfirmed={() => {
confirmDiscardOpen = false
discarding = true
drawer?.closeDrawer()
}}
>
<span> Are you sure you want to discard the changes you have made to this folder? </span>
</ConfirmationModal>
+18 -103
View File
@@ -3,28 +3,16 @@
import { workspaceStore, userStore } from '$lib/stores'
import { isDemoWorkspaceRestricted } from '$lib/cloud'
import { ChevronDown, Pen, PlusIcon } from 'lucide-svelte'
import { Button, Drawer, DrawerContent } from './common'
import FolderEditor from './FolderEditor.svelte'
import { Button } from './common'
import FolderEditorDrawer from './FolderEditorDrawer.svelte'
import Select from './select/Select.svelte'
import TextInput from './text_input/TextInput.svelte'
import Label from './Label.svelte'
import InputError from './InputError.svelte'
import { tick } from 'svelte'
import { sendUserToast } from '$lib/toast'
const VALID_FOLDER_NAME = /^[a-zA-Z_0-9-]+$/
let folders: { name: string; write: boolean }[] = $state([])
let filterText: string = $state('')
let selectOpen: boolean = $state(false)
let nameInput: TextInput | undefined = $state()
let newFolder: Drawer | null = $state(null)
let viewFolder: Drawer | null = $state(null)
let newFolderName: string = $state('')
let folderCreated: string | undefined = $state(undefined)
let creating: boolean = $state(false)
let folderEditorDrawer: FolderEditorDrawer | undefined = $state()
let loadingFolders: boolean = $state(true)
let editingFolder: string = $state('')
type Props = {
folderName: string
@@ -99,45 +87,24 @@
}
}
async function openCreateFolder() {
newFolderName = filterText
folderCreated = undefined
newFolder?.openDrawer()
await tick()
nameInput?.focus()
}
async function addFolder() {
if (nameError || !newFolderName || creating) return
creating = true
try {
await FolderService.createFolder({
workspace: targetWorkspace,
requestBody: { name: newFolderName }
})
folderCreated = newFolderName
async function onFolderSaved(saved: string, created: boolean) {
if (created) {
// The creator owns what they just created. Recorded on whichever membership
// this picker is reading, and *before* reloading, so the new folder comes
// back selectable rather than `(read-only)` — `loadFolders` derives `write`
// from exactly this.
if (aimedElsewhere) {
if (targetUser) targetUser.folders = [...(targetUser.folders ?? []), newFolderName]
if (targetUser) targetUser.folders = [...(targetUser.folders ?? []), saved]
} else if ($userStore) {
// Writing $userStore.folders = [...] would call userStore.set(),
// which re-triggers Path.svelte's $effect.pre and calls initPath()/reset(),
// switching the owner toggle from "Folder" back to "User".
if (!$userStore.folders) $userStore.folders = []
$userStore.folders.push(newFolderName)
$userStore.folders.push(saved)
}
await loadFolders()
folderName = newFolderName
} catch (e) {
sendUserToast(`Could not create folder: ${e}`, true)
} finally {
creating = false
}
await loadFolders()
if (created) folderName = saved
}
let selectItems = $derived(
@@ -148,16 +115,6 @@
}))
)
let nameError = $derived(
!newFolderName
? ''
: !VALID_FOLDER_NAME.test(newFolderName)
? 'Folder name can only contain alphanumeric characters, underscores, and hyphens'
: folders.some((f) => f.name === newFolderName)
? 'A folder with this name already exists'
: ''
)
let noMatchingItems = $derived(
filterText &&
!selectItems.some((item) => item.label.toLowerCase().includes(filterText.toLowerCase()))
@@ -167,7 +124,7 @@
if (e.key === 'Enter' && selectOpen && noMatchingItems && !restricted) {
e.preventDefault()
selectOpen = false
openCreateFolder()
folderEditorDrawer?.initNew(filterText)
}
}
@@ -185,53 +142,12 @@
loadTargetUser().then(loadFolders)
</script>
<Drawer bind:this={newFolder} name="newFolder" offset={drawerOffset}>
<DrawerContent
title={folderCreated ? `Folder ${folderCreated}` : 'Create folder'}
on:close={() => {
newFolder?.closeDrawer()
folderCreated = undefined
}}
>
{#if folderCreated}
<FolderEditor name={folderCreated} />
{:else}
<div class="flex flex-col gap-4">
<Label label="Folder name">
<TextInput
bind:this={nameInput}
bind:value={newFolderName}
error={!!nameError}
inputProps={{
placeholder: 'folder_name',
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter' && newFolderName) {
e.preventDefault()
addFolder()
}
}
}}
/>
<InputError error={nameError} />
</Label>
<Button
variant="accent"
disabled={!newFolderName || !!nameError || creating}
loading={creating}
onClick={addFolder}
>
Create
</Button>
</div>
{/if}
</DrawerContent>
</Drawer>
<Drawer bind:this={viewFolder} offset={drawerOffset}>
<DrawerContent title="Folder {editingFolder}" on:close={viewFolder.closeDrawer}>
<FolderEditor name={editingFolder} />
</DrawerContent>
</Drawer>
<FolderEditorDrawer
bind:this={folderEditorDrawer}
offset={drawerOffset}
workspace={targetWorkspace}
onSaved={onFolderSaved}
/>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -262,8 +178,7 @@
wrapperClasses="-mr-2 pl-1 -my-2"
btnClasses="hover:bg-surface-tertiary"
onClick={() => {
editingFolder = item.value ?? ''
viewFolder?.openDrawer()
folderEditorDrawer?.initEdit(item.value ?? '')
close()
}}
startIcon={{ icon: Pen }}
@@ -278,7 +193,7 @@
: ''}"
onclick={() => {
close()
openCreateFolder()
folderEditorDrawer?.initNew(filterText)
}}
>
<PlusIcon class="inline" size={16} />
+470 -223
View File
@@ -7,10 +7,13 @@
type InstanceGroup
} from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher, untrack } from 'svelte'
import { onMount, tick, untrack } from 'svelte'
import { Button } from './common'
import Skeleton from './common/skeleton/Skeleton.svelte'
import TableCustom from './TableCustom.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'
@@ -19,42 +22,112 @@
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
import TextInput from './text_input/TextInput.svelte'
import { Trash } from 'lucide-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'
interface Props {
name: string
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.'
}
let { name }: Props = $props()
let can_write = $state(false)
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.'
type Role = 'member' | 'manager' | 'admin'
// 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($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let can_write = $state(false)
let group: Group | undefined
let instance_group: InstanceGroup | undefined = $state()
let members: { member_name: string; role: Role }[] | undefined = $state(undefined)
let usernames: string[] | undefined = $state([])
let username: string = $state('')
let summary = $state('')
let usernames: string[] = $state([])
let groupNames: string[] = $state([])
let loaded = $state(false)
let reloadHistory = $state(0)
let nameInput: TextInput | undefined = $state(undefined)
const dispatch = createEventDispatcher()
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: $workspaceStore! })
}
async function load() {
return Promise.all([loadGroup(), loadInstanceGroup(), loadUsernames()])
}
async function addToGroup() {
await GroupService.addUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: { username }
})
loadGroup()
async function loadGroupNames(): Promise<void> {
groupNames = (await GroupService.listGroupNames({ workspace: $workspaceStore! })) ?? []
}
async function loadInstanceGroup(): Promise<void> {
@@ -65,55 +138,218 @@
}
}
async function loadGroup(): Promise<void> {
try {
group = await GroupService.getGroup({ workspace: $workspaceStore!, name })
can_write = canWrite(name!, group.extra_perms ?? {}, $userStore)
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) => {
return {
member_name: x,
role: getRole(x)
}
})
summary = group.summary ?? ''
reloadHistory++
} catch (e) {
can_write = false
members = []
summary = ''
group = {
name
}
async function load() {
loadAside(loadUsernames)
if (isNew) {
loadAside(loadGroupNames)
can_write = true
setDraft(emptyDraft())
loaded = true
} else {
loadAside(loadInstanceGroup)
await loadGroup()
}
}
function getRole(x: string): Role {
const writer = 'u/' + x in (group?.extra_perms ?? {}) && (group?.extra_perms ?? {})['u/' + x]
const member = group?.members?.includes(x)
/** `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: $workspaceStore!, name })
can_write = canWrite(name, group.extra_perms ?? {}, $userStore)
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) can_write = false
} finally {
loaded = true
}
}
if (writer && member) {
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 (writer) {
} 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 = $workspaceStore ?? ''
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: $workspaceStore ?? '',
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: $workspaceStore ?? '',
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 ($workspaceStore && $userStore) {
loadStarted = true
untrack(() => {
load()
})
}
})
let reloadHistory = $state(0)
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">
@@ -124,207 +360,218 @@
permission, deployed items will be reassigned to the deploying user.
</Alert>
{/if}
<Label label="Summary" for="summary">
<div class="flex flex-row gap-2">
{#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
inputProps={{ placeholder: 'Short summary to be displayed when listed', id: 'summary' }}
bind:value={summary}
bind:this={nameInput}
bind:value={name}
error={!!nameError}
size="md"
inputProps={{ placeholder: 'group_name', disabled: !isNew }}
/>
<Button
unifiedSize="md"
variant="accent"
on:click={async () => {
await GroupService.updateGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: { summary }
})
dispatch('update')
sendUserToast('Group summary updated')
loadGroup()
}}>Save</Button
>
</div>
<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 (${members?.length ?? 0})`}>
{#if can_write}
<div class="flex items-start gap-1">
<Select items={safeSelectItems(usernames)} bind:value={username} size="md" class="grow" />
<Button variant="accent" color="blue" unifiedSize="md" on:click={addToGroup}>
Add member
</Button>
</div>
{/if}
{#if members}
<TableCustom>
{#snippet headerRow()}
<tr>
<th>user</th>
<th></th>
<th></th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each members ?? [] as { member_name, role }}<tr>
<td>{member_name}</td>
<td>
{#if can_write}
<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
selected={role}
on:selected={async (e) => {
const role = e.detail
// const wasInGroup = (group?.members ?? []).includes(group)
// const inAcl = (
// group?.extra_perms ? Object.keys(group?.extra_perms) : []
// ).includes(group)
if (role == 'member') {
await GroupService.addUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: {
username: member_name
}
})
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore ?? '',
path: name,
kind: 'group_',
requestBody: {
owner: 'u/' + member_name
}
})
} else if (role == 'manager') {
await GroupService.removeUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: {
username: member_name
}
})
await GranularAclService.addGranularAcls({
workspace: $workspaceStore ?? '',
path: name,
kind: 'group_',
requestBody: {
owner: 'u/' + member_name,
write: true
}
})
} else if (role == 'admin') {
await GroupService.addUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: {
username: member_name
}
})
await GranularAclService.addGranularAcls({
workspace: $workspaceStore ?? '',
path: name,
kind: 'group_',
requestBody: {
owner: 'u/' + member_name,
write: true
}
})
}
loadGroup()
disabled={isFixedCreatorRow(member.member_name)}
selected={member.role}
on:selected={(e) => {
draft.members[idx].role = e.detail
}}
>
{#snippet children({ item })}
<ToggleButton
value="member"
small
label="Member"
tooltip="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"
tooltip={ROLE_TOOLTIPS.member}
{item}
size="sm"
/>
<ToggleButton
value="admin"
small
label="Admin"
tooltip="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."
tooltip={ROLE_TOOLTIPS.admin}
{item}
size="sm"
/>
{#if role === 'manager'}
<!-- 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"
small
label="Manager"
tooltip="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"
tooltip={ROLE_TOOLTIPS.manager}
{item}
size="sm"
/>
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
{:else}
{role}
{/if}</td
>
<td class="flex justify-end">
{#if can_write}
<Button
variant="subtle"
destructive
unifiedSize="md"
startIcon={{ icon: Trash }}
iconOnly
onclick={async () => {
await GroupService.removeUserToGroup({
workspace: $workspaceStore ?? '',
name,
requestBody: { username: member_name }
})
await GranularAclService.removeGranularAcls({
workspace: $workspaceStore ?? '',
path: name,
kind: 'group_',
requestBody: {
owner: 'u/' + member_name
}
})
loadGroup()
}}
/>
{/if}</td
>
</tr>{/each}
{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>
{/snippet}
</TableCustom>
{#if instance_group?.emails}
<h2 class="mt-6 text-emphasis text-xs font-semibold">Members from the instance group</h2>
<TableCustom>
{#snippet headerRow()}
<tr>
<th>user</th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each instance_group?.emails ?? [] as email}<tr>
<td>{email}</td>
</tr>{/each}
</tbody>
{/snippet}
</TableCustom>
</DataTable>
{:else}
<div class="flex flex-col">
{#each new Array(6) as _}
<Skeleton layout={[[2], 0.7]} />
{/each}
</div>
{/if}
{: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
@@ -0,0 +1,162 @@
<script lang="ts">
import { Button, Drawer, DrawerContent } from './common'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import GroupEditor from './GroupEditor.svelte'
import { Save } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
let {
offset = 0,
disableChatOffset = false,
onSaved = undefined
}: {
offset?: number
disableChatOffset?: boolean
onSaved?: (name: string, created: boolean) => void | Promise<void>
} = $props()
let drawer: Drawer | undefined = $state()
let mode: 'edit' | 'new' = $state('edit')
let name: string = $state('')
let canSave = $state(false)
let unsaved = $state(false)
// A `new` drawer whose group has been created but whose member calls then failed stays
// open on the edit path. Calling it Create there would offer to create what exists.
let exists = $state(false)
let saving = $state(false)
let confirmDiscardOpen = $state(false)
let discarding = $state(false)
let editor: { save: () => Promise<{ name: string; created: boolean } | undefined> } | undefined =
$state()
// Bumped per open so the editor reloads its draft from the group it is now pointed at.
// Keying on `name` instead would remount on every keystroke of the name field in `new` mode.
let instance = $state(0)
function open(nextMode: 'edit' | 'new', groupName: string): void {
mode = nextMode
name = groupName
discarding = false
confirmDiscardOpen = false
exists = nextMode === 'edit'
// The remounted editor reports these on its first effect, which is a tick away. Until
// then the header would carry the last group's answers.
canSave = false
unsaved = false
instance++
drawer?.openDrawer()
}
export function initEdit(groupName: string): void {
open('edit', groupName)
}
export function initNew(initialName: string = ''): void {
open('new', initialName)
}
/** The editor keeps its draft in memory only, so closing throws it away. */
function requestClose() {
// A save is already writing. `unsaved` only clears once it reloads, so closing here
// would offer to discard changes the in-flight requests are busy persisting — and
// confirming would close on that lie. Saving is the shorter wait; ignore the close.
if (saving) return
if (discarding || !unsaved) {
drawer?.closeDrawer()
return
}
confirmDiscardOpen = true
}
async function save() {
saving = true
try {
const saved = await editor?.save()
if (saved) {
// Callers reload a list here. Called from inside the chain, not before it, so a
// synchronous throw is caught too — thrown out of `save()` it would skip the
// close below and strand the drawer open on a group that did save.
void Promise.resolve()
.then(() => onSaved?.(saved.name, saved.created))
.catch((e) => sendUserToast(e?.body ?? String(e), true))
// The editor reloads its baseline after saving, but that lands a tick later;
// close on our own authority rather than racing it.
discarding = true
// Belt and braces with the `saving` guard on the close paths: nothing that
// asked to discard may outlive a save that then succeeded.
confirmDiscardOpen = false
drawer?.closeDrawer()
}
} finally {
saving = false
}
}
</script>
<Drawer
bind:this={drawer}
{offset}
{disableChatOffset}
on:close={() => {
// Escape and click-away close the drawer before asking. Reopening in the same tick is
// how the flow's script editor drawer handles this too: the close transition has not
// started, so nothing flickers.
if (saving) {
drawer?.openDrawer()
return
}
if (!discarding && unsaved) {
drawer?.openDrawer()
confirmDiscardOpen = true
}
}}
>
<DrawerContent title={exists ? `Group ${name}` : 'Create group'} on:close={requestClose}>
<!-- `save()` snapshots the draft and then awaits several requests. An edit landing in
that window would not be in the snapshot, and the drawer closes on success — so it
would be lost without ever being offered as unsaved. `inert` keeps the form from
taking one. -->
<div inert={saving} class={saving ? 'opacity-60 transition-opacity' : 'transition-opacity'}>
{#key instance}
<GroupEditor
bind:this={editor}
bind:name
{mode}
onCanSaveChange={(v) => (canSave = v)}
onUnsavedChange={(v) => (unsaved = v)}
onExistsChange={(v) => (exists = v)}
/>
{/key}
</div>
{#snippet actions()}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Save }}
disabled={!canSave}
loading={saving}
on:click={save}
>
{exists ? 'Save' : 'Create'}
</Button>
{/snippet}
</DrawerContent>
</Drawer>
<!-- `alwaysPortal`: this drawer is opened from inside another drawer (the folder editor, itself
reachable from a resource or variable form), and that outer drawer is a stacking context
this dialog cannot climb out of on z-index alone. Left in place it paints under the drawer
whose unsaved changes it is asking about, which leaves that drawer impossible to close. -->
<ConfirmationModal
alwaysPortal
open={confirmDiscardOpen}
title="Unsaved changes detected"
confirmationText="Discard changes"
onCanceled={() => (confirmDiscardOpen = false)}
onConfirmed={() => {
confirmDiscardOpen = false
discarding = true
drawer?.closeDrawer()
}}
>
<span> Are you sure you want to discard the changes you have made to this group? </span>
</ConfirmationModal>
+34 -2
View File
@@ -8,9 +8,22 @@
labels: string[] | undefined
onchange?: () => void
class?: string
/** Suggest the labels of this workspace rather than the active one, for an editor
* aimed elsewhere (the folder drawer opened from a cross-workspace picker). */
workspace?: string
/** Text typed into the input but not yet added to `labels`. An editor with a Save
* button needs it: without it that text is invisible to the editor's dirty state,
* so it is silently dropped on close and cannot even enable Save on its own. */
onPendingChange?: (pending: string) => void
}
let { labels = $bindable(), onchange, class: clazz = '' }: Props = $props()
let {
labels = $bindable(),
onchange,
class: clazz = '',
workspace,
onPendingChange
}: Props = $props()
let adding = $state(false)
let inputValue = $state('')
@@ -34,9 +47,13 @@
!(labels ?? []).includes(trimmedInput)
)
$effect(() => {
onPendingChange?.(adding ? trimmedInput : '')
})
async function loadExistingLabels() {
try {
const resp = await fetch(`/api/w/${$workspaceStore}/labels/list`)
const resp = await fetch(`/api/w/${workspace ?? $workspaceStore}/labels/list`)
if (resp.ok) existingLabels = await resp.json()
} catch {}
}
@@ -82,8 +99,15 @@
addLabel() // either "Create new" selected or free text
}
} else if (e.key === 'Escape') {
// Escape cancels the label, and nothing else. Left to bubble it also reaches
// whatever encloses us — a drawer or dialog closes on it, and one guarding on
// unsaved changes reads `pending` before this clears it, so it prompts to
// discard work this key just discarded.
e.preventDefault()
e.stopPropagation()
inputValue = ''
adding = false
onPendingChange?.('')
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const maxIdx = suggestions.length + (showCreateNew ? 1 : 0) - 1
@@ -100,6 +124,14 @@
if (adding) addLabel()
}, 150)
}
/** Add whatever is typed but not yet committed, right now. Blur commits on a 150ms
* grace period, so a caller that reads `labels` in the same tick as the blur — a Save
* button, which blurs this input by being clicked — would miss the last label.
* `adding` is cleared here, so the pending timer then finds nothing to do. */
export function flushPendingLabel(): void {
if (adding) addLabel()
}
</script>
<div class="inline-flex items-center gap-1 ml-0.5 h-5 {clazz}">
@@ -252,7 +252,7 @@
{/if}
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-emphasis"
>Extra permissions ({acls?.length ?? 0})</span
>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">
@@ -299,7 +299,7 @@
size="lg"
variant="accent"
disabled={!newOwner}
on:click={() => addAcl(newOwner, write)}>Add permission</Button
on:click={() => addAcl(newOwner, write)}>Add member</Button
>
</div>
{/if}
@@ -307,7 +307,7 @@
<TableCustom>
{#snippet headerRow()}
<tr>
<th>owner</th>
<th>member</th>
<th></th>
<th></th>
</tr>
@@ -23,6 +23,12 @@
/** Tailwind z-index class for the modal root. Override to stack this modal
* above another modal that's already open (both default to `z-[9999]`). */
zIndexClass?: string
/** Render into `body` instead of where this component sits. Needed when an ancestor
* creates a stacking context the dialog has to escape — a drawer paints over the page
* whatever the dialog's z-index, and a `transform`, `filter` or `overflow` on the way
* up confines it. Off by default: it moves the dialog out of its DOM position, so opt
* in per call site rather than assuming every caller wants it. */
alwaysPortal?: boolean
children?: Snippet
onConfirmed?: () => void | Promise<void>
onCanceled?: () => void
@@ -40,6 +46,7 @@
id,
trashbin = false,
zIndexClass = 'z-[9999]',
alwaysPortal = false,
children,
onConfirmed,
onCanceled
@@ -141,7 +148,11 @@
<svelte:window onkeydowncapture={onKeyDown} />
<ConditionalPortal condition={!!hostEl} target={hostEl} class="contents">
<ConditionalPortal
condition={alwaysPortal || !!hostEl}
target={hostEl}
class={hostEl ? 'contents' : undefined}
>
{#if open}
<div
transition:fadeFast|local
@@ -102,7 +102,7 @@
<SettingsPageHeader
title="Members {members != undefined ? `(${members.length})` : ''}"
description="Add collaborators to the fork you created."
description="Add members to the fork you created."
link="https://www.windmill.dev/docs/core_concepts/roles_and_permissions"
/>
@@ -124,12 +124,12 @@
nonCaptureEvent={true}
startIcon={{ icon: UserPlus }}
>
Add collaborator
Add member
</Button>
{/snippet}
{#snippet content()}
<div class="flex flex-col w-[28rem] p-4 gap-2">
<span class="text-sm leading-6 font-semibold">Add a collaborator</span>
<span class="text-sm leading-6 font-semibold">Add a member</span>
<span class="text-xs text-secondary">
They join as a developer of this fork. Only members of
<b>{parentWorkspaceId}</b> who are developers or admins there can be added.
+38 -7
View File
@@ -11,7 +11,9 @@
shouldStopPropagation?: boolean
selected?: boolean
sticky?: boolean
stickyEnd?: boolean
/** The column holding a row's action buttons. It hugs its content at the table's
* right edge instead of absorbing the width the other columns leave over. */
actions?: boolean
wrap?: boolean
children?: import('svelte').Snippet
[key: string]: any
@@ -25,7 +27,7 @@
shouldStopPropagation = false,
selected = false,
sticky = false,
stickyEnd = false,
actions = false,
wrap = false,
children,
...rest
@@ -55,11 +57,11 @@
last && size === 'xs' ? 'sm:pr-3' : '',
numeric ? 'text-right' : '',
// Pin an actions column to the right so it stays visible when a wide table
// scrolls horizontally. The background must be opaque so cells sliding under it
// are occluded — the row's hover tint is translucent and would bleed through.
stickyEnd ? 'sticky right-0 border-l' : '',
stickyEnd ? (head ? 'bg-surface-secondary' : 'bg-surface') : '',
// `w-0` shrinks the column to its buttons instead of taking the leftover width, and
// the pin keeps them reachable while a wide table scrolls. The background must stay
// opaque for the cells passing under it to be occluded — see `wm-cell-pinned` below.
actions ? 'w-0 text-right [&>*]:ml-auto sticky right-0 wm-cell-pinned' : '',
actions ? (head ? 'bg-surface-secondary' : 'bg-surface') : '',
sticky ? `!p-0 sticky ${first ? 'left-0' : 'right-0'}` : 'px-2 py-2',
size === 'sm' ? 'px-1.5 py-2.5' : '',
size === 'lg' ? 'px-3 py-4' : '',
@@ -77,3 +79,32 @@
{@render children?.()}
{/if}
</svelte:element>
<style>
/* A sticky cell paints over its row's hover tint rather than inheriting it, and the tint
token carries alpha — adopting it would make the cell translucent and stop it
occluding. So it is layered over the opaque colour. A pseudo-element, not a
`background-image`: that is not animatable, and the row fades its tint on this curve. */
.wm-cell-pinned::after {
content: '';
position: absolute;
inset: 0;
/* Above the cell's own background, below its buttons. */
z-index: -1;
pointer-events: none;
background-color: rgb(var(--color-surface-hover));
opacity: 0;
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
:global(tr.wm-row-hoverable:hover) > .wm-cell-pinned::after {
opacity: 1;
}
/* Drawn only while the table overflows (DataTable measures it), since the seam marks that
content is passing under. A shadow, not a border: under `border-collapse: collapse` a
cell's borders belong to the table and scroll away with it. */
:global(.wm-table-x-overflow) .wm-cell-pinned {
box-shadow: -1px 0 0 0 rgb(var(--color-border-light));
}
</style>
@@ -15,6 +15,24 @@
let tableHeight: number = $state(0)
const dispatch = createEventDispatcher()
let tableContainer: HTMLDivElement | undefined = $state()
let tableEl: HTMLTableElement | undefined = $state()
// A pinned actions column only earns a seam once something can actually pass under it,
// so the overflow is measured rather than assumed: on a table that fits, the column
// should be indistinguishable from an ordinary one.
let xOverflowing = $state(false)
$effect(() => {
const container = tableContainer
const table = tableEl
if (!container || !table) return
// Sub-pixel widths make an exactly-fitting table read as 0.5px over.
const measure = () => (xOverflowing = container.scrollWidth > container.clientWidth + 1)
measure()
const observer = new ResizeObserver(measure)
observer.observe(container)
observer.observe(table)
return () => observer.disconnect()
})
interface Props {
paginated?: boolean
currentPage?: number
@@ -130,11 +148,15 @@
>
<List justify="between" gap="none" hFull={true}>
<div
class={twMerge('w-full overflow-auto h-fit', preventXOverflow ? 'overflow-x-hidden' : '')}
class={twMerge(
'w-full overflow-auto h-fit',
preventXOverflow ? 'overflow-x-hidden' : '',
xOverflowing ? 'wm-table-x-overflow' : ''
)}
bind:this={tableContainer}
onscroll={handleScroll}
>
<table class={tableFixed ? 'table-fixed w-full' : 'min-w-full'}>
<table bind:this={tableEl} class={tableFixed ? 'table-fixed w-full' : 'min-w-full'}>
{@render children?.()}
</table>
{@render emptyMessage?.()}
+3 -1
View File
@@ -28,7 +28,9 @@
<tr
class={twMerge(
hoverable ? 'hover:bg-surface-hover cursor-pointer' : '',
// `wm-row-hoverable` lets a pinned cell re-create this tint on top of its own opaque
// background — it cannot simply adopt it, since the hover token carries alpha.
hoverable ? 'wm-row-hoverable hover:bg-surface-hover cursor-pointer' : '',
selected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'transition-all',
dividable ? 'divide-x' : '',
+148
View File
@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest'
import {
folderPermissionDiff,
isFolderDraftDirty,
type FolderDraft,
type FolderMember,
type FolderRole
} from './folderDraft'
function member(role: FolderRole): FolderMember {
return { owner_name: 'u/alice', role }
}
function baseline(): FolderDraft {
return {
summary: 'Reporting jobs',
labels: ['prod'],
defaultPermissionedAs: [{ path_glob: '**', permissioned_as: 'u/admin' }],
perms: [
{ owner_name: 'u/admin', role: 'admin' },
{ owner_name: 'g/all', role: 'viewer' }
]
}
}
describe('folderPermissionDiff', () => {
// The whole transition matrix: which endpoint each role change maps to. `admin` lives in
// `owners` and the other two in `extra_perms`, so leaving admin is the one transition that
// cannot go through the ACL endpoint.
const transitions: Array<[from: FolderRole | 'absent', to: FolderRole, expected: unknown]> = [
['absent', 'viewer', { kind: 'setAcl', owner: 'u/alice', write: false }],
['absent', 'writer', { kind: 'setAcl', owner: 'u/alice', write: true }],
['absent', 'admin', { kind: 'grantAdmin', owner: 'u/alice' }],
['viewer', 'writer', { kind: 'setAcl', owner: 'u/alice', write: true }],
['viewer', 'admin', { kind: 'grantAdmin', owner: 'u/alice' }],
['writer', 'viewer', { kind: 'setAcl', owner: 'u/alice', write: false }],
['writer', 'admin', { kind: 'grantAdmin', owner: 'u/alice' }],
['admin', 'viewer', { kind: 'demoteAdmin', owner: 'u/alice', write: false }],
['admin', 'writer', { kind: 'demoteAdmin', owner: 'u/alice', write: true }]
]
it.each(transitions)('%s → %s', (from, to, expected) => {
const prev = from === 'absent' ? [] : [member(from)]
expect(folderPermissionDiff(prev, [member(to)])).toEqual([expected])
})
it.each(['viewer', 'writer', 'admin'] as const)('%s → removed drops owner and acl', (role) => {
expect(folderPermissionDiff([member(role)], [])).toEqual([{ kind: 'remove', owner: 'u/alice' }])
})
it.each(['viewer', 'writer', 'admin'] as const)('%s unchanged calls nothing', (role) => {
expect(folderPermissionDiff([member(role)], [member(role)])).toEqual([])
})
// The caller is a folder admin only through `g/ops`, so that demotion is the one the write
// policy refuses. Sent first it takes the rest of the save down with it.
it('gives up the caller own admin last', () => {
const prev: FolderMember[] = [
{ owner_name: 'g/ops', role: 'admin' },
{ owner_name: 'u/bob', role: 'viewer' }
]
const next: FolderMember[] = [
{ owner_name: 'g/ops', role: 'viewer' },
{ owner_name: 'u/bob', role: 'admin' }
]
expect(folderPermissionDiff(prev, next, ['u/alice', 'g/ops'])).toEqual([
{ kind: 'grantAdmin', owner: 'u/bob' },
{ kind: 'demoteAdmin', owner: 'g/ops', write: false }
])
})
// `g/z` is a group the caller belongs to but holds no admin through, so removing it is an
// ordinary call — queued behind the refused one it would never run.
it('defers only the rows the caller is an admin through', () => {
const prev: FolderMember[] = [
{ owner_name: 'g/a', role: 'admin' },
{ owner_name: 'g/z', role: 'viewer' }
]
expect(folderPermissionDiff(prev, [], ['u/alice', 'g/a', 'g/z'])).toEqual([
{ kind: 'remove', owner: 'g/z' },
{ kind: 'remove', owner: 'g/a' }
])
})
it('touches only the members that changed', () => {
const prev: FolderMember[] = [
{ owner_name: 'u/admin', role: 'admin' },
{ owner_name: 'g/all', role: 'viewer' },
{ owner_name: 'g/ops', role: 'writer' }
]
const next: FolderMember[] = [
{ owner_name: 'u/admin', role: 'admin' },
{ owner_name: 'g/all', role: 'writer' }
]
expect(folderPermissionDiff(prev, next)).toEqual([
{ kind: 'setAcl', owner: 'g/all', write: true },
{ kind: 'remove', owner: 'g/ops' }
])
})
})
describe('isFolderDraftDirty', () => {
it('is clean against its own baseline', () => {
expect(isFolderDraftDirty(baseline(), baseline())).toBe(false)
})
it('is clean before anything has loaded', () => {
expect(isFolderDraftDirty(baseline(), undefined)).toBe(false)
})
// A reload rebuilds the members in the server's order, which is not the order they were
// added in. Order-sensitive, an applied change would keep Save lit with nothing to send.
it('ignores the order the members are held in', () => {
const reordered = baseline()
reordered.perms = [...reordered.perms].reverse()
expect(isFolderDraftDirty(reordered, baseline())).toBe(false)
})
// Enumerated from the value itself rather than a hand-written list: a field added to
// `FolderDraft` and to `baseline()` is covered here without anyone remembering to add a
// case. An edit this misses is one the drawer discards without asking.
it.each(Object.keys(baseline()) as Array<keyof FolderDraft>)('notices a change to %s', (key) => {
const edited = baseline()
if (key === 'summary') edited.summary = 'Something else'
else if (key === 'labels') edited.labels = [...edited.labels, 'staging']
else if (key === 'defaultPermissionedAs') edited.defaultPermissionedAs = []
else if (key === 'perms') edited.perms[1].role = 'writer'
else throw new Error(`no edit defined for ${key} — add one so the field stays covered`)
expect(isFolderDraftDirty(edited, baseline())).toBe(true)
})
it('notices a member added and a member removed', () => {
const added = baseline()
added.perms.push({ owner_name: 'g/ops', role: 'writer' })
expect(isFolderDraftDirty(added, baseline())).toBe(true)
const removed = baseline()
removed.perms.pop()
expect(isFolderDraftDirty(removed, baseline())).toBe(true)
})
it('is clean again once the baseline catches up', () => {
const saved = baseline()
saved.summary = 'Renamed'
expect(isFolderDraftDirty(saved, structuredClone(saved))).toBe(false)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { deepEqual } from 'fast-equals'
import type { FolderDefaultPermissionedAs } from '$lib/gen'
/** What a member may hold on a folder. `admin` is the `owners` array server-side; `writer`
* and `viewer` are the `true`/`false` entries of `extra_perms`. */
export type FolderRole = 'viewer' | 'writer' | 'admin'
export type FolderMember = { owner_name: string; role: FolderRole }
/** Everything the folder editor can change, held as one value so the whole edit is one
* comparison against the loaded folder and one Save. */
export type FolderDraft = {
summary: string
labels: string[]
defaultPermissionedAs: FolderDefaultPermissionedAs
perms: FolderMember[]
}
/** Whether the draft still matches the folder it was loaded from. Every field of
* `FolderDraft` participates, so a field added to the type is covered by construction —
* which is what the discard guard depends on: an edit this misses is an edit the drawer
* throws away without asking. No baseline means nothing has loaded yet, so nothing to lose. */
export function isFolderDraftDirty(draft: FolderDraft, baseline: FolderDraft | undefined): boolean {
return baseline != undefined && !deepEqual(sortedMembers(draft), sortedMembers(baseline))
}
/** Members are a set, but a reload rebuilds them in the server's `extra_perms` key order while
* the draft keeps the order they were added in. Compared as-is, a change that has already been
* applied still reads as dirty. Labels and rules keep their order, which is meaningful. */
function sortedMembers(value: FolderDraft): FolderDraft {
return {
...value,
perms: [...value.perms].sort((a, b) => a.owner_name.localeCompare(b.owner_name))
}
}
/** One backend call the folder's members need. Kept as data so the mapping from role
* transitions to endpoints can be read — and tested — without a server. */
export type FolderPermissionCall =
/** `addowner`: appends to `owners` and sets `extra_perms[owner] = true`. */
| { kind: 'grantAdmin'; owner: string }
/** `removeowner` with a write flag: takes the member out of `owners` and sets their
* level. The only way down from admin. */
| { kind: 'demoteAdmin'; owner: string; write: boolean }
/** `acls/add`: sets `extra_perms[owner]`, for a member who is not an admin. */
| { kind: 'setAcl'; owner: string; write: boolean }
/** Both removals. `removeowner` without a write only drops the member from `owners`,
* leaving their `extra_perms` entry — alone it demotes an admin rather than removing
* them, so the ACL delete is not optional. */
| { kind: 'remove'; owner: string }
/** The calls that turn `prev` into `next`. Members whose role is unchanged produce none.
*
* `callerOwners` is the caller's own `u/name` plus every group they belong to. Giving up the
* last of those that is in `owners` goes last: the write policy checks the row the update
* would produce, so that call is refused for anyone but a workspace admin, and sent early it
* takes the rest of the save with it. */
export function folderPermissionDiff(
prev: FolderMember[],
next: FolderMember[],
callerOwners?: string[]
): FolderPermissionCall[] {
const previousRole = new Map(prev.map((p) => [p.owner_name, p.role]))
const calls: FolderPermissionCall[] = []
for (const member of next) {
const before = previousRole.get(member.owner_name)
if (before === member.role) continue
if (member.role === 'admin') {
calls.push({ kind: 'grantAdmin', owner: member.owner_name })
} else if (before === 'admin') {
calls.push({
kind: 'demoteAdmin',
owner: member.owner_name,
write: member.role === 'writer'
})
} else {
calls.push({ kind: 'setAcl', owner: member.owner_name, write: member.role === 'writer' })
}
}
const kept = new Set(next.map((n) => n.owner_name))
for (const member of prev) {
if (kept.has(member.owner_name)) continue
calls.push({ kind: 'remove', owner: member.owner_name })
}
// `previousRole === 'admin'` is what makes it a handle: `callerOwners` lists every group
// the caller belongs to, and one holding only a viewer or writer row is not in `owners`,
// so removing it is an ordinary call that should not queue behind the fatal one.
const revokesCaller = (call: FolderPermissionCall) =>
(call.kind === 'demoteAdmin' || call.kind === 'remove') &&
(callerOwners?.includes(call.owner) ?? false) &&
previousRole.get(call.owner) === 'admin'
return [...calls.filter((c) => !revokesCaller(c)), ...calls.filter(revokesCaller)]
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest'
import { groupMemberDiff, isGroupDraftDirty, type GroupDraft, type GroupRole } from './groupDraft'
function baseline(): GroupDraft {
return {
summary: 'On-call engineers',
members: [
{ member_name: 'admin', role: 'admin' },
{ member_name: 'alice', role: 'member' }
]
}
}
describe('groupMemberDiff', () => {
// The whole transition matrix: which endpoints each role change maps to. A role is a
// membership row plus an ACL entry, so only the halves that actually change are sent —
// an extra call would log a permission-history row for something that did not move.
const transitions: Array<
[from: GroupRole | 'absent', to: GroupRole | 'absent', expected: unknown[]]
> = [
['absent', 'member', [{ kind: 'addUser', username: 'bob' }]],
['absent', 'manager', [{ kind: 'setAcl', username: 'bob' }]],
[
'absent',
'admin',
[
{ kind: 'addUser', username: 'bob' },
{ kind: 'setAcl', username: 'bob' }
]
],
['member', 'admin', [{ kind: 'setAcl', username: 'bob' }]],
[
'member',
'manager',
[
{ kind: 'removeUser', username: 'bob' },
{ kind: 'setAcl', username: 'bob' }
]
],
['manager', 'admin', [{ kind: 'addUser', username: 'bob' }]],
[
'manager',
'member',
[
{ kind: 'addUser', username: 'bob' },
{ kind: 'removeAcl', username: 'bob' }
]
],
['admin', 'member', [{ kind: 'removeAcl', username: 'bob' }]],
['admin', 'manager', [{ kind: 'removeUser', username: 'bob' }]],
['member', 'absent', [{ kind: 'removeUser', username: 'bob' }]],
['manager', 'absent', [{ kind: 'removeAcl', username: 'bob' }]],
[
'admin',
'absent',
[
{ kind: 'removeUser', username: 'bob' },
{ kind: 'removeAcl', username: 'bob' }
]
]
]
for (const [from, to, expected] of transitions) {
it(`${from} to ${to}`, () => {
const prev = from === 'absent' ? [] : [{ member_name: 'bob', role: from }]
const next = to === 'absent' ? [] : [{ member_name: 'bob', role: to }]
expect(groupMemberDiff(prev, next)).toEqual(expected)
})
}
it('sends nothing for an unchanged member', () => {
expect(groupMemberDiff(baseline().members, baseline().members)).toEqual([])
})
it('revokes the caller last so the rest of the save stays authorized', () => {
const prev = [{ member_name: 'admin', role: 'admin' as GroupRole }]
const next = [
{ member_name: 'admin', role: 'member' as GroupRole },
{ member_name: 'bob', role: 'admin' as GroupRole }
]
expect(groupMemberDiff(prev, next, 'admin')).toEqual([
{ kind: 'addUser', username: 'bob' },
{ kind: 'setAcl', username: 'bob' },
{ kind: 'removeAcl', username: 'admin' }
])
})
})
describe('isGroupDraftDirty', () => {
it('is clean against an equal baseline and dirty on any field', () => {
expect(isGroupDraftDirty(baseline(), baseline())).toBe(false)
expect(isGroupDraftDirty({ ...baseline(), summary: 'Other' }, baseline())).toBe(true)
expect(
isGroupDraftDirty(
{ ...baseline(), members: [{ member_name: 'admin', role: 'member' }] },
baseline()
)
).toBe(true)
})
it('is clean while nothing has loaded', () => {
expect(isGroupDraftDirty(baseline(), undefined)).toBe(false)
})
// A reload rebuilds the members in the server's order, which is not the order they were
// added in. Order-sensitive, an applied change would keep Save lit with nothing to send.
it('ignores the order the members are held in', () => {
const reordered = baseline()
reordered.members = [...reordered.members].reverse()
expect(isGroupDraftDirty(reordered, baseline())).toBe(false)
})
})
+99
View File
@@ -0,0 +1,99 @@
import { deepEqual } from 'fast-equals'
/** What a member may hold on a group. `member` is the `usr_to_group` row server-side and
* `manager` is the `true` entry in `extra_perms`; `admin` is both at once. */
export type GroupRole = 'member' | 'manager' | 'admin'
export type GroupMember = { member_name: string; role: GroupRole }
/** Everything the group editor can change, held as one value so the whole edit is one
* comparison against the loaded group and one Save. */
export type GroupDraft = {
summary: string
members: GroupMember[]
}
/** Whether the draft still matches the group it was loaded from. Every field of `GroupDraft`
* participates, so a field added to the type is covered by construction — which is what the
* discard guard depends on: an edit this misses is an edit the drawer throws away without
* asking. No baseline means nothing has loaded yet, so nothing to lose. */
export function isGroupDraftDirty(draft: GroupDraft, baseline: GroupDraft | undefined): boolean {
return baseline != undefined && !deepEqual(sortedMembers(draft), sortedMembers(baseline))
}
/** Members are a set, but a reload rebuilds them in the server's order while the draft keeps
* the order they were added in. Compared as-is, a change that has already been applied still
* reads as dirty. */
function sortedMembers(value: GroupDraft): GroupDraft {
return {
...value,
members: [...value.members].sort((a, b) => a.member_name.localeCompare(b.member_name))
}
}
/** One backend call a group's members need. Kept as data so the mapping from role
* transitions to endpoints can be read — and tested — without a server. */
export type GroupMemberCall =
/** `addUserToGroup` / `removeUserToGroup`: the `usr_to_group` row. */
| { kind: 'addUser'; username: string }
| { kind: 'removeUser'; username: string }
/** `acls/add` / `acls/remove` on kind `group_`: the write entry that lets someone
* manage the group. */
| { kind: 'setAcl'; username: string }
| { kind: 'removeAcl'; username: string }
/** The two independent things a role is made of: belonging to the group, and holding the
* write entry that lets you manage it. Every role is one combination of the two, which is
* why a transition needs at most one call per flag. */
function flagsOf(role: GroupRole | undefined): { belongs: boolean; manages: boolean } {
return {
belongs: role === 'member' || role === 'admin',
manages: role === 'manager' || role === 'admin'
}
}
/** The calls that turn `prev` into `next`. Members whose role is unchanged produce none, and
* a member dropped from `next` is treated as holding neither flag — which is what removing
* one means.
*
* `require_is_owner` authorizes each of these against `extra_perms['u/<caller>']`, so the
* caller's own revocation goes last: in row order it lands first and the rest 403s. */
export function groupMemberDiff(
prev: GroupMember[],
next: GroupMember[],
caller?: string
): GroupMemberCall[] {
const previousRole = new Map(prev.map((p) => [p.member_name, p.role]))
const calls: GroupMemberCall[] = []
const transition = (
username: string,
before: GroupRole | undefined,
after: GroupRole | undefined
) => {
const from = flagsOf(before)
const to = flagsOf(after)
if (to.belongs !== from.belongs) {
calls.push({ kind: to.belongs ? 'addUser' : 'removeUser', username })
}
if (to.manages !== from.manages) {
calls.push({ kind: to.manages ? 'setAcl' : 'removeAcl', username })
}
}
for (const member of next) {
const before = previousRole.get(member.member_name)
if (before === member.role) continue
transition(member.member_name, before, member.role)
}
const kept = new Set(next.map((n) => n.member_name))
for (const member of prev) {
if (kept.has(member.member_name)) continue
transition(member.member_name, member.role, undefined)
}
const revokesCaller = (call: GroupMemberCall) =>
call.kind === 'removeAcl' && call.username === caller
return [...calls.filter((c) => !revokesCaller(c)), ...calls.filter(revokesCaller)]
}
@@ -4,11 +4,10 @@
import CenteredPage from '$lib/components/CenteredPage.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import FolderEditor from '$lib/components/FolderEditor.svelte'
import FolderEditorDrawer from '$lib/components/FolderEditorDrawer.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import { userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { Button, Drawer, DrawerContent, EmptyState, Skeleton } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import FolderInfo from '$lib/components/FolderInfo.svelte'
import FolderUsageInfo from '$lib/components/FolderUsageInfo.svelte'
import { sendUserToast } from '$lib/utils'
@@ -28,9 +27,8 @@
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let newFolderName: string = $state('')
let folders: FolderW[] | undefined = $state(undefined)
let folderDrawer: Drawer | undefined = $state()
let folderEditorDrawer: FolderEditorDrawer | undefined = $state()
let hubDrawer: Drawer | undefined = $state()
let publishFolderName: string = $state('')
@@ -47,23 +45,11 @@
})
}
function handleKeyUp(event: KeyboardEvent, close: () => void) {
const key = event.key
if (key === 'Enter') {
event.preventDefault()
addFolder()
close()
}
}
async function addFolder() {
await FolderService.createFolder({
workspace: $workspaceStore ?? '',
requestBody: { name: newFolderName }
})
$userStore?.folders.push(newFolderName)
loadFolders()
editFolderName = newFolderName
folderDrawer?.openDrawer()
function onFolderSaved(name: string, created: boolean) {
if (created) $userStore?.folders.push(name)
// Returned, not fired: the drawer reports a failed reload, and it can only see one
// through the promise this hands back.
return loadFolders()
}
$effect(() => {
@@ -74,8 +60,6 @@
}
})
let editFolderName: string = $state('')
function computeMembers(owners: string[], extra_perms: Record<string, any>) {
const members = new Set(owners)
for (const [user, _] of Object.entries(extra_perms)) {
@@ -85,48 +69,18 @@
}
</script>
{#snippet newFolderPopover(
label: string,
placement: 'bottom' | 'bottom-end',
variant: 'accent' | 'default'
)}
<Popover
floatingConfig={{ strategy: 'absolute', placement }}
contentClasses="flex flex-col gap-2 p-4"
{#snippet newFolderButton(label: string, variant: 'accent' | 'default')}
<Button
{variant}
unifiedSize="md"
startIcon={{ icon: Plus }}
on:click={() => folderEditorDrawer?.initNew()}
>
{#snippet trigger()}
<Button {variant} unifiedSize="md" startIcon={{ icon: Plus }} nonCaptureEvent>{label}</Button>
{/snippet}
{#snippet content({ close })}
<input
class="mr-2"
onkeyup={(e) => handleKeyUp(e, () => close())}
placeholder="New folder name"
bind:value={newFolderName}
/>
<div>
<Button
variant="accent"
startIcon={{ icon: Plus }}
disabled={!newFolderName}
on:click={() => {
addFolder()
close()
}}
>
Create
</Button>
</div>
{/snippet}
</Popover>
{label}
</Button>
{/snippet}
<Drawer bind:this={folderDrawer}>
<DrawerContent title="Folder {editFolderName}" on:close={folderDrawer.closeDrawer}>
<FolderEditor on:update={loadFolders} name={editFolderName} />
</DrawerContent>
</Drawer>
<FolderEditorDrawer bind:this={folderEditorDrawer} onSaved={onFolderSaved} />
<Drawer bind:this={hubDrawer} size="1100px">
<DrawerContent
@@ -168,7 +122,7 @@
New folder
</Button>
{:else}
{@render newFolderPopover('New folder', 'bottom-end', 'accent')}
{@render newFolderButton('New folder', 'accent')}
{/if}
</div>
</PageHeader>
@@ -181,7 +135,7 @@
description="Folders are how you grant permissions: make a user or group viewer, writer or admin on a folder and that access applies to every script, flow, app, resource and schedule inside it."
>
{#if !restricted}
{@render newFolderPopover('Add a folder', 'bottom', 'default')}
{@render newFolderButton('Add a folder', 'default')}
{/if}
</EmptyState>
{:else}
@@ -196,8 +150,8 @@
<Cell head class="w-20">Schedules</Cell>
<Cell head class="w-20">Variables</Cell>
<Cell head class="w-20">Resources</Cell>
<Cell head class="w-20">Participants</Cell>
<Cell head last stickyEnd />
<Cell head class="w-20">Members</Cell>
<Cell head last actions>Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
@@ -211,13 +165,7 @@
{/each}
{:else}
{#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)}
<Row
hoverable
on:click={() => {
editFolderName = name
folderDrawer?.openDrawer()
}}
>
<Row hoverable on:click={() => folderEditorDrawer?.initEdit(name)}>
<Cell first>
<span class="text-emphasis text-xs font-semibold">{name}</span>
{#if summary}
@@ -250,17 +198,14 @@
<FolderUsageInfo {name} tabular />
<Cell><FolderInfo members={computeMembers(owners, extra_perms)} /></Cell>
<Cell last stickyEnd shouldStopPropagation>
<Cell last actions shouldStopPropagation>
<Dropdown
items={[
{
displayName: 'Manage folder',
icon: Pen,
disabled: !canWrite,
action: () => {
editFolderName = name
folderDrawer?.openDrawer()
}
action: () => folderEditorDrawer?.initEdit(name)
},
{
displayName: 'Publish to Hub',
@@ -5,9 +5,8 @@
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Button, Drawer, DrawerContent, Skeleton } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import GroupEditor from '$lib/components/GroupEditor.svelte'
import GroupEditorDrawer from '$lib/components/GroupEditorDrawer.svelte'
import InstanceGroupEditor from '$lib/components/InstanceGroupEditor.svelte'
import GroupInfo from '$lib/components/GroupInfo.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
@@ -20,7 +19,6 @@
import Cell from '$lib/components/table/Cell.svelte'
import Row from '$lib/components/table/Row.svelte'
import { untrack } from 'svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud'
@@ -30,10 +28,9 @@
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
let newGroupName: string = $state('')
let groups: GroupW[] | undefined = $state(undefined)
let instanceGroups: InstanceGroupWithWorkspaces[] | undefined = $state(undefined)
let groupDrawer: Drawer | undefined = $state()
let groupEditorDrawer: GroupEditorDrawer | undefined = $state()
async function loadGroups(): Promise<void> {
groups = (await GroupService.listGroups({ workspace: $workspaceStore! })).map((x) => {
@@ -49,24 +46,6 @@
}
}
function handleKeyUp(event: KeyboardEvent, close: () => void) {
const key = event.key
if (key === 'Enter') {
event.preventDefault()
addGroup()
close()
}
}
async function addGroup() {
await GroupService.createGroup({
workspace: $workspaceStore ?? '',
requestBody: { name: newGroupName }
})
loadGroups()
editGroupName = newGroupName
groupDrawer?.openDrawer()
}
$effect(() => {
untrack(() => loadInstanceGroups())
if ($workspaceStore && $userStore) {
@@ -74,16 +53,11 @@
}
})
let editGroupName: string = $state('')
let instanceGroupDrawer: Drawer | undefined = $state()
let editInstanceGroupName: string = $state('')
</script>
<Drawer bind:this={groupDrawer}>
<DrawerContent title="Group {editGroupName}" on:close={groupDrawer.closeDrawer}>
<GroupEditor on:update={loadGroups} name={editGroupName} />
</DrawerContent>
</Drawer>
<GroupEditorDrawer bind:this={groupEditorDrawer} onSaved={loadGroups} />
<Drawer bind:this={instanceGroupDrawer}>
<DrawerContent
@@ -119,37 +93,14 @@
New&nbsp;group
</Button>
{:else}
<Popover floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}>
{#snippet trigger()}
<Button unifiedSize="md" variant="accent" startIcon={{ icon: Plus }} nonCaptureEvent
>New&nbsp;group</Button
>
{/snippet}
{#snippet content({ close })}
<div class="flex-col flex gap-2 p-4">
<TextInput
size="md"
inputProps={{
placeholder: 'New group name',
onkeyup: (e) => handleKeyUp(e, close)
}}
bind:value={newGroupName}
/>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
disabled={!newGroupName}
on:click={() => {
addGroup()
close()
}}
>
Create
</Button>
</div>
{/snippet}
</Popover>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => groupEditorDrawer?.initNew()}
>
New&nbsp;group
</Button>
{/if}
</div>
</div>
@@ -161,7 +112,7 @@
<tr>
<Cell head first>Name</Cell>
<Cell head>Members</Cell>
<Cell head last />
<Cell head last actions>Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
@@ -175,13 +126,7 @@
{/each}
{:else}
{#each groups as { name, summary, extra_perms, canWrite } (name)}
<Row
hoverable
on:click={() => {
editGroupName = name
groupDrawer?.openDrawer()
}}
>
<Row hoverable on:click={() => groupEditorDrawer?.initEdit(name)}>
<Cell first>
<div class="flex flex-row gap-2 justify-between">
<div>
@@ -197,7 +142,7 @@
<Cell>
<GroupInfo {name} />
</Cell>
<Cell>
<Cell last actions shouldStopPropagation>
<Dropdown
items={[
{
@@ -206,8 +151,7 @@
disabled: !canWrite,
action: (e) => {
e?.stopPropagation()
editGroupName = name
groupDrawer?.openDrawer()
groupEditorDrawer?.initEdit(name)
}
},
{
@@ -1086,8 +1086,8 @@
<Cell head>Path</Cell>
<Cell head>Resource type</Cell>
<Cell head>Description</Cell>
<Cell head />
<Cell head last stickyEnd />
<Cell head>Status</Cell>
<Cell head last actions>Actions</Cell>
</Row>
</Head>
<tbody class="divide-y bg-surface">
@@ -1254,8 +1254,8 @@
{/if}
</div>
</Cell>
<Cell last stickyEnd>
<div class="flex justify-end">
<Cell last actions>
<div class="flex justify-end items-center gap-2">
{#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator}
<ExploreAssetButton
asset={{ kind: 'resource', path }}
@@ -1374,7 +1374,7 @@
<Row>
<Cell head first>Name</Cell>
<Cell head>Description</Cell>
<Cell head last stickyEnd />
<Cell head last actions>Actions</Cell>
</Row>
</Head>
<tbody class="divide-y bg-surface">
@@ -1418,7 +1418,7 @@
</span>
</div>
</Cell>
<Cell last stickyEnd class="border-l-0 text-right">
<Cell last actions>
{#if !canWrite}
<!-- Badge is inline-flex, so it needs a right-aligning wrapper to sit
flush with the action buttons on the rows that have them. -->
@@ -391,8 +391,8 @@
<Cell head>Path</Cell>
<Cell head>Value</Cell>
<Cell head>Description</Cell>
<Cell head />
<Cell head last stickyEnd />
<Cell head>Status</Cell>
<Cell head last actions>Actions</Cell>
</tr>
</Head>
<tbody class="divide-y">
@@ -494,7 +494,7 @@
{#if refresh_error}
<Popover notClickable>
<!-- isolate: confine the ping indicator's z-50 to a local stacking context
so it can't paint over a sticky-pinned actions column scrolling past it -->
so it can't paint over anything that scrolls past it -->
<div
class="relative inline-flex justify-center items-center w-4 h-4 isolate"
>
@@ -546,7 +546,7 @@
{/if}
</div>
</Cell>
<Cell last stickyEnd shouldStopPropagation>
<Cell last actions shouldStopPropagation>
<Dropdown
items={() => {
let owner = isOwner(path, $userStore, $workspaceStore)