Files
windmill/frontend/src/lib/components/FolderPicker.svelte
T
Ruben FiszelandClaude Opus 4.8 564b93b968 (cloud) restrict folder creation, sharing and group creation in demo workspace (#10257)
* feat: restrict folder creation, sharing and group creation in demo workspace

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

* fix: guard update_folder sharing bypass and gate folder editor/share ACL controls in demo

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

* fix: guard remove_owner grant path and gate alternate folder-create controls; keep pure-revoke controls enabled

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:00:01 +02:00

258 lines
6.8 KiB
Svelte

<script lang="ts">
import { FolderService } from '$lib/gen'
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 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-]+$/
const restricted = $derived(
isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
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 loadingFolders: boolean = $state(true)
let editingFolder: string = $state('')
type Props = {
folderName: string
initialPath?: string
disabled?: boolean
disableEditing?: boolean
size?: 'sm' | 'md'
drawerOffset?: number
selectInputClass?: string
}
let {
folderName = $bindable(''),
initialPath = $bindable(undefined),
disabled = $bindable(undefined),
disableEditing = $bindable(undefined),
size = 'md',
drawerOffset = 0,
selectInputClass
}: Props = $props()
async function loadFolders(): Promise<void> {
loadingFolders = true
try {
let initialFolders: { name: string; write: boolean }[] = []
let initialFolder = ''
if (initialPath?.split('/')?.[0] == 'f') {
initialFolder = initialPath?.split('/')?.[1]
initialFolders.push({ name: initialFolder, write: true })
}
const excludedFolders = [initialFolder, 'app_groups', 'app_custom', 'app_themes']
folders = initialFolders.concat(
(
await FolderService.listFolderNames({
workspace: $workspaceStore!
})
)
.filter((x) => !excludedFolders.includes(x))
.map((x) => ({
name: x,
write:
$userStore?.folders?.includes(x) == true ||
($userStore?.is_admin ?? false) ||
($userStore?.is_super_admin ?? false)
}))
)
} catch (e) {
sendUserToast(`Could not load folders: ${e}`, true)
} finally {
loadingFolders = false
}
}
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: $workspaceStore ?? '',
requestBody: { name: newFolderName }
})
folderCreated = newFolderName
await loadFolders()
folderName = newFolderName
// 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) {
if (!$userStore.folders) $userStore.folders = []
$userStore.folders.push(newFolderName)
}
} catch (e) {
sendUserToast(`Could not create folder: ${e}`, true)
} finally {
creating = false
}
}
let selectItems = $derived(
folders.map((f) => ({
value: f.name,
label: f.name + (f.write ? '' : ' (read-only)'),
disabled: !f.write
}))
)
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()))
)
function handleSelectKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && selectOpen && noMatchingItems && !restricted) {
e.preventDefault()
selectOpen = false
openCreateFolder()
}
}
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>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="flex group flex-row w-full items-center relative"
role="group"
onkeydown={handleSelectKeydown}
>
<Select
useContentEditable
bind:value={folderName}
bind:filterText
bind:open={selectOpen}
items={selectItems}
disabled={disabled || disableEditing}
loading={loadingFolders}
{size}
placeholder="Select folder"
class="grow min-w-0"
inputClass={selectInputClass}
RightIcon={ChevronDown}
>
{#snippet endSnippet({ item, close })}
<Button
disabled={disabled || disableEditing}
variant="subtle"
unifiedSize="xs"
wrapperClasses="-mr-2 pl-1 -my-2"
btnClasses="hover:bg-surface-tertiary"
onClick={() => {
editingFolder = item.value ?? ''
viewFolder?.openDrawer()
close()
}}
startIcon={{ icon: Pen }}
iconOnly
/>
{/snippet}
{#snippet bottomSnippet({ close })}
{#if !restricted}
<button
class="sticky py-2 px-4 w-full text-left text-xs font-medium hover:bg-surface-hover flex items-center justify-center gap-2 border-t border-border-light {noMatchingItems
? 'bg-surface-hover'
: ''}"
onclick={() => {
close()
openCreateFolder()
}}
>
<PlusIcon class="inline" size={16} />
Create folder
</button>
{/if}
{/snippet}
</Select>
</div>