feat: include service accounts in instance settings users list (#9157)

* feat: include service accounts in instance settings users list

Service accounts (workspace-scoped, no password row) now appear in the
superadmin users list with a Bot icon, workspace badge, and a link to
manage them in the workspace settings. Role is locked to Operator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update sqlx offline cache

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: use composite key for users each block

Service accounts can share emails across workspaces, so key by email + workspace_id to avoid Svelte each_key_duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-05-14 06:46:48 +00:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 4f3a1e3109
commit e5286f4607
6 changed files with 530 additions and 184 deletions
@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "devops",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "verified",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "first_time_user",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "role_source",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null,
false,
false,
false,
true,
true,
true,
null,
false,
false,
false,
null
]
},
"hash": "0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328"
}
@@ -0,0 +1,95 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, true as operator_only, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "verified!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "super_admin!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "devops!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "first_time_user!",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "role_source!",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled!",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37"
}
@@ -0,0 +1,95 @@
{
"db_name": "PostgreSQL",
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", true as operator_only, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "verified!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "super_admin!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "devops!",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "first_time_user!",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "role_source!",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled!",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef"
}
+20 -7
View File
@@ -210,6 +210,8 @@ pub struct GlobalUserInfo {
first_time_user: bool,
role_source: String,
disabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
workspace_id: Option<String>,
}
#[derive(Serialize, Debug)]
@@ -451,13 +453,17 @@ async fn list_users_as_super_admin(
let rows = if active_only.is_some_and(|x| x) {
sqlx::query_as!(
GlobalUserInfo,
"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),
r#"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),
authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)
SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled
SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id
FROM password
WHERE email IN (SELECT email FROM active_users)
ORDER BY super_admin DESC, devops DESC
LIMIT $1 OFFSET $2",
UNION ALL
SELECT email as "email!", true as operator_only, 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id
FROM usr
WHERE is_service_account IS true
ORDER BY "super_admin!" DESC, "devops!" DESC
LIMIT $1 OFFSET $2"#,
per_page as i32,
offset as i32
)
@@ -466,8 +472,13 @@ async fn list_users_as_super_admin(
} else {
sqlx::query_as!(
GlobalUserInfo,
"SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \
$1 OFFSET $2",
r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password
UNION ALL
SELECT email as "email!", 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, true as operator_only, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id
FROM usr
WHERE is_service_account IS true
ORDER BY "super_admin!" DESC, "devops!" DESC, "email!"
LIMIT $1 OFFSET $2"#,
per_page as i32,
offset as i32
)
@@ -716,7 +727,7 @@ async fn global_whoami(
) -> JsonResult<GlobalUserInfo> {
let user = sqlx::query_as!(
GlobalUserInfo,
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE \
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \
email = $1",
email
)
@@ -740,6 +751,7 @@ async fn global_whoami(
first_time_user: false,
role_source: "manual".to_string(),
disabled: false,
workspace_id: None,
}))
} else {
// Service accounts don't have a password row
@@ -756,6 +768,7 @@ async fn global_whoami(
first_time_user: false,
role_source: "service_account".to_string(),
disabled: false,
workspace_id: None,
}))
}
}
+4 -2
View File
@@ -26363,7 +26363,7 @@ components:
type: string
login_type:
type: string
enum: ["password", "github"]
enum: ["password", "github", "service_account"]
super_admin:
type: boolean
devops:
@@ -26382,9 +26382,11 @@ components:
type: boolean
role_source:
type: string
enum: ["manual", "instance_group"]
enum: ["manual", "instance_group", "service_account"]
disabled:
type: boolean
workspace_id:
type: string
required:
- email
@@ -21,12 +21,15 @@
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'
@@ -219,7 +222,16 @@
{filter}
items={users}
bind:filteredItems={filteredUsers}
f={(x) => x.email + ' ' + x.name + ' ' + x.company}
f={(x) =>
(x.email ?? '') +
' ' +
(x.name ?? '') +
' ' +
(x.company ?? '') +
' ' +
(x.username ?? '') +
' ' +
(x.workspace_id ?? '')}
/>
<div class="flex flex-col h-full w-full">
@@ -393,7 +405,8 @@
</Head>
<tbody>
{#if filteredUsers && users}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled }, i (email)}
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, 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'
@@ -401,9 +414,22 @@
>
<Cell first class="max-w-[250px]">
<div class="flex items-center gap-1.5">
<a href="mailto:{email}" title={email} class="truncate block"
>{email}</a
>
{#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"
@@ -449,184 +475,205 @@
</Cell>
{/if}
<Cell>
<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)
{#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"
>
Operator
</span>
<Tooltip>Service accounts are always operators.</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)
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>
}}
>
{#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">
<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)
{#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)
})
}
} 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
}
}
},
{
displayName: 'Reassign',
icon: ArrowRightLeft,
action: () => {
offboardingEmail = email
offboardingReassignOnly = true
}
},
{
displayName: 'Remove',
icon: UserMinus,
type: 'delete',
action: () => {
offboardingEmail = email
offboardingReassignOnly = false
}
}
]}
/>
]}
/>
{/if}
</div>
</Cell>
</tr>