mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
feat: introduce folders, deprecate items owned by groups
This commit is contained in:
@@ -1821,7 +1821,8 @@ paths:
|
||||
(default false)
|
||||
show also the archived files.
|
||||
when multiple archived hash share the same path, only the ones with the latest create_at
|
||||
are displayed.
|
||||
are
|
||||
ed.
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
@@ -3620,6 +3621,172 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/folders/list:
|
||||
get:
|
||||
summary: list folders
|
||||
operationId: listFolders
|
||||
tags:
|
||||
- folder
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
responses:
|
||||
"200":
|
||||
description: folder list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Folder"
|
||||
|
||||
/w/{workspace}/folders/listnames:
|
||||
get:
|
||||
summary: list folder names
|
||||
operationId: listFolderNames
|
||||
tags:
|
||||
- folder
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: only_member_of
|
||||
in: query
|
||||
description: only list the folders the user is member of (default false)
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: folder list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/folders/create:
|
||||
post:
|
||||
summary: create folder
|
||||
operationId: createFolder
|
||||
tags:
|
||||
- folder
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: create folder
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
owners:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
extra_perms:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
required:
|
||||
- name
|
||||
responses:
|
||||
"200":
|
||||
description: folder created
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/folders/delete/{name}:
|
||||
delete:
|
||||
summary: delete folder
|
||||
operationId: deleteFolder
|
||||
tags:
|
||||
- folder
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Name"
|
||||
responses:
|
||||
"200":
|
||||
description: folder deleted
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/folders/get/{name}:
|
||||
get:
|
||||
summary: get folder
|
||||
operationId: getFolder
|
||||
tags:
|
||||
- folder
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Name"
|
||||
responses:
|
||||
"200":
|
||||
description: folder
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Folder"
|
||||
|
||||
/w/{workspace}/folders/addowner/{name}:
|
||||
post:
|
||||
summary: add owner to folder
|
||||
operationId: addOwnerToFolder
|
||||
tags:
|
||||
- folder
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Name"
|
||||
requestBody:
|
||||
description: owner user to folder
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
owner:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: owner added to folder
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/folders/removeowner/{name}:
|
||||
post:
|
||||
summary: remove owner to folder
|
||||
operationId: removeOwnerToFolder
|
||||
tags:
|
||||
- folder
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Name"
|
||||
requestBody:
|
||||
description: added owner to folder
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
owner:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: owner removed from folder
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/workers/list:
|
||||
get:
|
||||
summary: list workers
|
||||
@@ -3653,7 +3820,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [script, group_, resource, schedule, variable, flow]
|
||||
enum: [script, group_, resource, schedule, variable, flow, folder]
|
||||
responses:
|
||||
"200":
|
||||
description: acls
|
||||
@@ -3678,7 +3845,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [script, group_, resource, schedule, variable, flow]
|
||||
enum: [script, group_, resource, schedule, variable, flow, folder]
|
||||
requestBody:
|
||||
description: acl to add
|
||||
required: true
|
||||
@@ -3714,7 +3881,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [script, group_, resource, schedule, variable, flow]
|
||||
enum: [script, group_, resource, schedule, variable, flow, folder]
|
||||
requestBody:
|
||||
description: acl to add
|
||||
required: true
|
||||
@@ -4297,6 +4464,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
folders:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
usage:
|
||||
$ref: "#/components/schemas/Usage"
|
||||
required:
|
||||
@@ -4307,6 +4478,7 @@ components:
|
||||
- created_at
|
||||
- operator
|
||||
- disabled
|
||||
- folders
|
||||
|
||||
Usage:
|
||||
type: object
|
||||
@@ -4832,6 +5004,24 @@ components:
|
||||
required:
|
||||
- name
|
||||
|
||||
Folder:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
owners:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
extra_perms:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
required:
|
||||
- name
|
||||
- owners
|
||||
- extra_perms
|
||||
|
||||
WorkerPing:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -62,7 +62,7 @@ pub struct ListableApp {
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
pub struct AppVersion {
|
||||
pub id: i64,
|
||||
pub flow_id: Uuid,
|
||||
pub app_id: Uuid,
|
||||
pub value: serde_json::Value,
|
||||
pub created_by: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
@@ -210,7 +210,7 @@ async fn get_app_by_id(
|
||||
"SELECT app.id, app.path, app.summary, app.versions, app.policy,
|
||||
app.extra_perms, app_version.value,
|
||||
app_version.created_at, app_version.created_by from app, app_version
|
||||
WHERE app_version.id = $1 AND app.id = app_version.flow_id AND app.workspace_id = $2",
|
||||
WHERE app_version.id = $1 AND app.id = app_version.app_id AND app.workspace_id = $2",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
@@ -244,7 +244,7 @@ async fn create_app(
|
||||
|
||||
let v_id = sqlx::query_scalar!(
|
||||
"INSERT INTO app_version
|
||||
(flow_id, value, created_by)
|
||||
(app_id, value, created_by)
|
||||
VALUES ($1, $2, $3) RETURNING id",
|
||||
id,
|
||||
app.value,
|
||||
@@ -358,7 +358,7 @@ async fn update_app(
|
||||
"".to_string()
|
||||
};
|
||||
if let Some(nvalue) = &ns.value {
|
||||
let flow_id = sqlx::query_scalar!(
|
||||
let app_id = sqlx::query_scalar!(
|
||||
"SELECT id FROM app WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
w_id
|
||||
@@ -368,9 +368,9 @@ async fn update_app(
|
||||
|
||||
let v_id = sqlx::query_scalar!(
|
||||
"INSERT INTO app_version
|
||||
(flow_id, value, created_by)
|
||||
(app_id, value, created_by)
|
||||
VALUES ($1, $2, $3) RETURNING id",
|
||||
flow_id,
|
||||
app_id,
|
||||
nvalue,
|
||||
authed.username,
|
||||
)
|
||||
|
||||
@@ -72,6 +72,34 @@ impl UserDB {
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
|
||||
let (folders_write, folders_read): &(Vec<_>, Vec<_>) =
|
||||
&authed.folders.clone().into_iter().partition(|x| x.1);
|
||||
|
||||
let mut folders_read = folders_read.clone();
|
||||
folders_read.extend(folders_write.clone());
|
||||
sqlx::query!(
|
||||
"SELECT set_config('session.folders_read', $1, true)",
|
||||
folders_read
|
||||
.iter()
|
||||
.map(|x| x.0.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"SELECT set_config('session.folders_write', $1, true)",
|
||||
folders_write
|
||||
.iter()
|
||||
.map(|x| x.0.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
|
||||
Ok(tx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,11 @@ async fn add_granular_acl(
|
||||
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let identifier = if kind == "group_" { "name" } else { "path" };
|
||||
let identifier = if kind == "group_" || kind == "folder" {
|
||||
"name"
|
||||
} else {
|
||||
"path"
|
||||
};
|
||||
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
||||
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
|
||||
true) WHERE {identifier} = $2 AND workspace_id = $3 RETURNING extra_perms"
|
||||
@@ -73,7 +77,11 @@ async fn remove_granular_acl(
|
||||
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let identifier = if kind == "group_" { "name" } else { "path" };
|
||||
let identifier = if kind == "group_" || kind == "folder" {
|
||||
"name"
|
||||
} else {
|
||||
"path"
|
||||
};
|
||||
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
||||
"UPDATE {kind} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND \
|
||||
workspace_id = $3 RETURNING extra_perms"
|
||||
|
||||
@@ -29,6 +29,7 @@ mod capture;
|
||||
mod db;
|
||||
mod favorite;
|
||||
mod flows;
|
||||
mod folders;
|
||||
mod granular_acls;
|
||||
mod groups;
|
||||
pub mod jobs;
|
||||
@@ -126,7 +127,8 @@ pub async fn run_server(
|
||||
.nest("/flows", flows::workspaced_service())
|
||||
.nest("/capture", capture::workspaced_service())
|
||||
.nest("/apps", apps::workspaced_service())
|
||||
.nest("/favorites", favorite::workspaced_service()),
|
||||
.nest("/favorites", favorite::workspaced_service())
|
||||
.nest("/folders", folders::workspaced_service()),
|
||||
)
|
||||
.nest("/workspaces", workspaces::global_service())
|
||||
.nest(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Script } from "./gen"
|
||||
|
||||
export type OwnerKind = 'group' | 'user'
|
||||
export type OwnerKind = 'group' | 'user' | 'folder'
|
||||
|
||||
export type ActionKind = 'Create' | 'Update' | 'Delete' | 'Execute'
|
||||
|
||||
@@ -32,6 +32,8 @@ export function pathToMeta(path: string): Meta {
|
||||
let ownerKind: OwnerKind
|
||||
if (splitted[0] == 'g') {
|
||||
ownerKind = 'group'
|
||||
} else if (splitted[0] == 'f') {
|
||||
ownerKind = 'folder'
|
||||
} else if (splitted[0] == 'u') {
|
||||
ownerKind = 'user'
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
type Folder,
|
||||
FolderService,
|
||||
UserService,
|
||||
GranularAclService,
|
||||
GroupService
|
||||
} from '$lib/gen'
|
||||
import AutoComplete from 'simple-svelte-autocomplete'
|
||||
import TableCustom from './TableCustom.svelte'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { Button, ToggleButton, ToggleButtonGroup } from './common'
|
||||
import Skeleton from './common/skeleton/Skeleton.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
|
||||
export let name: string
|
||||
let can_write = false
|
||||
|
||||
type Role = 'viewer' | 'writer' | 'owner'
|
||||
let folder: Folder | undefined
|
||||
let perms: { owner_name: string; role: Role }[] | undefined = undefined
|
||||
let managing_folders: string[] = []
|
||||
let ownerItems: string[] | undefined = []
|
||||
let ownerItem: string = ''
|
||||
let folders: string[] = []
|
||||
|
||||
async function loadUsernames(): Promise<void> {
|
||||
ownerItem = ''
|
||||
ownerItems = await UserService.listUsernames({ workspace: $workspaceStore! })
|
||||
}
|
||||
|
||||
async function loadGroups(): Promise<void> {
|
||||
ownerItem = ''
|
||||
ownerItems = await GroupService.listGroupNames({ workspace: $workspaceStore! })
|
||||
}
|
||||
|
||||
$: {
|
||||
if ($workspaceStore && $userStore) {
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if ($workspaceStore && $userStore) {
|
||||
if (ownerKind == 'user') {
|
||||
loadUsernames()
|
||||
} else {
|
||||
loadGroups()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loadFolders()
|
||||
await loadFolder()
|
||||
loadUsernames()
|
||||
}
|
||||
|
||||
async function loadFolders(): Promise<void> {
|
||||
folders = (await FolderService.listFolders({ workspace: $workspaceStore! })).map((x) => x.name)
|
||||
}
|
||||
|
||||
async function addToFolder() {
|
||||
await GranularAclService.addGranularAcls({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: name,
|
||||
kind: 'folder',
|
||||
requestBody: {
|
||||
owner: (ownerKind == 'user' ? 'u/' : 'g/') + ownerItem
|
||||
}
|
||||
})
|
||||
loadFolder()
|
||||
}
|
||||
|
||||
async function loadFolder(): Promise<void> {
|
||||
folder = await FolderService.getFolder({ workspace: $workspaceStore!, name })
|
||||
can_write = canWrite(name!, folder.extra_perms ?? {}, $userStore)
|
||||
perms = Array.from(
|
||||
new Set(
|
||||
Object.entries(folder?.extra_perms ?? {})
|
||||
.map((x) => x[0])
|
||||
.concat(folder?.owners ?? [])
|
||||
)
|
||||
).map((x) => {
|
||||
return {
|
||||
owner_name: x,
|
||||
role: getRole(x)
|
||||
}
|
||||
})
|
||||
managing_folders = Object.entries(folder?.extra_perms ?? {})
|
||||
.filter(([k, v]) => k.startsWith('g/') && v)
|
||||
.map(([k, v]) => k)
|
||||
}
|
||||
|
||||
function getRole(x: string): Role {
|
||||
const viewer = x in (folder?.extra_perms ?? {})
|
||||
const writer = viewer && (folder?.extra_perms ?? {})[x]
|
||||
const owner = folder?.owners?.includes(x)
|
||||
|
||||
if (owner) {
|
||||
return 'owner'
|
||||
} else if (writer) {
|
||||
return 'writer'
|
||||
} else {
|
||||
return 'viewer'
|
||||
}
|
||||
}
|
||||
let ownerKind: 'user' | 'group' = 'user'
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1>{name}</h1>
|
||||
<h2>Permissions</h2>
|
||||
{#if can_write}
|
||||
<div class="flex items-center gap-1">
|
||||
<div>
|
||||
<ToggleButtonGroup bind:selected={ownerKind}>
|
||||
<ToggleButton position="left" value="user" size="xs">User</ToggleButton>
|
||||
<ToggleButton position="right" value="group" size="xs">Group</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<AutoComplete items={ownerItems} bind:selectedItem={ownerItem} />
|
||||
<Button
|
||||
disabled={ownerItem == ''}
|
||||
variant="contained"
|
||||
color="blue"
|
||||
size="sm"
|
||||
on:click={addToFolder}
|
||||
>
|
||||
Grant permission to folder
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if perms}
|
||||
<TableCustom>
|
||||
<tr slot="header-row">
|
||||
<th>user</th>
|
||||
<th />
|
||||
<th />
|
||||
</tr>
|
||||
<tbody slot="body">
|
||||
{#each perms as { owner_name, role }}<tr>
|
||||
<td>{owner_name}</td>
|
||||
<td>
|
||||
{#if can_write}
|
||||
<div>
|
||||
<ToggleButtonGroup
|
||||
selected={role}
|
||||
on:selected={async (e) => {
|
||||
const role = e.detail
|
||||
// const wasInFolder = (folder?.owners ?? []).includes(folder)
|
||||
// const inAcl = (
|
||||
// folder?.extra_perms ? Object.keys(folder?.extra_perms) : []
|
||||
// ).includes(folder)
|
||||
if (role == 'owner') {
|
||||
await FolderService.addOwnerToFolder({
|
||||
workspace: $workspaceStore ?? '',
|
||||
name,
|
||||
requestBody: {
|
||||
owner: owner_name
|
||||
}
|
||||
})
|
||||
await GranularAclService.addGranularAcls({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: name,
|
||||
kind: 'folder',
|
||||
requestBody: {
|
||||
owner: owner_name
|
||||
}
|
||||
})
|
||||
} else if (role == 'writer') {
|
||||
await FolderService.removeOwnerToFolder({
|
||||
workspace: $workspaceStore ?? '',
|
||||
name,
|
||||
requestBody: {
|
||||
owner: owner_name
|
||||
}
|
||||
})
|
||||
await GranularAclService.addGranularAcls({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: name,
|
||||
kind: 'folder',
|
||||
requestBody: {
|
||||
owner: owner_name,
|
||||
write: true
|
||||
}
|
||||
})
|
||||
} else if (role == 'viewer') {
|
||||
await FolderService.removeOwnerToFolder({
|
||||
workspace: $workspaceStore ?? '',
|
||||
name,
|
||||
requestBody: {
|
||||
owner: owner_name
|
||||
}
|
||||
})
|
||||
await GranularAclService.addGranularAcls({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: name,
|
||||
kind: 'folder',
|
||||
requestBody: {
|
||||
owner: owner_name,
|
||||
write: false
|
||||
}
|
||||
})
|
||||
}
|
||||
loadFolder()
|
||||
}}
|
||||
>
|
||||
<ToggleButton position="left" value="viewer" size="xs"
|
||||
>Viewer <Tooltip
|
||||
>A viewer of a folder has read-only access to all the elements
|
||||
(scripts/flows/apps/schedules/resources/variables) inside the folder</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
<ToggleButton position="center" value="writer" size="xs"
|
||||
>Writer <Tooltip
|
||||
>A viewer of a folder has read AND write access to all the elements
|
||||
(scripts/flows/apps/schedules/resources/variables) inside the folder</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
<ToggleButton position="right" value="owner" size="xs"
|
||||
>Admin <Tooltip
|
||||
>An admin of a folder has read AND write access to all the elements inside
|
||||
the folders and can manage the permissions as well as add new admins</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{/if}</td
|
||||
>
|
||||
<td>
|
||||
{#if can_write}
|
||||
<button
|
||||
class="ml-2 text-red-500"
|
||||
on:click={async () => {
|
||||
await FolderService.removeOwnerToFolder({
|
||||
workspace: $workspaceStore ?? '',
|
||||
name,
|
||||
requestBody: { owner: owner_name }
|
||||
})
|
||||
await GranularAclService.removeGranularAcls({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: name,
|
||||
kind: 'folder',
|
||||
requestBody: {
|
||||
owner: owner_name
|
||||
}
|
||||
})
|
||||
loadFolder()
|
||||
}}>remove</button
|
||||
>
|
||||
{/if}</td
|
||||
>
|
||||
</tr>{/each}
|
||||
</tbody>
|
||||
</TableCustom>
|
||||
<!-- <h2 class="mt-10"
|
||||
>Folders managing this folder <Tooltip
|
||||
>Any owner of those folders can manage this folder</Tooltip
|
||||
></h2
|
||||
>
|
||||
{#if can_write}
|
||||
<div class="flex items-start">
|
||||
<AutoComplete items={folders} bind:selectedItem={new_managing_folder} />
|
||||
<Button
|
||||
variant="contained"
|
||||
color="blue"
|
||||
size="sm"
|
||||
btnClasses="!ml-4"
|
||||
on:click={addToManagingFolder}
|
||||
>
|
||||
Add folder managing this folder
|
||||
</Button>
|
||||
</div>
|
||||
{/if} -->
|
||||
<!-- {#if managing_folders.length == 0}
|
||||
<p class="text-gray-600 text-sm">No folder is managing this folder</p>
|
||||
{:else}
|
||||
<TableCustom>
|
||||
<tr slot="header-row">
|
||||
<th>folder</th>
|
||||
<th />
|
||||
</tr>
|
||||
<tbody slot="body">
|
||||
{#each managing_folders as managing_folder}<tr>
|
||||
<td>{managing_folder.split('/')[1]}</td>
|
||||
<td>
|
||||
{#if can_write}
|
||||
<button
|
||||
class="ml-2 text-red-500"
|
||||
on:click={async () => {
|
||||
await GranularAclService.removeGranularAcls({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: name,
|
||||
kind: 'folder',
|
||||
requestBody: {
|
||||
owner: managing_folder
|
||||
}
|
||||
})
|
||||
loadFolder()
|
||||
}}>remove</button
|
||||
>
|
||||
{/if}</td
|
||||
>
|
||||
</tr>{/each}
|
||||
</tbody>
|
||||
</TableCustom>
|
||||
{/if} -->
|
||||
{:else}
|
||||
<div class="flex flex-col">
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[2], 0.7]} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -3,10 +3,12 @@
|
||||
import { type Group, GroupService, UserService, GranularAclService } from '$lib/gen'
|
||||
import AutoComplete from 'simple-svelte-autocomplete'
|
||||
import TableCustom from './TableCustom.svelte'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { canWrite, sendUserToast } from '$lib/utils'
|
||||
import { Button, ToggleButton, ToggleButtonGroup } from './common'
|
||||
import Skeleton from './common/skeleton/Skeleton.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import autosize from 'svelte-autosize'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let name: string
|
||||
let can_write = false
|
||||
@@ -19,6 +21,8 @@
|
||||
let username: string = ''
|
||||
let groups: string[] = []
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function loadUsernames(): Promise<void> {
|
||||
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
|
||||
}
|
||||
@@ -48,19 +52,6 @@
|
||||
loadGroup()
|
||||
}
|
||||
|
||||
async function addToManagingGroup() {
|
||||
await GranularAclService.addGranularAcls({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: name,
|
||||
kind: 'group_',
|
||||
requestBody: {
|
||||
owner: 'g/' + new_managing_group,
|
||||
write: true
|
||||
}
|
||||
})
|
||||
loadGroup()
|
||||
}
|
||||
|
||||
async function loadGroup(): Promise<void> {
|
||||
group = await GroupService.getGroup({ workspace: $workspaceStore!, name })
|
||||
can_write = canWrite(name!, group.extra_perms ?? {}, $userStore)
|
||||
@@ -85,7 +76,7 @@
|
||||
function getRole(x: string): Role {
|
||||
const writer = 'u/' + x in (group?.extra_perms ?? {}) && (group?.extra_perms ?? {})['u/' + x]
|
||||
const member = group?.members?.includes(x)
|
||||
console.log(writer, member, x, group?.members, group?.extra_perms)
|
||||
|
||||
if (writer && member) {
|
||||
return 'member_manager'
|
||||
} else if (writer) {
|
||||
@@ -94,14 +85,33 @@
|
||||
return 'member'
|
||||
}
|
||||
}
|
||||
|
||||
let new_managing_group = ''
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1>{name}</h1>
|
||||
{#if group}
|
||||
<p>{group?.summary ?? 'No summary'}</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<textarea
|
||||
disabled={!can_write}
|
||||
rows="2"
|
||||
type="text"
|
||||
use:autosize
|
||||
bind:value={group.summary}
|
||||
placeholder="Summary of the group"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
await GroupService.updateGroup({
|
||||
workspace: $workspaceStore ?? '',
|
||||
name,
|
||||
requestBody: { summary: group?.summary }
|
||||
})
|
||||
dispatch('update')
|
||||
sendUserToast('New summary saved')
|
||||
}}>Save Summary</Button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<Skeleton layout={[[4]]} />
|
||||
{/if}
|
||||
@@ -195,14 +205,14 @@
|
||||
everything the group can write, and generally act on behalf of the group</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
<ToggleButton position="center" value="manager" size="xs"
|
||||
<!-- <ToggleButton position="center" value="manager" size="xs"
|
||||
>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
|
||||
></ToggleButton
|
||||
>
|
||||
> -->
|
||||
<ToggleButton position="right" value="member_manager" size="xs"
|
||||
>Member & Manager</ToggleButton
|
||||
>Admin</ToggleButton
|
||||
>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
@@ -226,7 +236,7 @@
|
||||
</tr>{/each}
|
||||
</tbody>
|
||||
</TableCustom>
|
||||
<h2 class="mt-10"
|
||||
<!-- <h2 class="mt-10"
|
||||
>Groups managing this group <Tooltip>Any member of those groups can manage this group</Tooltip
|
||||
></h2
|
||||
>
|
||||
@@ -276,7 +286,7 @@
|
||||
</tr>{/each}
|
||||
</tbody>
|
||||
</TableCustom>
|
||||
{/if}
|
||||
{/if} -->
|
||||
{:else}
|
||||
<div class="flex flex-col">
|
||||
{#each new Array(6) as _}
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
|
||||
import {
|
||||
FlowService,
|
||||
FolderService,
|
||||
ResourceService,
|
||||
ScheduleService,
|
||||
ScriptService,
|
||||
VariableService
|
||||
} from '$lib/gen'
|
||||
import { GroupService } from '$lib/gen'
|
||||
import { superadmin, userStore, workspaceStore } from '$lib/stores'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Required from './Required.svelte'
|
||||
import { Button, Drawer, DrawerContent } from './common'
|
||||
import { faEye, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import GroupEditor from './GroupEditor.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton/ToggleButton.svelte'
|
||||
import { Icon } from 'svelte-awesome'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import FolderEditor from './FolderEditor.svelte'
|
||||
|
||||
type PathKind = 'resource' | 'script' | 'variable' | 'flow' | 'schedule' | 'app'
|
||||
let meta: Meta | undefined = undefined
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let groups: string[] = []
|
||||
let folders: string[] = []
|
||||
|
||||
$: meta && onMetaChange()
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
|
||||
function metaToPath(meta: Meta): string {
|
||||
return [meta.ownerKind === 'group' ? 'g' : 'u', meta.owner, meta.name].join('/')
|
||||
return [meta.ownerKind === 'folder' ? 'f' : 'u', meta.owner, meta.name].join('/')
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
@@ -80,15 +80,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups(): Promise<void> {
|
||||
let initialGroups: string[] = []
|
||||
if (initialPath?.split('/')?.[0] == 'g') {
|
||||
initialGroups.push(initialPath?.split('/')?.[1])
|
||||
async function loadFolders(): Promise<void> {
|
||||
let initialFolders: string[] = []
|
||||
if (initialPath?.split('/')?.[0] == 'f') {
|
||||
initialFolders.push(initialPath?.split('/')?.[1])
|
||||
}
|
||||
groups = initialGroups.concat(
|
||||
await GroupService.listGroupNames({
|
||||
workspace: $workspaceStore!,
|
||||
onlyMemberOf: !$userStore?.is_admin
|
||||
folders = initialFolders.concat(
|
||||
await FolderService.listFolderNames({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -149,6 +148,9 @@
|
||||
} else if (!/^[\w-]+(\/[\w-]+)*$/.test(meta.name)) {
|
||||
error = 'This name is not valid'
|
||||
return false
|
||||
} else if (meta.owner == '') {
|
||||
error = 'Folder need to be chosen'
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
@@ -156,7 +158,7 @@
|
||||
|
||||
$: {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadGroups()
|
||||
loadFolders()
|
||||
initPath()
|
||||
}
|
||||
}
|
||||
@@ -171,40 +173,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
let newGroup: Drawer
|
||||
let viewGroup: Drawer
|
||||
let newGroupName: string
|
||||
let groupCreated: string | undefined = undefined
|
||||
let newFolder: Drawer
|
||||
let viewFolder: Drawer
|
||||
let newFolderName: string
|
||||
let folderCreated: string | undefined = undefined
|
||||
|
||||
async function addGroup() {
|
||||
await GroupService.createGroup({
|
||||
async function addFolder() {
|
||||
await FolderService.createFolder({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { name: newGroupName }
|
||||
requestBody: { name: newFolderName }
|
||||
})
|
||||
groupCreated = newGroupName
|
||||
loadGroups()
|
||||
folderCreated = newFolderName
|
||||
if (meta) {
|
||||
meta.owner = newFolderName
|
||||
}
|
||||
loadFolders()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={newGroup}>
|
||||
<DrawerContent title="New Group" on:close={newGroup.closeDrawer}>
|
||||
<Drawer bind:this={newFolder}>
|
||||
<DrawerContent title="New Folder" on:close={newFolder.closeDrawer}>
|
||||
<div class="flex flex-row">
|
||||
<input class="mr-2" placeholder="New group name" bind:value={newGroupName} />
|
||||
<Button size="md" startIcon={{ icon: faPlus }} disabled={!newGroupName} on:click={addGroup}>
|
||||
New group
|
||||
<input class="mr-2" placeholder="New folder name" bind:value={newFolderName} />
|
||||
<Button size="md" startIcon={{ icon: faPlus }} disabled={!newFolderName} on:click={addFolder}>
|
||||
New folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if groupCreated}
|
||||
{#if folderCreated}
|
||||
<div class="mt-8" />
|
||||
<GroupEditor name={groupCreated} />
|
||||
<FolderEditor name={folderCreated} />
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={viewGroup}>
|
||||
<DrawerContent title="Group {meta?.owner}" on:close={viewGroup.closeDrawer}>
|
||||
<GroupEditor name={meta?.owner ?? ''} />
|
||||
<Drawer bind:this={viewFolder}>
|
||||
<DrawerContent title="Folder {meta?.owner}" on:close={viewFolder.closeDrawer}>
|
||||
<FolderEditor name={meta?.owner ?? ''} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -220,10 +225,9 @@
|
||||
bind:selected={meta.ownerKind}
|
||||
on:selected={(e) => {
|
||||
const kind = e.detail
|
||||
console.log(kind)
|
||||
if (meta) {
|
||||
if (kind === 'group') {
|
||||
meta.owner = 'all'
|
||||
if (kind === 'folder') {
|
||||
meta.owner = $userStore?.folders?.[0] ?? ''
|
||||
} else {
|
||||
meta.owner = $userStore?.username ?? ''
|
||||
}
|
||||
@@ -231,19 +235,12 @@
|
||||
}}
|
||||
>
|
||||
<ToggleButton light size="xs" value="user" position="left">User</ToggleButton>
|
||||
<ToggleButton light size="xs" value="group" position="right">Group</ToggleButton>
|
||||
<ToggleButton light size="xs" value="folder" position="right">Folder</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</label>
|
||||
{#if meta.ownerKind === 'user'}
|
||||
<label class="block shrink min-w-0">
|
||||
<span
|
||||
><span class="text-gray-700 text-sm mr-1">Owner</span><Tooltip
|
||||
>The prefix of a path defines the owner of an item. An owner has write permissions
|
||||
and can modify the path. An item can still be made writable or readable using
|
||||
granular perissioning.
|
||||
<a href="https://docs.windmill.dev/docs/reference/namespaces">See docs</a></Tooltip
|
||||
></span
|
||||
>
|
||||
<span class="text-gray-700 text-sm">User</span>
|
||||
<input
|
||||
class="!w-36"
|
||||
type="text"
|
||||
@@ -254,25 +251,18 @@
|
||||
</label>
|
||||
{:else}
|
||||
<label class="block grow w-48">
|
||||
<span
|
||||
><span class="text-gray-700 text-sm mr-1">Owner</span><Tooltip
|
||||
>The prefix of a path defines the owner of an item. An owner has write permissions
|
||||
and can modify the path. An item can still be made writable or readable using
|
||||
granular perissioning.
|
||||
<a href="https://docs.windmill.dev/docs/reference/namespaces">docs</a></Tooltip
|
||||
></span
|
||||
>
|
||||
<span class="text-gray-700 text-sm">Folder</span>
|
||||
|
||||
<div class="flex flex-row gap-1 w-full">
|
||||
<select class="grow w-full" {disabled} bind:value={meta.owner}>
|
||||
{#each groups as g}
|
||||
<option>{g}</option>
|
||||
{#each folders as f}
|
||||
<option>{f}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button variant="border" size="xs" on:click={viewGroup.openDrawer}>
|
||||
<Button variant="border" size="xs" on:click={viewFolder.openDrawer}>
|
||||
<Icon scale={0.8} data={faEye} /></Button
|
||||
>
|
||||
<Button variant="border" size="xs" on:click={newGroup.openDrawer}>
|
||||
<Button variant="border" size="xs" on:click={newFolder.openDrawer}>
|
||||
<Icon scale={0.8} data={faPlus} /></Button
|
||||
></div
|
||||
>
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
faRobot,
|
||||
faUsersCog,
|
||||
faCog,
|
||||
faDollarSign
|
||||
faDollarSign,
|
||||
faFolderTree
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'
|
||||
import MenuLink from './MenuLink.svelte'
|
||||
import { superadmin, userStore } from '$lib/stores'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
const mainMenuLinks = [
|
||||
{ label: 'Home', href: '/', icon: faHomeAlt },
|
||||
@@ -24,6 +25,7 @@
|
||||
|
||||
$: secondaryMenuLinks = [
|
||||
{ label: 'Schedules', href: '/schedules', icon: faCalendar, disabled: $userStore?.operator },
|
||||
{ label: 'Folders', href: '/folders', icon: faFolderTree, disabled: $userStore?.operator },
|
||||
{ label: 'Groups', href: '/groups', icon: faUsersCog, disabled: $userStore?.operator },
|
||||
{ label: 'Audit Logs', href: '/audit_logs', icon: faEye, disabled: $userStore?.operator },
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface UserExt {
|
||||
created_at: string;
|
||||
groups: string[];
|
||||
pgroups: string[];
|
||||
folders: string[]
|
||||
}
|
||||
|
||||
let persistedWorkspace = browser && localStorage.getItem("workspace");
|
||||
|
||||
@@ -199,6 +199,11 @@ export function canWrite(
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
user.folders.findIndex((x) => path.startsWith('f/' + x)) != -1
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -332,7 +337,7 @@ export function mapUserToUserExt(user: User): UserExt {
|
||||
return {
|
||||
...user,
|
||||
groups: user.groups!,
|
||||
pgroups: user.groups!.map((x) => `g/${x}`)
|
||||
pgroups: user.groups!.map((x) => `g/${x}`),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script context="module">
|
||||
export function load() {
|
||||
return {
|
||||
stuff: { title: 'Folders' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Folder } from '$lib/gen'
|
||||
import { FolderService } from '$lib/gen'
|
||||
import { canWrite } from '$lib/utils'
|
||||
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import Dropdown from '$lib/components/Dropdown.svelte'
|
||||
import FolderEditor from '$lib/components/FolderEditor.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import TableCustom from '$lib/components/TableCustom.svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { faEdit, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
|
||||
type FolderW = Folder & { canWrite: boolean }
|
||||
|
||||
let newFolderName: string = ''
|
||||
let folders: FolderW[] = []
|
||||
let folderDrawer: Drawer
|
||||
|
||||
async function loadFolders(): Promise<void> {
|
||||
folders = (await FolderService.listFolders({ workspace: $workspaceStore! })).map((x) => {
|
||||
return { canWrite: canWrite(x.name, x.extra_perms ?? {}, $userStore), ...x }
|
||||
})
|
||||
}
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
if (key === 'Enter') {
|
||||
event.preventDefault()
|
||||
addFolder()
|
||||
}
|
||||
}
|
||||
async function addFolder() {
|
||||
await FolderService.createFolder({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { name: newFolderName }
|
||||
})
|
||||
loadFolders()
|
||||
editFolderName = newFolderName
|
||||
folderDrawer.openDrawer()
|
||||
}
|
||||
|
||||
$: {
|
||||
if ($workspaceStore && $userStore) {
|
||||
loadFolders()
|
||||
}
|
||||
}
|
||||
|
||||
let editFolderName: string = ''
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={folderDrawer}>
|
||||
<DrawerContent title="Folder {editFolderName}" on:close={folderDrawer.closeDrawer}>
|
||||
<FolderEditor on:update={loadFolders} name={editFolderName} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Folders"
|
||||
tooltip="Folder users together to grant roles and homegenous permissions. Same users can be in many folders at the same time."
|
||||
>
|
||||
<div class="flex flex-row">
|
||||
<input
|
||||
class="mr-2"
|
||||
on:keyup={handleKeyUp}
|
||||
placeholder="New folder name"
|
||||
bind:value={newFolderName}
|
||||
/>
|
||||
<Button size="md" startIcon={{ icon: faPlus }} disabled={!newFolderName} on:click={addFolder}>
|
||||
New folder
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div class="relative mb-20">
|
||||
<TableCustom>
|
||||
<tr slot="header-row">
|
||||
<th>Name</th>
|
||||
<th />
|
||||
</tr>
|
||||
<tbody slot="body">
|
||||
{#if folders.length === 0}
|
||||
<tr>
|
||||
<td colspan="2" class="text-gray-600 mt-2"> No folders yet, create one! </td>
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
{#each folders as { name, extra_perms, canWrite }}
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="#{name}"
|
||||
on:click={() => {
|
||||
editFolderName = name
|
||||
folderDrawer.openDrawer()
|
||||
}}
|
||||
>{name}
|
||||
</a>
|
||||
<div>
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
dropdownItems={[
|
||||
{
|
||||
displayName: 'Manage folder',
|
||||
icon: faEdit,
|
||||
disabled: !canWrite,
|
||||
action: () => {
|
||||
editFolderName = name
|
||||
folderDrawer.openDrawer()
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
|
||||
icon: faTrash,
|
||||
type: 'delete',
|
||||
disabled: !canWrite,
|
||||
action: async () => {
|
||||
await FolderService.deleteFolder({ workspace: $workspaceStore ?? '', name })
|
||||
loadFolders()
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</TableCustom>
|
||||
</div>
|
||||
</CenteredPage>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -47,6 +47,8 @@
|
||||
requestBody: { name: newGroupName }
|
||||
})
|
||||
loadGroups()
|
||||
editGroupName = newGroupName
|
||||
groupDrawer.openDrawer()
|
||||
}
|
||||
|
||||
$: {
|
||||
@@ -60,7 +62,7 @@
|
||||
|
||||
<Drawer bind:this={groupDrawer}>
|
||||
<DrawerContent title="Group {editGroupName}" on:close={groupDrawer.closeDrawer}>
|
||||
<GroupEditor name={editGroupName} />
|
||||
<GroupEditor on:update={loadGroups} name={editGroupName} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user