mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 08:07:03 +00:00
* [ee] feat(service-accounts): allow choosing role at creation time Previously, service accounts were hardcoded to operator and could not be used as the CLI sync user since they had no write access. They also only counted as 0.5 seat each. This change: - Extends `NewServiceAccount` to accept optional `is_admin` / `operator` (defaults to `operator=true` for backward compatibility). - Exposes a role picker in `AddUser.svelte` when creating a service account (Operator / Developer / Admin). - Lets admins update a service account's role from the user list (it used to be locked to "Operator" with a tooltip). - Updates the OpenAPI spec + regenerates the frontend client. A developer/admin service account counts as 1 seat under the existing seat-cap logic (operators stay at 0.5). Companion PR on windmill-ee-private updates the `INSERT INTO usr` to honour the chosen role. Fixes WIN-1985 * [ee] feat(service-accounts): wm_deployers opt-in for Dev role When creating a service account with role=Developer, surface a toggle "Add to wm_deployers" (recommended). Members of wm_deployers can deploy on behalf of other users — the typical setup when the service account is used as the CLI sync / CI deploy identity. - `NewServiceAccount` gains an optional `add_to_deployers` flag. - Frontend defaults the toggle to on but only shows it under Developer (admins have it implicitly; operators can't deploy). - Tooltip links to docs.windmill.dev "Run on behalf of". Companion EE PR updates the handler to INSERT into usr_to_group for wm_deployers when the flag is set. Refs WIN-1985 * chore: update ee-repo-ref to 974ed42067d9f63acb42332b671b8c01ffd4b625 This commit updates the EE repository reference after PR #589 was merged in windmill-ee-private. Previous ee-repo-ref: f7dbc3cc2ba21c396f4828881e3b9d9ab6f50c69 New ee-repo-ref: 974ed42067d9f63acb42332b671b8c01ffd4b625 Automated by sync-ee-ref workflow. * [ee] fix(service-accounts): unhardcode role in superadmin user list Two review issues from the merged #9307 / #589: 1. P1 — The global Users tab in #superadmin-settings still pinned every service account to "Operator". Now it shows the actual role (Admin / Operator / Developer), derived from the SA's usr row. - `list_users_as_super_admin`: replaced `true as operator_only` with the real `operator` value, and added `is_workspace_admin` from the row (NULL for password users since their admin status is per-workspace). - `global_whoami`: when the email belongs to a service account, look up its real `operator` / `is_admin` instead of pinning to operator. - `SuperadminSettingsInner.svelte`: drop the hardcoded "Operator" badge; render Admin / Operator / Developer using the new fields, matching the workspace-level view. 2. P2 — Regenerate the bundled `openapi-deref.{yaml,json}` so the `createServiceAccount` body (now exposing `is_admin`, `operator`, `add_to_deployers`) and the new `GlobalUserInfo.is_workspace_admin` field show up at runtime in `/api/openapi.{yaml,json}`. Bumps `ee-repo-ref.txt` to the EE follow-up that adds the offline seat-cap check on `create_service_account`. Refs WIN-1985 * chore: update ee-repo-ref to b7a6068c1f3dc845e012959268b2426f0de4d697 This commit updates the EE repository reference after PR #590 was merged in windmill-ee-private. Previous ee-repo-ref: 0b1307c21d1bfd6fb43a03c2ba39d2a8bf8e6470 New ee-repo-ref: b7a6068c1f3dc845e012959268b2426f0de4d697 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
768 lines
25 KiB
Svelte
768 lines
25 KiB
Svelte
<script lang="ts">
|
|
import { UserService, type GlobalUserInfo, type ExternalJwtToken, SettingService } 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 { goto as gotoUrl } 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'
|
|
|
|
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)
|
|
gotoUrl(hashRemoved)
|
|
}
|
|
|
|
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' = $state('users')
|
|
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}
|
|
|
|
{#if extJwtTokens.length > 0}
|
|
<Tabs bind:selected={usersSubTab} class="mb-4">
|
|
<Tab value="users" label="Users" />
|
|
<Tab value="ext_jwt" label="External JWTs" />
|
|
</Tabs>
|
|
{/if}
|
|
|
|
{#if usersSubTab === 'users' || 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 "User" 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)
|
|
}}
|
|
/>
|
|
{/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>
|