mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
* 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>
1314 lines
42 KiB
Svelte
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>
|