Files
windmill/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte
T
hugocasaandClaude Opus 5 9d335de87a feat: add a workspace toggle that adds its admins and developers to new forks (#11215)
* feat: add a workspace toggle that adds its admins and developers to new forks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: add members copied into a fork as manual members, not instance-group ones

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: keep the fork members copy comment at the query and drop the raw spacer

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:15:35 +02:00

1350 lines
44 KiB
Svelte

<script lang="ts">
import AddUser from '$lib/components/AddUser.svelte'
import { Alert, Badge, Button, Section, Skeleton } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import WorkspaceOperatorSettings from '$lib/components/settings/WorkspaceOperatorSettings.svelte'
import InviteUser from '$lib/components/InviteUser.svelte'
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.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, GroupService, type WorkspaceInvite } from '$lib/gen'
import {
userStore,
workspaceStore,
superadmin,
globalEmailInvite,
enterpriseLicense,
workspaceMembershipVersion
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Loader2, Mails, Search, Plus, UserMinus, X, Bot, LogIn } from 'lucide-svelte'
import Select from '$lib/components/select/Select.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 UserOffboardingModal from '$lib/components/UserOffboardingModal.svelte'
import { isCloudHosted } from '$lib/cloud'
import { truncate } from '$lib/utils'
import { onDestroy, untrack } from 'svelte'
import { goto } from '$lib/navigation'
import { ArrowRightLeft } from 'lucide-svelte'
let offboardingUser: string | undefined = $state(undefined)
let offboardingReassignOnly = $state(false)
let users: User[] | undefined = $state(undefined)
let invites: WorkspaceInvite[] = $state([])
let filteredUsers: User[] | undefined = $state(undefined)
let userFilter = $state('')
let auto_invite_domain: string | undefined = $state()
let operatorOnly: boolean | undefined = $state(undefined)
let autoAdd: boolean | undefined = $state(false)
let addAdminsAndDevelopersToForks = $state(false)
let nbDisplayed = $state(30)
// Instance group auto-add settings
let instanceGroups: Array<{ name: string; summary?: string; emails?: string[] }> = $state([])
let autoAddInstanceGroups: string[] = $state([])
let autoAddInstanceGroupsRoles: Record<string, string> = $state({})
// Add new instance group form state
let selectedNewInstanceGroup: string | undefined = $state(undefined)
let selectedNewRole: string | undefined = $state('developer')
// Service account creation
// Available groups for dropdowns - filter out already configured groups
let availableGroupItems = $derived(
instanceGroups
.filter((group) => !autoAddInstanceGroups.includes(group.name))
.map((group) => ({
value: group.name,
label: group.name + (group.summary ? ` - ${group.summary}` : '')
}))
)
// Sort users so manual users come first, then instance group users
let sortedUsers = $derived(() => {
const userList = (filteredUsers || users || []).slice()
return userList.sort((a: User, b: User) => {
const aIsInstanceGroup = a.added_via?.source === 'instance_group' ? 1 : 0
const bIsInstanceGroup = b.added_via?.source === 'instance_group' ? 1 : 0
return aIsInstanceGroup - bIsInstanceGroup
})
})
let hasNonManualUsers = $derived(
(filteredUsers || users || []).some(
(user: User) =>
user.added_via?.source === 'instance_group' || user.added_via?.source === 'domain'
)
)
// Function to check if a manual user can be converted to a group user
function canConvertToGroup(user: User): boolean {
// User must be manually added (not via instance group or domain)
if (user.added_via?.source === 'instance_group' || user.added_via?.source === 'domain') {
return false
}
// Check if user's email is in any configured instance group
const userEmail = user.email
for (const groupName of autoAddInstanceGroups) {
const group = instanceGroups.find((g) => g.name === groupName)
if (group && group.emails && group.emails.includes(userEmail)) {
return true
}
}
return false
}
async function loadSettings(): Promise<void> {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
const autoInvite = settings.auto_invite as
| {
enabled?: boolean
domain?: string
operator?: boolean
mode?: string
instance_groups?: string[]
instance_groups_roles?: Record<string, string>
}
| undefined
auto_invite_domain = autoInvite?.enabled ? (autoInvite?.domain ?? '*') : undefined
operatorOnly = autoInvite?.operator ?? false
autoAdd = autoInvite?.mode === 'add'
autoAddInstanceGroups = autoInvite?.instance_groups || []
autoAddInstanceGroupsRoles = autoInvite?.instance_groups_roles || {}
addAdminsAndDevelopersToForks = settings.add_admins_and_developers_to_forks ?? false
}
async function updateAddAdminsAndDevelopersToForks(enabled: boolean): Promise<void> {
try {
await WorkspaceService.editAddAdminsAndDevelopersToForks({
workspace: $workspaceStore!,
requestBody: { add_admins_and_developers_to_forks: enabled }
})
sendUserToast(
enabled
? 'New forks will start with the admins and developers of this workspace'
: 'New forks will start with their creator only'
)
} catch (e) {
console.error('Failed to update the fork members setting:', e)
addAdminsAndDevelopersToForks = !enabled
sendUserToast(`Failed to update the fork members setting: ${e}`, true)
}
}
let getUsagePromise: CancelablePromise<UserUsage[]> | undefined = undefined
let usage: Record<string, number> | undefined = $state(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> {
// Mounting, switching workspace (the switch only rewrites `?workspace=`, so this
// component survives) and every membership mutation all refetch through here.
// Bump only when the same workspace's member set is seen to change; anything else
// re-baselines silently, or consumers re-fetch for a list nothing changed about.
const workspace = $workspaceStore!
const seq = ++usersIssued
const previous = lastSeen?.workspace === workspace ? lastSeen.signature : undefined
const list = await UserService.listUsers({ workspace })
// A response a newer read has already overtaken describes a member set that is no
// longer on screen: baselining on it makes the next real change look like none.
if (lastSeen && seq < lastSeen.seq) return
users = list
const signature = membershipSignature(list)
lastSeen = { workspace, signature, seq }
if (previous !== undefined && previous !== signature) {
$workspaceMembershipVersion++
}
}
// The last member set applied, which workspace it belonged to, and the read it came from.
let lastSeen: { workspace: string; signature: string; seq: number } | undefined = undefined
let usersIssued = 0
// Every field a paid seat count depends on — the developer/operator split, and the
// two categories billing excludes.
function membershipSignature(list: User[] | undefined): string {
return (list ?? [])
.map((u) => `${u.email}:${u.operator}:${u.disabled}:${u.is_service_account}`)
.sort()
.join(',')
}
async function listInvites(): Promise<void> {
invites = await WorkspaceService.listPendingInvites({ workspace: $workspaceStore! })
}
let allowedAutoDomain = $state(false)
async function getDisallowedAutoDomain() {
allowedAutoDomain = await WorkspaceService.isDomainAllowed()
}
async function loadInstanceGroups(): Promise<void> {
try {
instanceGroups = await GroupService.listInstanceGroups()
} catch (e) {
console.warn('Failed to load instance groups:', e)
instanceGroups = []
}
}
async function saveInstanceGroupSettings(): Promise<void> {
try {
await WorkspaceService.editInstanceGroups({
workspace: $workspaceStore ?? '',
requestBody: {
groups: autoAddInstanceGroups,
roles: autoAddInstanceGroupsRoles
}
})
sendUserToast('Instance group settings saved')
// Refresh user list to show newly auto-added users
listUsers()
} catch (e) {
console.error('Failed to save instance group settings:', e)
sendUserToast('Failed to save settings', true)
}
}
async function addInstanceGroup(): Promise<void> {
if (!selectedNewInstanceGroup || !selectedNewRole) return
const groupToAdd = selectedNewInstanceGroup
const roleToAdd = selectedNewRole
try {
autoAddInstanceGroups = [...autoAddInstanceGroups, groupToAdd]
autoAddInstanceGroupsRoles[groupToAdd] = roleToAdd
// Reset form
selectedNewInstanceGroup = undefined
selectedNewRole = 'developer'
await saveInstanceGroupSettings()
} catch (e) {
// Rollback on error
autoAddInstanceGroups = autoAddInstanceGroups.filter((g) => g !== groupToAdd)
delete autoAddInstanceGroupsRoles[groupToAdd]
sendUserToast('Failed to add instance group', true)
}
}
async function removeInstanceGroup(groupName: string): Promise<void> {
const previousGroups = [...autoAddInstanceGroups]
const previousRole = autoAddInstanceGroupsRoles[groupName]
try {
autoAddInstanceGroups = autoAddInstanceGroups.filter((g) => g !== groupName)
delete autoAddInstanceGroupsRoles[groupName]
await saveInstanceGroupSettings()
} catch (e) {
// Rollback on error
autoAddInstanceGroups = previousGroups
if (previousRole) {
autoAddInstanceGroupsRoles[groupName] = previousRole
}
sendUserToast('Failed to remove instance group', true)
}
}
async function updateGroupRole(groupName: string, role: string): Promise<void> {
const previousRole = autoAddInstanceGroupsRoles[groupName]
try {
autoAddInstanceGroupsRoles[groupName] = role
await saveInstanceGroupSettings()
} catch (e) {
// Rollback on error
autoAddInstanceGroupsRoles[groupName] = previousRole
sendUserToast('Failed to update role', true)
}
}
async function convertUserToGroup(username: string): Promise<void> {
try {
await UserService.convertUserToGroup({
workspace: $workspaceStore ?? '',
username
})
sendUserToast('User converted to group user')
listUsers()
} catch (e) {
console.error('Failed to convert user:', e)
sendUserToast('Failed to convert user', true)
}
}
let domain = $derived($userStore?.email.split('@')[1])
$effect(() => {
if ($workspaceStore) {
untrack(() => {
getDisallowedAutoDomain()
listUsers()
getUsage()
listInvites()
loadSettings()
loadInstanceGroups()
})
}
})
onDestroy(() => {
try {
getUsagePromise?.cancel()
} catch (e) {
console.warn(e)
}
})
let removeInstanceGroupConfirmedCallback: (() => void) | undefined = $state(undefined)
let convertConfirmedCallback: (() => void) | undefined = $state(undefined)
// Auto-add/invite confirmation modal states
let autoAddConfirmCallback: (() => void) | undefined = $state(undefined)
let autoInviteDisableConfirmCallback: (() => void) | undefined = $state(undefined)
let switchToAutoAddConfirmCallback: (() => void) | undefined = $state(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 = $state(50)
async function inviteUser(email: string, selected: 'operator' | 'developer' | 'admin') {
try {
await WorkspaceService.inviteUser({
workspace: $workspaceStore!,
requestBody: {
email,
is_admin: selected == 'admin',
operator: selected == 'operator'
}
})
sendUserToast(`Invited ${email}`)
} catch (e) {
console.error('Failed to invite user:', e)
sendUserToast('Failed to invite user', true)
}
if (!(await UserService.existsEmail({ email }))) {
let isSuperadmin = $superadmin
if (!isCloudHosted()) {
sendUserToast(
`User ${email} is not registered yet on the instance. ${
!isSuperadmin
? `If not using SSO, ask an administrator to add ${email} to the instance`
: ''
}`,
true,
isSuperadmin
? [
{
label: 'Add user to the instance',
callback: () => {
$globalEmailInvite = email
goto('#superadmin-settings')
}
}
]
: []
)
}
}
listInvites()
}
async function updateAutoInvite(enable: boolean) {
// Cleanup invites if auto add is enabled
if (enable && autoAdd) {
await removeAllInvitesFromDomain()
}
const updateType = enable ? (autoInviteOrAddEnabled ? 'update' : 'enable') : 'disable'
try {
// await removeAllInvitesFromDomain()
await WorkspaceService.editAutoInvite({
workspace: $workspaceStore ?? '',
requestBody: enable
? {
operator: operatorOnly ?? false,
invite_all: !isCloudHosted(),
auto_add: autoAdd
}
: {
operator: undefined,
auto_add: undefined
}
})
const message =
updateType === 'update'
? `Auto-${autoAdd ? 'add' : 'invite'} updated`
: updateType === 'enable'
? `Auto-${autoAdd ? 'add' : 'invite'} enabled`
: `Auto-${autoAdd ? 'add' : 'invite'} disabled`
sendUserToast(message)
} catch (e) {
console.error('Failed to update auto invite:', e)
sendUserToast('Failed to update auto invite', true)
}
loadSettings()
listInvites()
listUsers()
}
const autoInviteOrAddEnabled = $derived(auto_invite_domain != undefined)
// Legacy auto-invite: user already has auto-invite enabled (not auto-add) on a non-cloud instance
// This preserves their existing setup even though auto-invite is deprecated for new setups
const isLegacyAutoInvite = $derived(autoInviteOrAddEnabled && !autoAdd && !isCloudHosted())
// Show auto-invite toggle only for:
// - Cloud hosted users (always available)
// - Legacy users who already have auto-invite enabled (preserve existing setup)
const showAutoInviteToggle = $derived(isCloudHosted() || isLegacyAutoInvite)
// Display mode for labels: for non-cloud, non-legacy users, always show "add"
// For cloud or legacy users, show based on actual autoAdd setting
const displayMode = $derived.by(() => {
if (!isCloudHosted() && !isLegacyAutoInvite) {
return 'add'
}
return autoAdd ? 'add' : 'invite'
})
const isAdminsWorkspaceWithoutEE = $derived($workspaceStore === 'admins' && !$enterpriseLicense)
</script>
<SearchItems
filter={userFilter}
items={users}
bind:filteredItems={filteredUsers}
f={(x) => x.email + ' ' + x.name + ' ' + x.company}
/>
<SettingsPageHeader
title="Members {(filteredUsers?.length ?? users?.length) != undefined
? `(${filteredUsers?.length ?? users?.length})`
: ''}"
description="Add members to your workspace and manage their roles. You can also auto-add users to join your workspace."
link="https://www.windmill.dev/docs/core_concepts/roles_and_permissions"
/>
{#if isAdminsWorkspaceWithoutEE}
<Alert type="info" title="Admins workspace">
The admins workspace is reserved for superadmins. Only users with superadmin privileges can
access it. Members cannot be manually added or invited to this workspace.
</Alert>
{/if}
<Section>
{#snippet action()}
<div class="flex flex-row items-center gap-2 relative whitespace-nowrap w-full">
<input placeholder="Filter members" bind:value={userFilter} class="input !pl-8 !w-56" />
<Search class="absolute left-2" size={14} />
{#if !isAdminsWorkspaceWithoutEE}
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
usePointerDownOutside
>
{#snippet trigger()}
<Button
variant="default"
unifiedSize="md"
nonCaptureEvent={true}
startIcon={{ icon: Mails }}
>Auto-{displayMode}: {autoInviteOrAddEnabled ? 'ON' : 'OFF'}
</Button>
{/snippet}
{#snippet content()}
<div class="flex flex-col items-start p-4 min-w-[320px] max-w-sm">
{#if showAutoInviteToggle}
<div class="text-xs mb-1 text-primary"
>Mode <Tooltip>Whether to invite or add users directly to the workspace.</Tooltip>
</div>
<ToggleButtonGroup
selected={displayMode}
on:selected={async (e) => {
const switchingToAdd = e.detail === 'add' && !autoAdd
// If switching from invite to add on non-cloud, show confirmation with warning
if (switchingToAdd && isLegacyAutoInvite) {
switchToAutoAddConfirmCallback = async () => {
autoAdd = true
if (autoInviteOrAddEnabled) {
await updateAutoInvite(true)
}
}
} else {
autoAdd = e.detail === 'add'
if (autoInviteOrAddEnabled) {
await updateAutoInvite(true)
}
}
}}
>
{#snippet children({ item })}
<ToggleButton value="invite" small label="Auto-invite" {item} />
<ToggleButton value="add" small label="Auto-add" {item} />
{/snippet}
</ToggleButtonGroup>
{#if isLegacyAutoInvite && !autoAdd}
<div class="mt-3 w-full">
<Alert type="warning" size="xs" title="Legacy mode">
Auto-invite is deprecated. Switching to auto-add will permanently disable
auto-invite for this workspace.
</Alert>
</div>
{/if}
<div class="mt-6"></div>
{/if}
<span class="text-xs mb-1">Role <Tooltip>Role of the auto-added users</Tooltip></span>
<ToggleButtonGroup
selected={operatorOnly ? 'operator' : 'developer'}
on:selected={async (e) => {
operatorOnly = e.detail === 'operator'
if (auto_invite_domain != undefined) {
await updateAutoInvite(true)
}
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
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)."
{item}
/>
{/snippet}
</ToggleButtonGroup>
<div class="mt-6">
<Toggle
checked={autoInviteOrAddEnabled}
on:change={async (e) => {
const enabling = e.detail
if (enabling) {
// Non-cloud users without legacy auto-invite: force auto-add mode
if (!isCloudHosted() && !isLegacyAutoInvite) {
autoAdd = true
}
// Show confirmation when enabling auto-add
if (autoAdd || (!isCloudHosted() && !showAutoInviteToggle)) {
autoAddConfirmCallback = async () => {
await updateAutoInvite(true)
}
} else {
await updateAutoInvite(true)
}
} else {
// Disabling: show confirmation if currently using auto-invite (legacy)
if (isLegacyAutoInvite) {
autoInviteDisableConfirmCallback = async () => {
await updateAutoInvite(false)
}
} else {
await updateAutoInvite(false)
}
}
}}
disabled={isCloudHosted() && !allowedAutoDomain}
options={{
right: isCloudHosted()
? `Auto-${displayMode} anyone from ${
autoInviteOrAddEnabled ? auto_invite_domain : domain
}`
: `Auto-${displayMode} anyone joining the instance`
}}
/>
</div>
{#if isCloudHosted() && !allowedAutoDomain}
<div class="text-red-400 text-xs">{domain} domain not allowed for auto-add</div>
{/if}
</div>
{/snippet}
</Popover>
{#if instanceGroups.length > 0}
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
usePointerDownOutside
>
{#snippet trigger()}
<Button
color={autoAddInstanceGroups.length > 0 ? 'green' : 'gray'}
variant="border"
size="xs"
nonCaptureEvent={true}
startIcon={{ icon: Mails }}
>Instance groups: {autoAddInstanceGroups.length}
</Button>
{/snippet}
{#snippet content()}
<div class="flex flex-col p-4 min-w-[500px]">
<div class="flex flex-col gap-4">
<span class="text-sm leading-6 font-semibold"> Auto-add instance groups </span>
<!-- Add new instance group form -->
{#if availableGroupItems.length > 0}
<div class="flex w-full mt-1 gap-2 items-end justify-between">
<div class="flex gap-2 items-end">
<div class="flex flex-col gap-1">
<span class="text-xs text-primary">Instance group</span>
<Select
items={availableGroupItems}
placeholder="Select group"
bind:value={selectedNewInstanceGroup}
class="max-w-[160px]"
disablePortal={true}
/>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-primary">Role</span>
<ToggleButtonGroup
selected={selectedNewRole}
on:selected={(e) => {
selectedNewRole = e.detail
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
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)."
{item}
/>
<ToggleButton
value="admin"
small
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."
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
</div>
<Button
color="blue"
size="xs"
startIcon={{ icon: Plus }}
disabled={!selectedNewInstanceGroup || !selectedNewRole}
onclick={addInstanceGroup}
>
Add
</Button>
</div>
{/if}
<!-- Configured groups table -->
{#if autoAddInstanceGroups.length > 0}
<div class="flex flex-col gap-2">
<p class="text-sm font-medium text-secondary">Configured groups:</p>
<div class="flex flex-col gap-1">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-primary">
<th class="pb-2 w-1/2">Group</th>
<th class="pb-2 w-1/4">Role</th>
<th class="pb-2 w-1/4"></th>
</tr>
</thead>
<tbody>
{#each autoAddInstanceGroups as groupName (groupName)}
{@const group = instanceGroups.find((g) => g.name === groupName)}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">
<div class="font-medium">{groupName}</div>
{#if group?.summary}
<div class="text-xs text-primary">{group.summary}</div>
{/if}
</td>
<td class="py-2">
<div>
<ToggleButtonGroup
selected={autoAddInstanceGroupsRoles[groupName] ||
'developer'}
on:selected={async (e) => {
autoAddInstanceGroupsRoles[groupName] = e.detail
await updateGroupRole(groupName, e.detail)
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
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)."
{item}
/>
<ToggleButton
value="admin"
small
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."
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
</td>
<td class="py-2">
<div class="flex justify-end">
<Button
color="light"
variant="contained"
btnClasses="text-red-500"
size="xs"
spacingSize="xs2"
onclick={() => {
removeInstanceGroupConfirmedCallback = async () => {
await removeInstanceGroup(groupName)
}
}}
>
Remove
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="text-center text-primary text-sm py-4">
No instance groups configured for auto-add
</div>
{/if}
</div>
</div>
{/snippet}
</Popover>
{/if}
{#if showAutoInviteToggle}
<InviteUser {inviteUser} />
{/if}
<AddUser
on:new={() => {
listUsers()
listInvites()
}}
/>
{/if}
</div>
{/snippet}
<DataTable
shouldLoadMore={(filteredUsers?.length ?? 0) > 30}
loadMore={30}
on:loadMore={() => {
nbDisplayed += 30
}}
>
<Head>
<tr>
<Cell head first>Email</Cell>
<Cell head>Username</Cell>
{#if hasNonManualUsers}
<Cell head>
Added via
<Tooltip>
Shows how the user was added to the workspace: manually, via domain auto-invite, or
through an instance group.
</Tooltip>
</Cell>
{/if}
<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>Role</Cell>
<Cell head>Enabled</Cell>
<Cell head last actions>
<span class="sr-only">Actions</span>
</Cell>
</tr>
</Head>
<tbody>
{#if filteredUsers}
{#each sortedUsers().slice(0, nbDisplayed) as user, index (user.email)}
{@const { email, username, is_admin, operator, disabled, added_via } = user}
<!-- Add separator between manual users and instance group users -->
{#if hasNonManualUsers && index > 0 && sortedUsers()[index - 1]?.added_via?.source !== 'instance_group' && added_via?.source === 'instance_group'}
<tr class="bg-surface-secondary">
<td colspan={hasNonManualUsers ? 8 : 7} class="px-4 py-2">
<div class="text-xs text-emphasis font-semibold"> Instance group users </div>
</td>
</tr>
{/if}
<tr class={index % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'}>
<Cell first>
{#if user.is_service_account}
<span class="flex items-center gap-1.5 max-w-[150px]" title={email}>
<Bot size={16} class="text-blue-500 shrink-0" />
<span class="truncate">{email}</span>
</span>
{:else}
<a href="mailto:{email}" class="block truncate max-w-[150px]" title={email}
>{email}</a
>
{/if}
</Cell>
<Cell
><span class="block truncate max-w-[120px]" title={username}>{username}</span></Cell
>
{#if hasNonManualUsers}
<Cell>
<div class="flex items-center gap-2">
{#if added_via?.source === 'instance_group'}
<Badge color="blue">Group</Badge>
<span>{truncate(added_via.group || 'Unknown', 20)}</span>
{:else if added_via?.source === 'domain'}
<Badge color="blue">Auto-add</Badge>
{:else}
<Badge color="blue">Manual</Badge>
{/if}
</div>
</Cell>
{/if}
<Cell
>{#if usage != undefined}{usage[email] ?? 0}{:else}<Loader2
size={14}
class="animate-spin"
/>{/if}</Cell
>
<Cell>
<div>
{#if added_via?.source === 'instance_group'}
<div class="flex items-center gap-1">
<span class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold">
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'}
</span>
<Tooltip>Role is managed through instance group configuration above.</Tooltip>
</div>
{:else}
<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()
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
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)."
{item}
/>
<ToggleButton
value="admin"
small
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."
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
</div>
</Cell>
<Cell>
<Toggle
checked={!disabled}
on:change={async (e) => {
try {
await UserService.updateUser({
workspace: $workspaceStore ?? '',
username,
requestBody: {
disabled: !disabled
}
})
sendUserToast(`User ${username} ${disabled ? 'enabled' : 'disabled'}`)
listUsers()
} catch (e) {
console.error('Failed to update user status:', e)
sendUserToast('Failed to update user status', true)
}
}}
size="xs"
/>
</Cell>
<Cell last actions class={index % 2 === 0 ? 'bg-surface-tertiary' : ''}>
<div class="flex gap-1">
{#if user.is_service_account && $userStore?.is_admin}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: LogIn }}
disabled={!$enterpriseLicense}
title={!$enterpriseLicense ? 'Requires Enterprise Edition' : undefined}
onClick={async () => {
try {
// Backend sets the impersonation cookie and returns the old token
const oldToken = await UserService.impersonateServiceAccount({
workspace: $workspaceStore ?? '',
requestBody: { username }
})
if (oldToken) {
sessionStorage.setItem('pre_impersonation_token', oldToken)
sessionStorage.setItem('pre_impersonation_email', $userStore?.email ?? '')
}
window.location.href = '/'
} catch (e) {
sendUserToast('Failed to impersonate service account', true)
}
}}
>
Impersonate
</Button>
{/if}
{#snippet removeUserButton(disabled: boolean)}
<Button
unifiedSize="sm"
variant="subtle"
destructive
{disabled}
onClick={() => {
offboardingUser = username
offboardingReassignOnly = false
}}
startIcon={{ icon: UserMinus }}
>
Remove
</Button>
{/snippet}
{#if added_via?.source === 'instance_group'}
<div class="flex items-center gap-1">
{@render removeUserButton(true)}
<Tooltip
>Cannot remove users synced from instance groups. Either disable the user or
remove them from the SCIM group.</Tooltip
>
</div>
{:else if canConvertToGroup(user)}
<Button
variant="accent"
unifiedSize="sm"
on:click={() => {
convertConfirmedCallback = async () => {
await convertUserToGroup(username)
}
}}
>
Convert
</Button>
{:else}
<div class="flex items-center gap-1">
<Button
unifiedSize="sm"
variant="subtle"
onClick={() => {
offboardingUser = username
offboardingReassignOnly = true
}}
startIcon={{ icon: ArrowRightLeft }}
>
Reassign
</Button>
{@render removeUserButton(false)}
</div>
{/if}
</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>
</Section>
<div class="pt-12"></div>
<WorkspaceOperatorSettings />
<div class="pt-12"></div>
<Section
label="Fork members"
description="Choose who a new fork of this workspace starts with, besides the person who creates it."
wrapperClass="pb-12"
>
<Toggle
bind:checked={addAdminsAndDevelopersToForks}
on:change={(e) => updateAddAdminsAndDevelopersToForks(e.detail)}
options={{
right: 'Add admins and developers to new forks',
rightTooltip:
'Admins and developers of this workspace join every new fork with the role they have here, so they can follow and review the work done in it. Forks of those forks follow the same setting.'
}}
/>
</Section>
{#if invites?.length > 0}
<Section
label="Invites ({invites.length ?? ''})"
tooltip="Manage invites on your workspace."
documentationLink="https://www.windmill.dev/docs/core_concepts/authentification#adding-users-to-a-workspace"
>
{#snippet action()}
{#if showAutoInviteToggle && !isAdminsWorkspaceWithoutEE}
<div class="flex gap-2 items-center">
<InviteUser {inviteUser} />
</div>
{/if}
{/snippet}
<DataTable>
<Head>
<tr>
<Cell head first>Email</Cell>
<Cell head>Role</Cell>
<Cell head last actions><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()
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
small
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
small
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)."
{item}
/>
<ToggleButton
value="admin"
small
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."
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
</Cell>
<Cell last actions>
<Button
variant="default"
destructive
unifiedSize="sm"
startIcon={{ icon: X }}
btnClasses="w-fit"
onClick={async () => {
await WorkspaceService.deleteInvite({
workspace: $workspaceStore ?? '',
requestBody: {
email,
is_admin,
operator
}
})
listInvites()
}}
>
Cancel invite
</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" onclick={() => (nbInviteDisplayed += 50)}>load 50 more</button></span
>
{/if}
</Section>
{/if}
{#if offboardingUser}
<UserOffboardingModal
open={offboardingUser != null}
username={offboardingUser}
reassignOnly={offboardingReassignOnly}
onClose={() => {
offboardingUser = undefined
}}
onComplete={() => {
offboardingUser = undefined
listUsers()
}}
/>
{/if}
<div class="[&>div]:!z-[5002]">
<ConfirmationModal
open={Boolean(removeInstanceGroupConfirmedCallback)}
title="Remove instance group"
confirmationText="Remove"
on:canceled={() => {
removeInstanceGroupConfirmedCallback = undefined
}}
on:confirmed={() => {
if (removeInstanceGroupConfirmedCallback) {
removeInstanceGroupConfirmedCallback()
}
removeInstanceGroupConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span
>Are you sure you want to remove this instance group from auto-add? This will not remove
users already added from this group.</span
>
</div>
</ConfirmationModal>
</div>
<ConfirmationModal
open={Boolean(convertConfirmedCallback)}
title="Convert to Group User"
confirmationText="Convert"
on:canceled={() => {
convertConfirmedCallback = undefined
}}
on:confirmed={() => {
if (convertConfirmedCallback) {
convertConfirmedCallback()
}
convertConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to convert this user to a group user?</span>
<span class="text-sm text-secondary">This will:</span>
<ul class="text-sm text-secondary list-disc ml-4 space-y-1">
<li>Change the user's role based on their instance group configuration</li>
<li>Make their role managed through the instance group settings</li>
<li>Prevent manual role changes for this user</li>
</ul>
</div>
</ConfirmationModal>
<!-- Auto-add/invite confirmation modals - z-index to appear above popover -->
<div class="[&>div]:!z-[5002]">
<ConfirmationModal
open={Boolean(autoAddConfirmCallback)}
title="Enable Auto-add"
confirmationText="Enable"
on:canceled={() => {
autoAddConfirmCallback = undefined
}}
on:confirmed={() => {
if (autoAddConfirmCallback) {
autoAddConfirmCallback()
}
autoAddConfirmCallback = undefined
}}
>
Are you sure you want to enable auto-add?<br />
Anyone added to the instance will automatically join this workspace.
</ConfirmationModal>
</div>
<div class="[&>div]:!z-[5002]">
<ConfirmationModal
open={Boolean(autoInviteDisableConfirmCallback)}
title="Disable Auto-invite"
confirmationText="Disable"
on:canceled={() => {
autoInviteDisableConfirmCallback = undefined
}}
on:confirmed={() => {
if (autoInviteDisableConfirmCallback) {
autoInviteDisableConfirmCallback()
}
autoInviteDisableConfirmCallback = undefined
}}
>
Are you sure you want to disable auto-invite? Auto-invite is a legacy feature. After disabling,
it will no longer be available for this workspace. You will only be able to use auto-add. <br />
Anyone added to the instance will automatically join this workspace.
</ConfirmationModal>
</div>
<div class="[&>div]:!z-[5002]">
<ConfirmationModal
open={Boolean(switchToAutoAddConfirmCallback)}
title="Switch to Auto-add"
confirmationText="Switch"
on:canceled={() => {
switchToAutoAddConfirmCallback = undefined
}}
on:confirmed={() => {
if (switchToAutoAddConfirmCallback) {
switchToAutoAddConfirmCallback()
}
switchToAutoAddConfirmCallback = undefined
}}
>
Are you sure you want to switch from auto-invite to auto-add?<br />
Auto-invite is a legacy feature. After switching to auto-add, auto-invite will no longer be available
for this workspace. <br />
With auto-add, anyone added to the instance will automatically join this workspace without needing
to accept an invitation.
</ConfirmationModal>
</div>