566 lines
16 KiB
Svelte
566 lines
16 KiB
Svelte
<script lang="ts">
|
|
import AddUser from '$lib/components/AddUser.svelte'
|
|
import { Badge, Button, Popup, Skeleton } from '$lib/components/common'
|
|
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
|
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
|
|
import InviteUser from '$lib/components/InviteUser.svelte'
|
|
import PageHeader from '$lib/components/PageHeader.svelte'
|
|
|
|
import DataTable from '$lib/components/table/DataTable.svelte'
|
|
import Head from '$lib/components/table/Head.svelte'
|
|
import Toggle from '$lib/components/Toggle.svelte'
|
|
import Tooltip from '$lib/components/Tooltip.svelte'
|
|
import type { CancelablePromise, User, UserUsage } from '$lib/gen'
|
|
import { UserService, WorkspaceService, type WorkspaceInvite } from '$lib/gen'
|
|
import { userStore, workspaceStore } from '$lib/stores'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { Loader2, Mails, Search } from 'lucide-svelte'
|
|
import SearchItems from '../SearchItems.svelte'
|
|
import Cell from '../table/Cell.svelte'
|
|
import Row from '../table/Row.svelte'
|
|
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
|
import { isCloudHosted } from '$lib/cloud'
|
|
import { truncate } from '$lib/utils'
|
|
import { onDestroy } from 'svelte'
|
|
|
|
let users: User[] | undefined = undefined
|
|
let invites: WorkspaceInvite[] = []
|
|
let filteredUsers: User[] | undefined = undefined
|
|
let userFilter = ''
|
|
let auto_invite_domain: string | undefined
|
|
let operatorOnly: boolean | undefined = undefined
|
|
let autoAdd: boolean | undefined = undefined
|
|
let nbDisplayed = 30
|
|
|
|
async function loadSettings(): Promise<void> {
|
|
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
|
auto_invite_domain = settings.auto_invite_domain
|
|
operatorOnly = settings.auto_invite_operator
|
|
autoAdd = settings.auto_add
|
|
}
|
|
|
|
let getUsagePromise: CancelablePromise<UserUsage[]> | undefined = undefined
|
|
|
|
let usage: Record<string, number> | undefined = undefined
|
|
|
|
async function getUsage() {
|
|
try {
|
|
getUsagePromise = UserService.listUsersUsage({ workspace: $workspaceStore! })
|
|
const res = await getUsagePromise
|
|
usage = res.reduce((acc, { email, executions }) => {
|
|
if (email) {
|
|
acc[email] = executions ?? 0
|
|
}
|
|
return acc
|
|
}, {} as Record<string, number>)
|
|
} catch (e) {
|
|
console.warn(e)
|
|
}
|
|
}
|
|
|
|
async function listUsers(): Promise<void> {
|
|
users = await UserService.listUsers({ workspace: $workspaceStore! })
|
|
}
|
|
|
|
async function listInvites(): Promise<void> {
|
|
invites = await WorkspaceService.listPendingInvites({ workspace: $workspaceStore! })
|
|
}
|
|
|
|
let allowedAutoDomain = false
|
|
|
|
async function getDisallowedAutoDomain() {
|
|
allowedAutoDomain = await WorkspaceService.isDomainAllowed()
|
|
}
|
|
|
|
$: domain = $userStore?.email.split('@')[1]
|
|
|
|
$: {
|
|
if ($workspaceStore) {
|
|
getDisallowedAutoDomain()
|
|
listUsers()
|
|
getUsage()
|
|
listInvites()
|
|
loadSettings()
|
|
}
|
|
}
|
|
|
|
onDestroy(() => {
|
|
try {
|
|
getUsagePromise?.cancel()
|
|
} catch (e) {
|
|
console.warn(e)
|
|
}
|
|
})
|
|
|
|
let deleteConfirmedCallback: (() => void) | undefined = undefined
|
|
|
|
async function removeAllInvitesFromDomain() {
|
|
await Promise.all(
|
|
invites
|
|
.filter((x) => (isCloudHosted() ? x.email.endsWith('@' + auto_invite_domain ?? '') : true))
|
|
.map(({ email, is_admin, operator }) =>
|
|
WorkspaceService.deleteInvite({
|
|
workspace: $workspaceStore ?? '',
|
|
requestBody: {
|
|
email,
|
|
is_admin,
|
|
operator
|
|
}
|
|
})
|
|
)
|
|
)
|
|
}
|
|
|
|
let nbInviteDisplayed = 50
|
|
|
|
let showInvites = false
|
|
$: showInvites = invites?.length > 0 || (auto_invite_domain != undefined && !autoAdd)
|
|
</script>
|
|
|
|
<SearchItems
|
|
filter={userFilter}
|
|
items={users}
|
|
bind:filteredItems={filteredUsers}
|
|
f={(x) => x.email + ' ' + x.name + ' ' + x.company}
|
|
/>
|
|
<div class="flex flex-col gap-4 my-8">
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-tertiary text-xs">
|
|
Add members to your workspace and manage their roles. You can also auto-add users to join your
|
|
workspace.
|
|
<a
|
|
href="https://www.windmill.dev/docs/core_concepts/roles_and_permissions"
|
|
target="_blank"
|
|
class="text-blue-500">Learn more</a
|
|
>.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-row justify-between items-center">
|
|
<PageHeader
|
|
title="Members ({filteredUsers?.length ?? users?.length ?? ''})"
|
|
primary={true}
|
|
tooltip="Manage users manually or enable SSO authentication."
|
|
documentationLink="https://www.windmill.dev/docs/core_concepts/authentification"
|
|
/>
|
|
|
|
<div class="flex flex-row items-center gap-2 relative">
|
|
<input placeholder="Filter members" bind:value={userFilter} class="input !pl-8" />
|
|
<Search class="absolute left-2" size={14} />
|
|
|
|
<Popup
|
|
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
|
|
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
|
|
>
|
|
<svelte:fragment slot="button">
|
|
<Button
|
|
color={auto_invite_domain != undefined ? 'green' : 'red'}
|
|
variant="border"
|
|
size="xs"
|
|
nonCaptureEvent={true}
|
|
startIcon={{ icon: Mails }}
|
|
>Auto-{showInvites && !autoAdd ? 'invite' : 'add'}: {auto_invite_domain != undefined
|
|
? 'ON'
|
|
: 'OFF'}
|
|
</Button>
|
|
</svelte:fragment>
|
|
<div class="flex flex-col items-start">
|
|
<span class="text-sm leading-6 font-semibold">
|
|
{isCloudHosted()
|
|
? `Auto-add anyone from ${
|
|
auto_invite_domain != undefined ? auto_invite_domain : domain
|
|
}`
|
|
: `Auto-add anyone joining the instance`}
|
|
</span>
|
|
|
|
{#if showInvites}
|
|
<span class="text-xs mb-1 leading-6 pt-2"
|
|
>Mode <Tooltip>Whether to invite or add users directly to the workspace.</Tooltip>
|
|
</span>
|
|
<ToggleButtonGroup
|
|
selected={autoAdd ? 'add' : 'invite'}
|
|
on:selected={async (e) => {
|
|
if (auto_invite_domain != undefined) {
|
|
await removeAllInvitesFromDomain()
|
|
await WorkspaceService.editAutoInvite({
|
|
workspace: $workspaceStore ?? '',
|
|
requestBody: {
|
|
operator: operatorOnly ?? false,
|
|
invite_all: !isCloudHosted(),
|
|
auto_add: e.detail === 'add'
|
|
}
|
|
})
|
|
loadSettings()
|
|
listInvites()
|
|
listUsers()
|
|
} else {
|
|
autoAdd = e.detail === 'add'
|
|
}
|
|
}}
|
|
>
|
|
<ToggleButton value="invite" size="xs" label="Auto-invite" />
|
|
<ToggleButton value="add" size="xs" label="Auto-add" />
|
|
</ToggleButtonGroup>
|
|
{/if}
|
|
|
|
<span class="text-xs mb-1 leading-6 pt-2"
|
|
>Role <Tooltip>Role of the auto-added users</Tooltip></span
|
|
>
|
|
<ToggleButtonGroup
|
|
selected={operatorOnly ? 'operator' : 'developer'}
|
|
on:selected={async (e) => {
|
|
if (auto_invite_domain != undefined) {
|
|
await removeAllInvitesFromDomain()
|
|
await WorkspaceService.editAutoInvite({
|
|
workspace: $workspaceStore ?? '',
|
|
requestBody: {
|
|
operator: e.detail === 'operator',
|
|
invite_all: !isCloudHosted(),
|
|
auto_add: showInvites ? autoAdd ?? false : true
|
|
}
|
|
})
|
|
loadSettings()
|
|
listInvites()
|
|
listUsers()
|
|
} else {
|
|
operatorOnly = e.detail === 'operator'
|
|
}
|
|
}}
|
|
>
|
|
<ToggleButton
|
|
value="operator"
|
|
size="xs"
|
|
label="Operator"
|
|
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
|
|
/>
|
|
<ToggleButton
|
|
value="developer"
|
|
size="xs"
|
|
label="Developer"
|
|
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
|
|
/>
|
|
</ToggleButtonGroup>
|
|
<div class="pt-2">
|
|
<Toggle
|
|
size="xs"
|
|
checked={auto_invite_domain != undefined}
|
|
on:change={async (e) => {
|
|
await removeAllInvitesFromDomain()
|
|
await WorkspaceService.editAutoInvite({
|
|
workspace: $workspaceStore ?? '',
|
|
requestBody: e.detail
|
|
? {
|
|
operator: operatorOnly ?? false,
|
|
invite_all: !isCloudHosted(),
|
|
auto_add: showInvites ? autoAdd ?? false : true
|
|
}
|
|
: { operator: undefined, auto_add: undefined }
|
|
})
|
|
loadSettings()
|
|
listInvites()
|
|
listUsers()
|
|
}}
|
|
disabled={isCloudHosted() && !allowedAutoDomain}
|
|
options={{
|
|
right: 'Enabled'
|
|
}}
|
|
/>
|
|
</div>
|
|
{#if isCloudHosted() && !allowedAutoDomain}
|
|
<div class="text-red-400 text-xs">{domain} domain not allowed for auto-add</div>
|
|
{/if}
|
|
</div>
|
|
</Popup>
|
|
<AddUser
|
|
on:new={() => {
|
|
listUsers()
|
|
listInvites()
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="">
|
|
<DataTable
|
|
shouldLoadMore={(filteredUsers?.length ?? 0) > 30}
|
|
loadMore={30}
|
|
on:loadMore={() => {
|
|
nbDisplayed += 30
|
|
}}
|
|
>
|
|
<Head>
|
|
<tr>
|
|
<Cell head first>Email</Cell>
|
|
<Cell head>Username</Cell>
|
|
|
|
<Cell head>
|
|
Executions (<abbr title="past 1w">1w</abbr>)
|
|
<Tooltip>
|
|
An execution is calculated as 1 for any runs of scripts + 1 for each seconds above the
|
|
first one
|
|
</Tooltip>
|
|
</Cell>
|
|
<Cell head>Status</Cell>
|
|
<Cell head>Role</Cell>
|
|
<Cell head last>
|
|
<span class="sr-only">Actions</span>
|
|
</Cell>
|
|
</tr>
|
|
</Head>
|
|
<tbody class="divide-y bg-surface">
|
|
{#if filteredUsers}
|
|
{#each filteredUsers.slice(0, nbDisplayed) as { email, username, is_admin, operator, disabled } (email)}
|
|
<tr class="!hover:bg-surface-hover">
|
|
<Cell first><a href="mailto:{email}">{truncate(email, 20)}</a></Cell>
|
|
<Cell>{truncate(username, 30)}</Cell>
|
|
<Cell
|
|
>{#if usage?.[email] != undefined}{usage?.[email]}{:else}<Loader2
|
|
size={14}
|
|
class="animate-spin"
|
|
/>{/if}</Cell
|
|
>
|
|
<Cell>
|
|
<div class="flex gap-1">
|
|
{#if disabled}
|
|
<Badge color="red">Disabled</Badge>
|
|
{:else}
|
|
<Badge color="green">Enabled</Badge>
|
|
{/if}
|
|
</div>
|
|
</Cell>
|
|
<Cell>
|
|
<div>
|
|
<ToggleButtonGroup
|
|
selected={is_admin ? 'admin' : operator ? 'operator' : 'developer'}
|
|
on:selected={async (e) => {
|
|
if (is_admin && email == $userStore?.email && e.detail != 'admin') {
|
|
sendUserToast(
|
|
'Admins cannot be demoted by themselves, ask another admin to demote you',
|
|
true
|
|
)
|
|
e.preventDefault()
|
|
listUsers()
|
|
return
|
|
}
|
|
const body =
|
|
e.detail == 'admin'
|
|
? { is_admin: true, operator: false }
|
|
: e.detail == 'operator'
|
|
? { is_admin: false, operator: true }
|
|
: { is_admin: false, operator: false }
|
|
await UserService.updateUser({
|
|
workspace: $workspaceStore ?? '',
|
|
username,
|
|
requestBody: body
|
|
})
|
|
listUsers()
|
|
}}
|
|
>
|
|
<ToggleButton
|
|
value="operator"
|
|
size="xs"
|
|
label="Operator"
|
|
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
|
|
/>
|
|
|
|
<ToggleButton
|
|
value="developer"
|
|
size="xs"
|
|
label="Developer"
|
|
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
|
|
/>
|
|
|
|
<ToggleButton
|
|
value="admin"
|
|
size="xs"
|
|
label="Admin"
|
|
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
|
|
/>
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
</Cell>
|
|
<Cell>
|
|
<div class="flex gap-1">
|
|
<Button
|
|
color="light"
|
|
variant="contained"
|
|
size="xs"
|
|
spacingSize="xs2"
|
|
on:click={async () => {
|
|
await UserService.updateUser({
|
|
workspace: $workspaceStore ?? '',
|
|
username,
|
|
requestBody: {
|
|
disabled: !disabled
|
|
}
|
|
})
|
|
listUsers()
|
|
}}
|
|
>
|
|
{disabled ? 'Enable' : 'Disable'}
|
|
</Button>
|
|
|
|
<Button
|
|
color="light"
|
|
variant="contained"
|
|
btnClasses="text-red-500"
|
|
size="xs"
|
|
spacingSize="xs2"
|
|
on:click={() => {
|
|
deleteConfirmedCallback = async () => {
|
|
await UserService.deleteUser({
|
|
workspace: $workspaceStore ?? '',
|
|
username
|
|
})
|
|
sendUserToast('User removed')
|
|
listUsers()
|
|
}
|
|
}}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</Cell>
|
|
</tr>
|
|
{/each}
|
|
{:else}
|
|
{#each new Array(6) as _}
|
|
<tr class="border">
|
|
<td colspan={6}>
|
|
<Skeleton layout={[[4]]} />
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
{/if}
|
|
</tbody>
|
|
</DataTable>
|
|
</div>
|
|
|
|
{#if showInvites}
|
|
<PageHeader
|
|
title="Invites ({invites.length ?? ''})"
|
|
primary={false}
|
|
tooltip="Manage invites on your workspace."
|
|
documentationLink="https://www.windmill.dev/docs/core_concepts/authentification#adding-users-to-a-workspace"
|
|
>
|
|
<div class="flex gap-2 items-center">
|
|
<InviteUser on:new={listInvites} />
|
|
</div>
|
|
</PageHeader>
|
|
|
|
<div>
|
|
<DataTable>
|
|
<Head>
|
|
<tr>
|
|
<Cell head first>Email</Cell>
|
|
<Cell head>Role</Cell>
|
|
<Cell head last><span class="sr-only">Actions</span></Cell>
|
|
</tr>
|
|
</Head>
|
|
<tbody class="divide-y bg-surface">
|
|
{#if invites?.length > 0}
|
|
{#each invites.slice(0, nbInviteDisplayed) as { email, is_admin, operator }}
|
|
<Row>
|
|
<Cell first>{email}</Cell>
|
|
<Cell>
|
|
<div>
|
|
<ToggleButtonGroup
|
|
selected={is_admin ? 'admin' : operator ? 'operator' : 'developer'}
|
|
on:selected={async (e) => {
|
|
const body =
|
|
e.detail == 'admin'
|
|
? { is_admin: true, operator: false }
|
|
: e.detail == 'operator'
|
|
? { is_admin: false, operator: true }
|
|
: { is_admin: false, operator: false }
|
|
await WorkspaceService.inviteUser({
|
|
workspace: $workspaceStore ?? '',
|
|
requestBody: {
|
|
email,
|
|
...body
|
|
}
|
|
})
|
|
listUsers()
|
|
}}
|
|
>
|
|
<ToggleButton
|
|
value="operator"
|
|
size="xs"
|
|
label="Operator"
|
|
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
|
|
/>
|
|
|
|
<ToggleButton
|
|
value="developer"
|
|
size="xs"
|
|
label="Developer"
|
|
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
|
|
/>
|
|
|
|
<ToggleButton
|
|
value="admin"
|
|
size="xs"
|
|
label="Admin"
|
|
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
|
|
/>
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
</Cell>
|
|
<Cell last>
|
|
<button
|
|
class="ml-2 text-red-500"
|
|
on:click={async () => {
|
|
await WorkspaceService.deleteInvite({
|
|
workspace: $workspaceStore ?? '',
|
|
requestBody: {
|
|
email,
|
|
is_admin,
|
|
operator
|
|
}
|
|
})
|
|
listInvites()
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</Cell>
|
|
</Row>
|
|
{/each}
|
|
{:else}
|
|
<tr>
|
|
<td colspan="3" class="text-center py-8">
|
|
<div class="text-xs text-secondary"> No invites yet </div>
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
</tbody>
|
|
</DataTable>
|
|
{#if invites && invites?.length > 50 && nbInviteDisplayed < invites.length}
|
|
<span class="text-xs"
|
|
>{nbInviteDisplayed} invites out of {invites.length}
|
|
<button class="ml-4" on:click={() => (nbInviteDisplayed += 50)}>load 50 more</button></span
|
|
>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<ConfirmationModal
|
|
open={Boolean(deleteConfirmedCallback)}
|
|
title="Remove user"
|
|
confirmationText="Remove"
|
|
on:canceled={() => {
|
|
deleteConfirmedCallback = undefined
|
|
}}
|
|
on:confirmed={() => {
|
|
if (deleteConfirmedCallback) {
|
|
deleteConfirmedCallback()
|
|
}
|
|
deleteConfirmedCallback = undefined
|
|
}}
|
|
>
|
|
<div class="flex flex-col w-full space-y-4">
|
|
<span>Are you sure you want to remove ?</span>
|
|
</div>
|
|
</ConfirmationModal>
|