Files
windmill/frontend/src/lib/components/SuperadminSettingsInner.svelte
T
fce635d3c4 feat: guest app execution mode, a role that takes no seat (#10929)
* feat: guest app execution mode, a fourth role that takes no seat

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: make the guest grant a server-minted label, not a declarable scope

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref to the guest session companion branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: close the relabel hole, guest embed tokens, read-path switch, custom-path entry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest tokens are not rescopable and guest embed tokens keep the sentinel

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest-derived tokens share one constraint set; gate sign-in on guest discovery

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the label alone governs a guest; refuse guests with accounts; unserialize discovery

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest discovery fails closed; SAML aborts if the guest cookie write fails

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* refactor: enforce the guest switch once at the auth door; sign-in for a guest of another app

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest app-mode decided once at the on-behalf resolver; clear a stale guest session before offering another app's sign-in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest may use anonymous apps; await the stale-session logout; trim comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest's path confinement waits for the app's mode, so anonymous apps stay open to it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest target survives http (Lax cookie), rides SAML RelayState; tell account holders on arrival

* fix: a guest uses an anonymous app as itself; S3 uploads confined by app mode

* fix: a guest upload needs an app policy; a missing app does not skip the confinement

* fix: guests are gated on the Enterprise plan server-side; pin ee-repo-ref

* fix: the guest plan gate fails closed on non-enterprise builds; settings report the effective switch

* fix: guest controls read the plan, not the key; gate the guest tests on the features they need

* docs: tighten the guest session invariant comments

* feat: 100 free guests per 30 days, then a quarter seat each on Enterprise and a hard cap elsewhere; superadmin guest list; refusals reach the page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the cap is exact, an account ends a guest session at the door, popups close, and guest mode survives the CLI round trip

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* feat: a superadmin switch over guests for the whole instance; the pre-existing-user flag keeps its meaning

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: drop the dead guest-access helper, name the instance setting once, guests tab states, CE save order

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest app path is refused at the mint if it could widen the scope; the instance toggle waits for its reload

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guests stop at the launched-by-me job grant; canonical app paths at the mint and discovery; the toggle ends on the stored value

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: only the scope grammar's own characters bar an app path from guests, refused at deploy as well as at the mint

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the deploy-time guest path guard checks the destination of a rename and refuses a leading slash

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a workspace rename keeps the guest switch; the rename guard reads the deployed mode under the row lock

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest_activity follows a workspace rename and goes with a workspace delete

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref to the state-bound guest target

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref; the guest cookie is never cleared by a callback

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* docs: the workspace-scoped guest_activity delete moves an instance-wide count; assert the mint records the guest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* test: the seeded allowance is a day old, so only the mint can write today's guest_activity row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: update ee-repo-ref to 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07

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

Previous ee-repo-ref: 32841072aa396bff91d30bd91854fa348cb3c439

New ee-repo-ref: 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07

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>
2026-09-04 22:47:28 +02:00

824 lines
27 KiB
Svelte

<script lang="ts">
import { UserService, type GlobalUserInfo, type ExternalJwtToken, SettingService } from '$lib/gen'
import type { GuestList } from '$lib/gen'
import { Tab, Tabs } from '$lib/components/common'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import InviteGlobalUser from '$lib/components/InviteGlobalUser.svelte'
import { Button } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import SearchItems from './SearchItems.svelte'
import { page } from '$app/state'
import { replaceState } from '$app/navigation'
import Version from './Version.svelte'
import Uptodate from './Uptodate.svelte'
import InstanceSettings from './InstanceSettings.svelte'
import { truncate } from '$lib/utils'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import {
ArrowRightLeft,
Ban,
Bot,
CheckCircle2,
ExternalLink,
Pencil,
UserMinus,
UserPlus
} from 'lucide-svelte'
import Badge from './common/badge/Badge.svelte'
import Tooltip from './Tooltip.svelte'
import DropdownV2 from './DropdownV2.svelte'
import Popover from './meltComponents/Popover.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import GlobalUserOffboardingModal from '$lib/components/GlobalUserOffboardingModal.svelte'
import ChangeInstanceUsername from './ChangeInstanceUsername.svelte'
import { isCloudHosted } from '$lib/cloud'
import InstanceNameEditor from './InstanceNameEditor.svelte'
import Toggle from './Toggle.svelte'
import { instanceSettingsSelectedTab } from '$lib/stores'
import { onDestroy, tick } from 'svelte'
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
import {
instanceSettingsNavigationGroups,
tabToCategoryMap,
tabToAuthSubTab,
categoryToTabMap,
buildSearchableSettingItems,
type SearchableSettingItem
} from './instanceSettings'
import TextInput from './text_input/TextInput.svelte'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte'
import InstanceAISettings from './instanceSettings/InstanceAISettings.svelte'
import ExternalJwtTokens from './instanceSettings/ExternalJwtTokens.svelte'
import GuestActivityList from './instanceSettings/GuestActivityList.svelte'
import { Alert, Skeleton } from '$lib/components/common'
let filter = $state('')
let {
closeDrawer,
showHeaderInfo = true,
disableChatOffset = false,
yamlMode = $bindable(false),
hasUnsavedChanges = $bindable(false),
hasAnyInvalid = $bindable(false)
} = $props()
function removeHash() {
const index = page.url.href.lastIndexOf('#')
if (index === -1) return
const hashRemoved = page.url.href.slice(0, index)
// Strip the drawer's URL hash without a SvelteKit navigation: a `goto`
// here re-fires path-reactive effects on the underlying page (e.g. the
// script editor's load effect), wiping unsaved editor content.
try {
replaceState(hashRemoved, page.state)
} catch (e) {
// replaceState throws if the router isn't initialized yet — possible
// when onDestroy runs during router teardown.
console.error(e)
}
}
onDestroy(() => {
removeHash()
})
let users: GlobalUserInfo[] = $state([])
let filteredUsers: GlobalUserInfo[] = $state([])
let offboardingEmail: string | undefined = $state(undefined)
let offboardingReassignOnly = $state(false)
let disableConfirmedCallback: (() => void) | undefined = $state(undefined)
let disableUserEmail: string = $state('')
let editWrappers: Record<string, HTMLDivElement> = $state({})
let activeOnly = $state(false)
async function listUsers(activeOnly: boolean): Promise<void> {
users = await UserService.listUsersAsSuperAdmin({ perPage: 100000, activeOnly: activeOnly })
}
$effect(() => {
listUsers(activeOnly)
})
let usersSubTab: 'users' | 'ext_jwt' | 'guests' = $state('users')
let guestList: GuestList | undefined = $state(undefined)
let guestHasMore = $state(true)
let guestLoading = $state(false)
const guestPerPage = 50
async function loadGuestPage(nextPage: number): Promise<boolean> {
guestLoading = true
try {
const res = await UserService.listGuests({ page: nextPage, perPage: guestPerPage })
guestList =
nextPage === 1 || !guestList
? res
: { usage: res.usage, guests: [...guestList.guests, ...res.guests] }
guestHasMore = res.guests.length === guestPerPage
return true
} catch (e) {
sendUserToast(`Failed to load guests: ${e}`, true)
return false
} finally {
guestLoading = false
}
}
loadGuestPage(1)
let extJwtTokens: ExternalJwtToken[] = $state([])
let extJwtHasMore = $state(true)
let extJwtLoading = $state(false)
let extJwtActiveOnly = $state(false)
const extJwtPerPage = 50
async function loadExtJwtPage(nextPage: number) {
extJwtLoading = true
try {
const res = await UserService.listExtJwtTokens({
page: nextPage,
perPage: extJwtPerPage,
activeOnly: extJwtActiveOnly
})
extJwtTokens = nextPage === 1 ? res : [...extJwtTokens, ...res]
extJwtHasMore = res.length === extJwtPerPage
} catch (e) {
sendUserToast(`Failed to load external JWT tokens: ${e}`, true)
} finally {
extJwtLoading = false
}
}
loadExtJwtPage(1)
let tab: string = $state('users')
$effect(() => {
tab = $instanceSettingsSelectedTab
})
$effect(() => {
instanceSettingsSelectedTab.set(tab)
})
let nbDisplayed = $state(50)
let instanceSettings: InstanceSettings | undefined = $state()
let automateUsernameCreation = $state(true)
async function getAutomateUsernameCreationSetting() {
automateUsernameCreation =
((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? true
}
getAutomateUsernameCreationSetting()
let automateUsernameModalOpen = $state(false)
async function enableAutomateUsernameCreationSetting() {
await SettingService.setGlobal({
key: 'automate_username_creation',
requestBody: { value: true }
})
getAutomateUsernameCreationSetting()
sendUserToast('Automatic username creation enabled')
listUsers(activeOnly)
}
async function updateName(name: string | undefined, email: string) {
try {
await UserService.globalUserUpdate({
email,
requestBody: {
name
}
})
sendUserToast('User updated')
listUsers(activeOnly)
} catch (e) {
sendUserToast('Error updating user', true)
}
}
// The category name for InstanceSettings based on current sidebar tab
let instanceSettingsCategory = $derived(tabToCategoryMap[tab] ?? 'Core')
let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[tab] ?? 'sso')
function handleNavigate(newTab: string) {
if (newTab === tab) return
tab = newTab
}
export function saveSettings() {
return instanceSettings?.saveSettings()
}
export function discardAll() {
instanceSettings?.discardAll()
}
export function syncBeforeDiff(): boolean {
return instanceSettings?.syncBeforeDiff() ?? true
}
export function buildFullDiff(): { original: string; modified: string } {
return instanceSettings?.buildFullDiff() ?? { original: '', modified: '' }
}
// --- Settings search ---
const searchableItems = buildSearchableSettingItems()
let scrollTimeout: ReturnType<typeof setTimeout> | undefined
let highlightTimeout: ReturnType<typeof setTimeout> | undefined
async function handleSearchSelect(item: SearchableSettingItem) {
handleNavigate(item.tabId)
if (item.settingKey) {
clearTimeout(scrollTimeout)
clearTimeout(highlightTimeout)
await tick()
// Wait for the tab content to render before scrolling
scrollTimeout = setTimeout(() => {
const el = document.querySelector(`[data-setting-key="${item.settingKey}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('setting-highlight')
highlightTimeout = setTimeout(() => el.classList.remove('setting-highlight'), 2500)
}
}, 100)
}
}
onDestroy(() => {
clearTimeout(scrollTimeout)
clearTimeout(highlightTimeout)
})
</script>
<SearchItems
{filter}
items={users}
bind:filteredItems={filteredUsers}
f={(x) =>
(x.email ?? '') +
' ' +
(x.name ?? '') +
' ' +
(x.company ?? '') +
' ' +
(x.username ?? '') +
' ' +
(x.workspace_id ?? '')}
/>
<div class="flex flex-col h-full w-full">
{#if showHeaderInfo}
<div>
<div class="flex justify-between">
<div class="text-xs pt-1 text-secondary flex flex-col">
<div>Windmill <Version /></div>
</div>
<div><Uptodate /></div></div
>
</div>
{#if $workspaceStore !== 'admins'}
<div class="flex flex-row-reverse">
<Button
variant="default"
target="_blank"
href="{base}/?workspace=admins"
endIcon={{ icon: ExternalLink }}
>
Admins workspace
</Button>
</div>
{/if}
{/if}
<div class="{showHeaderInfo ? 'pt-4' : ''} flex grow min-h-0">
{#if !yamlMode}
<!-- Sidebar Navigation -->
<div class="w-52 shrink-0 h-full overflow-auto p-4 bg-surface flex flex-col">
<SettingsSearchInput {searchableItems} onSelect={handleSearchSelect} class="mb-3" />
<SidebarNavigation
groups={instanceSettingsNavigationGroups}
selectedId={tab}
onNavigate={handleNavigate}
/>
{#if $workspaceStore !== 'admins'}
<div class="mt-4 pt-2 border-t border-surface-hover">
<a
href="{base}/?workspace=admins"
target="_blank"
class="flex items-center gap-2 px-2 py-1.5 text-xs text-secondary hover:text-primary transition-colors"
>
<ExternalLink size={14} />
Admins workspace
</a>
</div>
{/if}
</div>
{/if}
<!-- Main Content -->
<div class="flex-1 min-w-0 h-full">
<div class="h-full overflow-auto bg-surface">
<div class="h-fit px-8 py-4">
{#if tab === 'ai' && !yamlMode}
<InstanceAISettings {disableChatOffset} />
{:else if tab === 'users' && !yamlMode}
<div class="h-full">
{#if !automateUsernameCreation && !isCloudHosted()}
<div class="mb-4">
<h3 class="mb-2"> Automatic username creation </h3>
<div class="mb-2">
<span class="text-primary text-sm"
>Automatically create a username for new users based on their email, shared
across workspaces. <a
target="_blank"
href="https://www.windmill.dev/docs/advanced/instance_settings#automatic-username-creation"
>Learn more</a
></span
>
</div>
<Button
btnClasses="w-auto"
size="sm"
variant="accent"
on:click={() => {
automateUsernameModalOpen = true
}}
>
Enable (recommended)
</Button>
<ConfirmationModal
open={automateUsernameModalOpen}
on:confirmed={() => {
automateUsernameModalOpen = false
enableAutomateUsernameCreationSetting()
}}
on:canceled={() => (automateUsernameModalOpen = false)}
title="Automatic username creation"
confirmationText="Enable"
>
Once activated, it will not be possible to disable this feature. In case
existing users have different usernames in different workspaces, you will have
to manually confirm the username for each user.
</ConfirmationModal>
</div>
{/if}
<Tabs bind:selected={usersSubTab} class="mb-4">
<Tab value="users" label="Users" />
{#if extJwtTokens.length > 0}
<Tab value="ext_jwt" label="External JWTs" />
{/if}
<Tab value="guests" label="Guests" />
</Tabs>
{#if usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0)}
<SettingsPageHeader
title="Instance users ({users.length})"
description="Manage all users across your Windmill instance."
link="https://www.windmill.dev/docs/advanced/instance_settings#global-users"
/>
<div class="flex flex-row gap-2 items-center">
<TextInput
inputProps={{ placeholder: 'Search users' }}
bind:value={filter}
class="w-60"
/><Toggle
bind:checked={activeOnly}
options={{
left: 'Recently active only',
leftTooltip:
'Show only users who have logged in or performed an action in the last 30 days'
}}
/>
<div class="flex-1"></div>
<Popover placement="bottom-end" disableFocusTrap closeButton>
{#snippet trigger()}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: UserPlus }}
nonCaptureEvent
wrapperClasses="w-fit shrink-0"
>
Add new user
</Button>
{/snippet}
{#snippet content()}
<InviteGlobalUser on:new={() => listUsers(activeOnly)} />
{/snippet}
</Popover>
</div>
<p class="text-hint text-2xs mt-2">
{filteredUsers.length} user{filteredUsers.length !== 1 ? 's' : ''} found
</p>
<div class="mt-1">
<DataTable
shouldLoadMore={(filteredUsers?.length ?? 0) > 50}
loadMore={50}
on:loadMore={() => {
nbDisplayed += 50
}}
>
<Head>
<tr>
<Cell head first>Email</Cell>
{#if automateUsernameCreation}
<Cell head>Username</Cell>
{/if}
<Cell head>Name</Cell>
<Cell head>Auth</Cell>
{#if activeOnly}
<Cell head>Kind</Cell>
{/if}
<Cell head>Role</Cell>
<Cell head last>
<span class="sr-only">Actions</span>
</Cell>
</tr>
</Head>
<tbody>
{#if filteredUsers && users}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, is_workspace_admin, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))}
{@const isServiceAccount = login_type === 'service_account'}
<tr
class="{i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'} {disabled
? 'opacity-60'
: ''}"
>
<Cell first class="max-w-[250px]">
<div class="flex items-center gap-1.5">
{#if isServiceAccount}
<Bot size={16} class="text-blue-500 shrink-0" />
<span title={email} class="truncate block">{email}</span>
{:else}
<a href="mailto:{email}" title={email} class="truncate block"
>{email}</a
>
{/if}
{#if workspace_id}
<a
href="{base}/?workspace={workspace_id}"
title="Workspace: {workspace_id}"
>
<Badge color="blue">{truncate(workspace_id, 20)}</Badge>
</a>
{/if}
{#if disabled}
<span
class="text-2xs px-1.5 py-0.5 rounded bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300 whitespace-nowrap"
>Disabled</span
>
{/if}
</div>
</Cell>
{#if automateUsernameCreation}
<Cell class="max-w-[150px]">
{#if username}
<span title={username} class="truncate block">{username}</span>
{:else}
{#key filteredUsers.map((u) => u.username).join()}
<ChangeInstanceUsername
username=""
{email}
isConflict
on:renamed={() => {
listUsers(activeOnly)
}}
/>
{/key}
{/if}
</Cell>
{/if}
<Cell class="max-w-[150px]"
><span title={name ?? ''} class="truncate block"
>{truncate(name ?? '', 30)}</span
></Cell
>
<Cell class="max-w-[100px]"
><span title={login_type} class="truncate block">{login_type}</span
></Cell
>
{#if activeOnly}
<Cell>
{#if is_workspace_admin}
Admin
{:else if operator_only}
Operator only
{:else}
Developer
{/if}
</Cell>
{/if}
<Cell>
{#if isServiceAccount}
<div class="flex items-center gap-1">
<span
class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold"
>
{is_workspace_admin
? 'Admin'
: operator_only
? 'Operator'
: 'Developer'}
</span>
<Tooltip>
Service-account role is managed in the workspace user settings.
</Tooltip>
</div>
{:else}
<div class="flex flex-col items-start">
{#key `${super_admin}_${devops}_${role_source}`}
<ToggleButtonGroup
selected={super_admin
? 'super_admin'
: devops
? 'devops'
: 'user'}
on:selected={async (e) => {
if (email == $userStore?.email) {
sendUserToast('You cannot demote yourself', true)
listUsers(activeOnly)
return
}
let role = e.detail
if (role === 'super_admin') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: true,
is_devops: false
}
})
}
if (role === 'devops') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: true
}
})
}
if (role === 'user') {
await UserService.globalUserUpdate({
email,
requestBody: {
is_super_admin: false,
is_devops: false
}
})
}
sendUserToast('User updated')
listUsers(activeOnly)
}}
>
{#snippet children({ item })}
<ToggleButton
value={'user'}
small
label="User"
disabled={role_source === 'instance_group' &&
(super_admin || devops)}
tooltip={role_source === 'instance_group' &&
(super_admin || devops)
? 'Role is set by an instance group. Remove the user from the group to demote to "User".'
: undefined}
showTooltipIcon={role_source === 'instance_group' &&
(super_admin || devops)}
{item}
/>
<ToggleButton
value={'devops'}
small
label="Devops"
tooltip="Devops is a role that grants visibilty similar to that of a super admin, but without giving all rights. For example devops users can see service logs and crtical alerts. You can think of it as a 'readonly' super admin"
{item}
/>
<ToggleButton
value={'super_admin'}
small
label="Superadmin"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/key}
{#if role_source === 'instance_group' && (super_admin || devops)}
<a
href="{base}/groups"
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
title="Role set by instance group. You can upgrade to a higher role manually, but demoting to &quot;User&quot; requires removing them from the group."
onclick={() => closeDrawer?.()}
>
Set by instance group
</a>
{/if}
</div>
{/if}
</Cell>
<Cell last>
<div class="flex items-center justify-end">
{#if isServiceAccount}
{#if workspace_id}
<a
href="{base}/workspace_settings?tab=users&workspace={workspace_id}"
class="text-xs text-secondary hover:text-primary hover:underline"
title="Manage in workspace settings">Manage in workspace</a
>
{/if}
{:else}
<div
bind:this={editWrappers[email]}
class="w-0 h-0 overflow-hidden"
>
<InstanceNameEditor
{login_type}
value={name}
{username}
{email}
on:refresh={() => {
listUsers(activeOnly)
}}
on:save={(e) => {
updateName(e.detail, email)
}}
on:renamed={() => {
listUsers(activeOnly)
}}
{automateUsernameCreation}
/>
</div>
<DropdownV2
items={[
{
displayName: 'Edit',
icon: Pencil,
action: () => {
const btn = editWrappers[email]?.querySelector(
'[aria-label="Popup button"]'
)
if (btn instanceof HTMLElement) btn.click()
}
},
{
displayName: disabled ? 'Enable' : 'Disable',
icon: disabled ? CheckCircle2 : Ban,
action: () => {
if (!disabled) {
disableUserEmail = email
disableConfirmedCallback = async () => {
try {
await UserService.globalUserUpdate({
email,
requestBody: { disabled: true }
})
sendUserToast('User disabled')
listUsers(activeOnly)
} catch (e) {
sendUserToast('Failed to disable user', true)
}
}
} else {
UserService.globalUserUpdate({
email,
requestBody: { disabled: false }
})
.then(() => {
sendUserToast('User enabled')
listUsers(activeOnly)
})
.catch(() => {
sendUserToast('Failed to enable user', true)
})
}
}
},
{
displayName: 'Reassign',
icon: ArrowRightLeft,
action: () => {
offboardingEmail = email
offboardingReassignOnly = true
}
},
{
displayName: 'Remove',
icon: UserMinus,
type: 'delete',
action: () => {
offboardingEmail = email
offboardingReassignOnly = false
}
}
]}
/>
{/if}
</div>
</Cell>
</tr>
{/each}
{/if}
</tbody>
</DataTable>
</div>
{:else if usersSubTab === 'ext_jwt'}
<ExternalJwtTokens
tokens={extJwtTokens}
hasMore={extJwtHasMore}
loading={extJwtLoading}
activeOnly={extJwtActiveOnly}
onLoadMore={() =>
loadExtJwtPage(Math.floor(extJwtTokens.length / extJwtPerPage) + 1)}
onActiveOnlyChange={(v) => {
extJwtActiveOnly = v
loadExtJwtPage(1)
}}
/>
{:else if usersSubTab === 'guests' && !guestList}
{#if guestLoading}
<Skeleton layout={[[2], 1, [8]]} />
{:else}
<Alert type="error" title="Could not load guests">
<Button unifiedSize="sm" variant="default" onclick={() => loadGuestPage(1)}
>Retry</Button
>
</Alert>
{/if}
{:else if usersSubTab === 'guests' && guestList}
<GuestActivityList
usage={guestList.usage}
guests={guestList.guests}
hasMore={guestHasMore}
loading={guestLoading}
onLoadMore={() =>
loadGuestPage(Math.floor((guestList?.guests.length ?? 0) / guestPerPage) + 1)}
onInstanceSwitch={() => loadGuestPage(1)}
/>
{/if}
</div>
{:else}
<InstanceSettings
bind:this={instanceSettings}
hideTabs
bind:yamlMode
bind:hasUnsavedChanges
bind:hasAnyInvalid
tab={instanceSettingsCategory}
{authSubTab}
{closeDrawer}
onNavigateToTab={(category) => {
const targetTab = categoryToTabMap[category]
if (targetTab) {
handleNavigate(targetTab)
}
}}
/>
{/if}
</div>
</div>
</div>
</div>
</div>
{#if offboardingEmail}
<GlobalUserOffboardingModal
open={offboardingEmail != null}
email={offboardingEmail}
reassignOnly={offboardingReassignOnly}
onClose={() => {
offboardingEmail = undefined
}}
onComplete={() => {
offboardingEmail = undefined
listUsers(activeOnly)
}}
/>
{/if}
<ConfirmationModal
open={Boolean(disableConfirmedCallback)}
title="Disable user"
confirmationText="Disable"
on:canceled={() => {
disableConfirmedCallback = undefined
listUsers(activeOnly)
}}
on:confirmed={() => {
if (disableConfirmedCallback) {
disableConfirmedCallback()
}
disableConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span
>Are you sure you want to disable <b>{disableUserEmail}</b>? All their active sessions and
tokens will be revoked immediately. They will be unable to log in until re-enabled. Their
workspace memberships and content will be preserved.</span
>
</div>
</ConfirmationModal>