mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
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:
+19
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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' : '',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 group
|
||||
</Button>
|
||||
{:else}
|
||||
<Popover floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}>
|
||||
{#snippet trigger()}
|
||||
<Button unifiedSize="md" variant="accent" startIcon={{ icon: Plus }} nonCaptureEvent
|
||||
>New 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 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)
|
||||
|
||||
Reference in New Issue
Block a user