mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
* 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>
824 lines
27 KiB
Svelte
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 "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)
|
|
}}
|
|
/>
|
|
{: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>
|