Files
windmill/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte
T
e866b68cdf feat: surface execution usage in the sidebar and explain what an execution is (#10760)
* feat: surface execution usage in the sidebar and explain what an execution is

Users read "executions" as a job count and are surprised by the real number,
which meters a second of compute. Every place the UI prints an execution count
now says so, and the sidebar carries a usage meter for the quota that will bind
first.

Adds SidebarUsage at the bottom of both sidebar surfaces: a ring in the
collapsed rail, a labelled bar when expanded, and a modal breaking down every
quota. On the free tier it meters the per-user and per-workspace 1000-execution
caps; on a paid plan it meters workspace usage against the executions the
workspace's seats already include.

Item.tooltip was inert on disabled dropdown rows: DropdownSubmenuItem rendered
the info icon inside the disabled button, which swallows hover, and the row's
own title attribute shadowed any wrapper title. Both renderers now fall back to
a wrapper title the way DropdownV2Inner already intended.

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

* fix: keep the usage meter tied to the workspace it describes

isPremiumStore held the previous workspace's tier across a switch, which no
consumer noticed while it only gated affordances — the usage meter is the first
surface to render a number from it, and would have shown a paid seat quota for a
free workspace. It is now undefined until the active workspace's tier is known,
and a superseded response no longer writes.

The seat fetch had the same shape: a slow response for the workspace we left
overwrote the current count and stayed wrong until the next switch.

The usage wrapper also carried the padding the brand-mark row used to own, which
shifted the sidebar bottom by 4px on every instance where the meter renders
nothing. The component owns its own padding instead.

Names the collapsed ring for assistive tech, which otherwise saw an unlabelled
button whose only signal was the arc's color.

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

* fix: scope the usage meter to the billing workspace and a known tier

A fork's usage, tier and bill all resolve to its billing root, but its member
list is deliberately a subset of the root's, so counting fork members metered
root usage against a fork-sized cap and invented billed-seat overages. Seats now
come from the billing root, and the paid meter stays hidden when that root is
not visible from the fork.

The tier was cleared only after the user-store round-trip, so the meter rendered
the previous workspace's tier for the length of it — a free→paid switch showed
the 1000-execution hard cap on a paid workspace, not a race but every time. The
clear now happens before the first await.

Workspace usage had neither guard: a superseded response overwrote the store
permanently, and the meter is the first surface to print that number as its
headline rather than bury it in a dropdown.

The free-tier counters keyed off `!$isPremiumStore`, which reads an unknown tier
as free and flashed the free-tier blocks during a paid-to-paid switch. They wait
for a known tier instead.

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

* fix: never render an unresolved execution count as zero

The workspace-usage clear wrote 0, which is a real usage value: an in-flight or
failed fetch rendered as a green "0/1,000" bar, and a rejection left it there for
the session because loadUsage had no failure path. Usage is now undefined until
it resolves, each endpoint is assigned on its own so one failing leaves the
other's number intact, and a quota is listed only once its own usage, tier and
cap are known. The legacy counters show an em dash rather than a fabricated 0.

The fork gates read an unknown tier as not-premium, so clearing the tier on
switch made the fork entry point disappear for the length of the fetch on a
paid-to-paid switch. They hold while the tier is unknown.

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

* fix: read the usage endpoints as numbers, and fall back to the free tier

Both usage endpoints serve text/plain, so the client hands back a string despite
the generated `number` type. Interpolation and arithmetic coerced it, which is
why nothing noticed before, but `toLocaleString` on a string returns it
unchanged — a five-figure count rendered without its thousands separator against
a formatted cap.

A failed tier fetch left the tier unknown for the session, and consumers hold
premium-only affordances through the unknown window so a free workspace kept
offering them. It falls back to the free tier instead.

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

* fix: keep an unknown tier unknown, and refresh the seat cap on demand

Falling back to the free tier on a failed tier fetch fixed the affordance gates
by lying to the meter: a paid workspace's real five-figure usage rendered
against the 1000 hard cap, red, under "jobs stop running for the rest of the
month". The tier stays unknown instead, and the two consumers get what each
needs — the meter hides, while affordances read `maybePremium`, which holds
through the pending window but fails closed once the fetch has failed.

Membership changes elsewhere don't reach this component, so the seat cap could
show an overage against a cap that had since grown. It re-resolves when the
modal opens, which is when the number is read rather than glanced at.

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

* fix: let anything showing executions re-read them

The counters were written in one place, the root layout, on a workspace change
only — so a tab left open all day showed the count from whenever the workspace
was opened, and the modal-open refresh could only reach the seat cap, leaving a
freshly computed denominator over a stale numerator.

Moves the fetch to lib/usage.ts, next to the stores it writes, so the meter can
refresh both numbers when its modal opens. Seats follow a membership signal that
WorkspaceUserSettings bumps where it already refetches after every mutation, so
the cap stops lagging a role change without either side owning the other.

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

* fix: order concurrent usage and seat refreshes

The workspace id doesn't order two requests for the same workspace, and both
refreshes can now have two in flight: usage through A→B→A or a modal-open
refresh landing on one already running, seats through a membership bump
arriving mid-request. An older response could win and restore the count it
replaced. Each refresh takes a generation and only writes if it is still the
newest.

The membership signal also fired on a plain read, so opening the users tab made
every consumer re-fetch a list identical to the one it held. It bumps on an
observed change to the member set instead, never on the first read.

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

* fix: count only billable seats, and order the tier requests

The cap counted every member row, while the backend bills
`NOT disabled AND NOT is_service_account` — a workspace with service accounts
got an inflated included quota, which hides a real overage rather than inventing
one. The seat basis matches `count_paid_seats` now, and the membership signature
carries both fields so enabling or disabling a member re-resolves the cap.

The tier fetch was the one refresh still ordered by workspace id alone, so a
late failure for a workspace could raise the failure flag over a tier a newer
request had already resolved. It takes a generation like the other two.

The membership signature is keyed by workspace: this page survives a workspace
switch, and comparing one workspace's members against another's reported a
membership change where only the workspace had changed.

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

* fix: compare the member set only against the same workspace's

Qualifying the signature with the workspace put the workspace inside the value
being compared, so a switch made every comparison unequal and bumped the version
unconditionally — the opposite of the intent, and worse than before the key. The
workspace is the key now, not part of the payload: a different one has nothing
to compare against and re-baselines silently.

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

* refactor: hold the usage and tier fetches in resources

Every one of these values belongs to a workspace but lived in a bare store, so
each writer and reader re-derived "does this still describe what I'm rendering?"
by hand. Nine sites did, and the ones that forgot were most of this branch's
review findings: three stale-workspace overwrites, three A→B→A races, and two
placeholders (`0` executions, `false` tier) that read as real data because an
in-band value was standing in for "not known".

`resource` from runed — which frontend/AGENTS.md prescribes for async data, and
which ~80 files here already use — supplies all three properties as behaviour
rather than convention: a superseded fetch is discarded, the value resets when
its key changes, and loading and error are states instead of magic values. The
seat count keys on the billing root and the membership version, so both a
workspace switch and an added member re-resolve it.

That removes three generation counters, two workspace trackers, and the manual
clear-and-compare around each fetch. What remains is one publish site that
asserts the value still carries the active workspace before it reaches a store.

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

* fix: order the resource responses that runed does not

The refactor claimed `resource` discards a superseded fetch. It does not: its
only ordering is an AbortController whose signal the generated client cannot
consume, and `current = result` runs unconditionally once a fetch resolves. So a
late answer for a workspace we had left still landed in `current`, and the
publish site — which trusted `current` — cleared the value on screen for the
workspace we were on. That reinstated the races the generation counters had
covered.

`loading` was standing in for the missing ordering, and it cannot: it is also
true during a `refetch()`, when `current` is still the right value. Gating on it
meant every re-read blanked the meter, and clicking it unmounted the modal that
same click had opened, since both sit behind the quota it had just cleared.

Values now carry the scope they describe and `scopedValue` keeps the newest one
matching the active scope, so a superseded answer neither publishes nor erases,
and a re-read leaves the display alone. The account-wide user counter keys on
the account, so a workspace switch no longer clears it.

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

* fix: order responses within a scope, not just across scopes

The tag carried what a value described but not when it was asked for, so two
fetches for one scope — a refetch landing on an in-flight load, or a second
membership invalidation — were indistinguishable and the older won if it landed
last. That left the seat cap reading the pre-change number until the next bump
or switch, which is the stale cap the generation counters had covered.

Widening the tag to the resource key would have fixed it by blanking the bar on
every membership change, so the issue order travels alongside the scope instead:
`tagged` stamps each request as it is issued, and only a strictly newer answer
for the current scope replaces the held one.

The unit tests now cover the same-key case they missed; both new ones fail
against the key-only guard.

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

* fix: ignore a user list a newer read has overtaken

`lastSeen` was written unconditionally after the await, so a response for a
workspace already left overwrote the baseline for the workspace on screen. The
next real membership change there then compared against a baseline that was
never taken for it, re-baselined silently, and never bumped
`workspaceMembershipVersion` — leaving the sidebar on the old seat cap. The
`users` assignment had the same hole: an overtaken list could paint over a
newer one.

Both now go through a single check: a read whose issue order is behind the last
applied one is dropped before it touches either.

Also trims the two `scopedValue` docstrings and the membership rationale to the
four lines AGENTS.md allows, and records there that a failed refresh keeps the
last successful value rather than blanking.

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

* fix: do not claim a plan before the tier resolves

Widening `isPremiumStore` to `boolean | undefined` left `UserMenu`'s `{:else}`
catching the unresolved state: with the tier still in flight, or after the
request failed, a free workspace was told it was on the "Premium plan". Both
branches under that block assert a plan, so the block now renders only once the
tier is known — which also keeps the bordered divider from appearing empty
while it resolves.

Verified against the running instance with the tier stubbed slow: unresolved
shows neither branch, `false` shows the free counters, `true` shows "Premium
plan". Reverting the guard reproduces the wrong label at 300ms.

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

* test: pin how a late answer orders against the read that replaced it

Returning to a scope whose earlier read is still in flight is the one case the
guard resolves by scope rather than by sequence, and the suite only covered it
with nothing outstanding. It now covers the late answer itself: it stands while
it is the only value describing the scope, the read issued on returning
supersedes it, and it cannot come back afterwards.

Also gives the meter the explicit `type="button"` the sibling sidebar rows use.

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

* fix: size the modal's plan button with unifiedSize

`size` is deprecated on `Button`. `unifiedSize="sm"` renders the plan button at
the same height and weight as the modal's own Close button.

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

* fix: match the plan button to the modal's own action button

`unifiedSize="sm"` is `h-7`, and the Cancel button `Modal` renders beside it is
`px-3 py-[7px]`, i.e. 32px — so the two sat 4px apart. `md` is the unified size
that lands on 32px, which pairs them without putting a deprecated prop back.

Measured both boxes rather than the new one alone: 32px and 32px, same top.

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

* feat: instrument the execution meter, and bill-align PremiumInfo's seats

The meter's only interaction is opening the modal, so that is what it counts:
`usage_meter/opened`, keyed by the plan tier and the quota that was tightest —
`free:user`, `free:workspace`, `paid:workspace`. The full set is a type next to
the call site so the vocabulary stays readable in one place.

The pair is registered in `FEATURE_USAGE_KINDS` (windmill-ee-private), without
which the post is dropped with a 204 and records nothing. Verified both halves:
the browser posts
`{"feature":"usage_meter","kind":"opened","key":"free:user","value":1}`, the
running EE image drops it because its registry predates the entry, and
`is_recordable_event` accepts it once the entry is there.

`PremiumInfo` computed its seats from an unfiltered user list, so the billing
page counted disabled members and service accounts that `count_paid_seats` does
not bill. Same filter as the sidebar's cap now.

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

* chore: point ee-repo-ref at the usage_meter registration

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

* fix: read the member list before the seat rows that depend on it

`loadPremiumInfo` reads `users` after its own await and nothing recomputes the
seat rows when the list lands, so whenever `premium_info` won the race the page
rendered zero developers, zero operators and zero seats and kept them. The list
is now fetched first, and a failure to read it no longer costs the rest of the
page.

Also refreshes the registered-action inventory in `docs/feature-telemetry.md`,
which the new pair makes 21 across nine features.

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

* docs: scope the seat comment to the counter it matches

The comment claimed parity with the seats actually charged, which nothing in
this repo computes: `count_paid_seats` documents itself as counting provisioned
members rather than billing's active-user population, and the Stripe quantity
is not derived here. What the filter buys is agreement with that counter.

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

* chore: update ee-repo-ref to c6902ec2c51dc0ce30962afbfab3e456c5d9b831

This commit updates the EE repository reference after PR #735 was merged in windmill-ee-private.

Previous ee-repo-ref: bbc48fae6b73b6d72fe2e125e6003794a4ece167

New ee-repo-ref: c6902ec2c51dc0ce30962afbfab3e456c5d9b831

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-08-20 22:17:15 +02:00

1314 lines
42 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 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 || {}
}
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>
<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>
<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>
{#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><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>
<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>