fix: keep the instance users table's actions and header in view (#11145)

* fix: keep the instance users table's actions and header in view

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

* fix: lock only the User option for group-granted roles and keep pinned cells opaque

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

* fix: close the instance settings drawer from the manage-in-workspace menu item

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Guilhem
2026-09-16 00:26:56 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Ruben Fiszel
parent d0d5b295b9
commit a9a9335a34
7 changed files with 232 additions and 153 deletions
@@ -3,6 +3,7 @@
import Popover from './meltComponents/Popover.svelte'
import { autoPlacement } from '@floating-ui/core'
import ChangeInstanceUsernameInner from './ChangeInstanceUsernameInner.svelte'
import { AlertTriangle } from 'lucide-svelte'
interface Props {
email: string
@@ -24,9 +25,22 @@
closeButton
>
{#snippet trigger()}
<Button color={isConflict ? 'red' : 'light'} size="xs" spacingSize="xs2" nonCaptureEvent={true}
>{isConflict ? 'Fix username conflict' : 'Change username'}</Button
>
{#if isConflict}
<!-- An icon rather than a labelled button: the username column truncates text but
cannot truncate a button, so a wide trigger here forced the whole table to scroll. -->
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: AlertTriangle }}
btnClasses="text-yellow-600 dark:text-yellow-400"
title="No instance username. Click to fix the conflict."
aria-label="Fix username conflict"
nonCaptureEvent={true}
/>
{:else}
<Button variant="default" unifiedSize="xs" nonCaptureEvent={true}>Change username</Button>
{/if}
{/snippet}
{#snippet content()}
<ChangeInstanceUsernameInner
@@ -89,7 +89,7 @@
}
</script>
<Drawer bind:this={drawer} size="1200px" {disableChatOffset} on:close={handleDrawerClose}>
<Drawer bind:this={drawer} size="1300px" {disableChatOffset} on:close={handleDrawerClose}>
<DrawerContent noPadding overflow_y={false} title="Instance settings" on:close={handleClose}>
{#snippet titleExtra()}
<MeltTooltip disablePopup={!uptodateVersion}>
@@ -26,11 +26,10 @@
CheckCircle2,
ExternalLink,
Pencil,
Settings,
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'
@@ -155,6 +154,11 @@
loadExtJwtPage(1)
let tab: string = $state('users')
let usersListShown = $derived(
tab === 'users' &&
!yamlMode &&
(usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0))
)
$effect(() => {
tab = $instanceSettingsSelectedTab
@@ -320,11 +324,14 @@
<!-- 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">
<!-- The users list scrolls inside a bounded table, so its tab fills the pane instead of
growing with it: that is what lets the header pin and keeps the horizontal
scrollbar in view rather than under hundreds of rows. -->
<div class={usersListShown ? 'h-full flex flex-col px-8 py-4' : 'h-fit px-8 py-4'}>
{#if tab === 'ai' && !yamlMode}
<InstanceAISettings {disableChatOffset} />
{:else if tab === 'users' && !yamlMode}
<div class="h-full">
<div class="flex-1 min-h-0 flex flex-col">
{#if !automateUsernameCreation && !isCloudHosted()}
<div class="mb-4">
<h3 class="mb-2"> Automatic username creation </h3>
@@ -373,7 +380,7 @@
<Tab value="guests" label="Guests" />
</Tabs>
{#if usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0)}
{#if usersListShown}
<SettingsPageHeader
title="Instance users ({users.length})"
description="Manage all users across your Windmill instance."
@@ -414,14 +421,10 @@
<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
}}
>
<!-- Shrinks but never grows: a short list keeps its box hugging the rows, a long one
is capped by the pane and scrolls inside, with a floor of a few rows. -->
<div class="mt-1 min-h-48">
<DataTable>
<Head>
<tr>
<Cell head first>Email</Cell>
@@ -434,7 +437,7 @@
<Cell head>Kind</Cell>
{/if}
<Cell head>Role</Cell>
<Cell head last>
<Cell head last actions>
<span class="sr-only">Actions</span>
</Cell>
</tr>
@@ -443,12 +446,22 @@
{#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'}
{@const groupRole =
role_source === 'instance_group' && (super_admin || devops)}
<!-- Any elevated role picked here is stored as manual and wins over the group on later
syncs; only a demotion to User is re-applied from the group. So only User locks. -->
{@const groupRoleTooltip =
'Role is set by an instance group. Superadmin and Devops can be set here, but demoting to User requires removing the user from the group.'}
{@const serviceAccountTooltip =
'Service accounts are always users in the instance. Their workspace role is managed in the workspace user settings.'}
<!-- Dimmed per cell content, not on the row: opacity on the row would make the pinned
actions cell translucent and let the columns scrolling under it show through. -->
<tr
class="{i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'} {disabled
? 'opacity-60'
? '[&>td>*]:opacity-60'
: ''}"
>
<Cell first class="max-w-[250px]">
<Cell first class="max-w-[240px]">
<div class="flex items-center gap-1.5">
{#if isServiceAccount}
<Bot size={16} class="text-blue-500 shrink-0" />
@@ -458,14 +471,6 @@
>{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"
@@ -475,7 +480,7 @@
</div>
</Cell>
{#if automateUsernameCreation}
<Cell class="max-w-[150px]">
<Cell class="max-w-[140px]">
{#if username}
<span title={username} class="truncate block">{username}</span>
{:else}
@@ -503,133 +508,157 @@
>
{#if activeOnly}
<Cell>
{#if is_workspace_admin}
Admin
{:else if operator_only}
Operator only
{:else}
Developer
{/if}
<span>
{#if is_workspace_admin}
Admin
{:else if operator_only}
Operator only
{:else}
Developer
{/if}
</span>
</Cell>
{/if}
<Cell>
{#if isServiceAccount}
<div class="flex items-center gap-1">
<!-- A service account has no `password` row, so it can never hold an
instance role: the group renders locked on "User" rather than hidden. -->
<div class="flex flex-col items-start">
{#key `${super_admin}_${devops}_${role_source}`}
<ToggleButtonGroup
disabled={isServiceAccount}
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"
shortLabel="User"
disabled={isServiceAccount || groupRole}
tooltip={isServiceAccount
? serviceAccountTooltip
: groupRole
? groupRoleTooltip
: undefined}
{item}
/>
<ToggleButton
value={'devops'}
small
label="Devops"
shortLabel="Dev"
disabled={isServiceAccount}
tooltip={isServiceAccount
? serviceAccountTooltip
: "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"
shortLabel="Admin"
disabled={isServiceAccount}
tooltip={isServiceAccount
? serviceAccountTooltip
: undefined}
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/key}
{#if isServiceAccount}
<span
class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold"
class="text-2xs text-tertiary mt-0.5 ml-1 whitespace-nowrap"
title={serviceAccountTooltip}
>
{is_workspace_admin
? 'Admin'
: operator_only
? 'Operator'
: 'Developer'}
in
{#if workspace_id}
<a
href="{base}/workspace_settings?tab=users&workspace={workspace_id}"
class="hover:underline"
title={workspace_id}
onclick={() => closeDrawer?.()}
>{truncate(workspace_id, 20)}</a
>
{:else}
its workspace
{/if}
</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}
{:else if groupRole}
<a
href="{base}/groups"
class="text-2xs text-tertiary mt-0.5 ml-1 hover:underline"
title={groupRoleTooltip}
onclick={() => closeDrawer?.()}
>
Set by instance group
</a>
{/if}
</div>
</Cell>
<Cell last>
<Cell last actions class={i % 2 === 0 ? 'bg-surface-tertiary' : ''}>
<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
>
<DropdownV2
items={[
{
displayName: 'Manage in workspace',
icon: Settings,
action: () => closeDrawer?.(),
href: `${base}/workspace_settings?tab=users&workspace=${workspace_id}`
}
]}
/>
{/if}
{:else}
<div
@@ -722,6 +751,29 @@
</Cell>
</tr>
{/each}
{#if filteredUsers.length > nbDisplayed}
{@const remaining = Math.min(50, filteredUsers.length - nbDisplayed)}
<!-- Last row rather than a footer under the scroller, the way the runs
list pages: the control scrolls with the rows it extends. -->
<tr>
<Cell
colspan={5 +
(automateUsernameCreation ? 1 : 0) +
(activeOnly ? 1 : 0)}
>
<Button
variant="subtle"
unifiedSize="xs"
wrapperClasses="w-full justify-center"
onClick={() => {
nbDisplayed += 50
}}
>
Load next {remaining} user{remaining !== 1 ? 's' : ''}
</Button>
</Cell>
</tr>
{/if}
{/if}
</tbody>
</DataTable>
@@ -7,6 +7,9 @@
interface Props {
label?: string | undefined
/** Shown instead of `label` below the `xl` breakpoint, for groups that must keep
* their width inside a narrow table cell. The full label stays the accessible name. */
shortLabel?: string | undefined
iconOnly?: boolean
tooltip?: string | undefined
icon?: any | undefined
@@ -30,6 +33,7 @@
let {
label = undefined,
shortLabel = undefined,
iconOnly = false,
tooltip = undefined,
icon = undefined,
@@ -68,6 +72,7 @@
<button
{id}
{disabled}
aria-label={shortLabel ? label : undefined}
class={twMerge(
'group rounded-md transition-all font-normal flex gap-1 flex-row items-center justify-center border text-xs',
horizontalPadding,
@@ -75,7 +80,7 @@
'text-primary data-[state=on]:text-primary',
'data-[state=on]:bg-surface-tertiary data-[state=off]:border-transparent data-[state=on]:border-border-normal/30',
'bg-surface-transparent hover:bg-surface-hover',
disabled ? '!shadow-none' : '',
disabled ? '!shadow-none !text-disabled' : '',
className
)}
use:melt={$item(value)}
@@ -102,7 +107,12 @@
/>
{/if}
{#if label && !iconOnly}
{label}
{#if shortLabel}
<span class="hidden xl:inline">{label}</span>
<span class="xl:hidden" aria-hidden="true">{shortLabel}</span>
{:else}
{label}
{/if}
{/if}
{#if showTooltipIcon}
<Info size={iconSize} class="text-gray-400" />
@@ -161,7 +161,7 @@
<Cell head first>Email</Cell>
<Cell head>Username</Cell>
<Cell head>Role</Cell>
<Cell head last><span class="sr-only">Actions</span></Cell>
<Cell head last actions><span class="sr-only">Actions</span></Cell>
</tr>
</Head>
<tbody>
@@ -187,7 +187,7 @@
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'}
</Badge>
</Cell>
<Cell last>
<Cell last actions>
<Button
unifiedSize="sm"
variant="subtle"
@@ -823,7 +823,7 @@
</Cell>
<Cell head>Role</Cell>
<Cell head>Enabled</Cell>
<Cell head last>
<Cell head last actions>
<span class="sr-only">Actions</span>
</Cell>
</tr>
@@ -963,7 +963,7 @@
size="xs"
/>
</Cell>
<Cell>
<Cell last actions class={index % 2 === 0 ? 'bg-surface-tertiary' : ''}>
<div class="flex gap-1">
{#if user.is_service_account && $userStore?.is_admin}
<Button
@@ -1085,7 +1085,7 @@
<tr>
<Cell head first>Email</Cell>
<Cell head>Role</Cell>
<Cell head last><span class="sr-only">Actions</span></Cell>
<Cell head last actions><span class="sr-only">Actions</span></Cell>
</tr>
</Head>
<tbody class="divide-y bg-surface">
@@ -1142,7 +1142,7 @@
</ToggleButtonGroup>
</div>
</Cell>
<Cell last>
<Cell last actions>
<Button
variant="default"
destructive
@@ -1,13 +1,16 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
headerAction?: import('svelte').Snippet;
children?: import('svelte').Snippet
headerAction?: import('svelte').Snippet
}
let { children, headerAction }: Props = $props();
let { children, headerAction }: Props = $props()
</script>
<thead class="bg-surface-secondary sticky top-0 z-10 border-b">
<!-- Stuck 1px above the scroller's edge rather than at it: at fractional device scale
factors the edge and the header round to different pixels, and rows scrolling
beneath show through the seam. Clipping the header's top row hides it. -->
<thead class="bg-surface-secondary sticky -top-px z-10 border-b">
{@render children?.()}
<tr class="absolute top-2 right-2">
{@render headerAction?.()}