feat: list external JWT tokens in instance settings (#8783)

* [ee] feat: add external JWT tokens listing in instance settings

Add the ability for superadmins to view all external JWT tokens that have
been used for authentication, along with their claim metadata.

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

* chore: update ee-repo-ref.txt

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

* refactor: move external JWT tokens listing to users tab

- Move list endpoint from /oidc/ext_jwt_tokens to /users/ext_jwt_tokens
- Display as a sub-tab below the instance Users tab, only shown when tokens exist
- Use DataTable's built-in load-more pattern for pagination
- Add "Recently active only" toggle (tokens used in the last 30 days)

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

* feat: add dev_override cargo feature to windmill-common

* feat: show placeholder for legacy external JWT entries

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

* chore: update ee-repo-ref to 62a462461271b900351c18b0ab1ca78651154b2a

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

Previous ee-repo-ref: 7b493a337abe00a47cf9d94847babe3cb3a6799f

New ee-repo-ref: 62a462461271b900351c18b0ab1ca78651154b2a

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-04-10 13:11:00 +00:00
committed by GitHub
co-authored by Claude Opus 4.6 windmill-internal-app[bot]
parent 4fff89f98c
commit ce3e676f4a
14 changed files with 750 additions and 316 deletions
@@ -0,0 +1,72 @@
{
"db_name": "PostgreSQL",
"query": "SELECT jwt_hash, email, username, is_admin, is_operator, workspace_id, label, scopes, last_used_at\n FROM unique_ext_jwt_token\n WHERE NOT $3 OR last_used_at > NOW() - INTERVAL '30 days'\n ORDER BY last_used_at DESC\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "jwt_hash",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "is_operator",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "workspace_id",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "label",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 8,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Bool"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "235e9f3afc4127f81fd0d09e1890b9069de29c7a1bf3d59a4ecb5db9062de316"
}
@@ -0,0 +1,21 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO unique_ext_jwt_token (jwt_hash, last_used_at, email, username, is_admin, is_operator, workspace_id, label, scopes)\n VALUES ($1, NOW(), $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (jwt_hash)\n DO UPDATE SET last_used_at = NOW(), email = $2, username = $3, is_admin = $4, is_operator = $5, workspace_id = $6, label = $7, scopes = $8",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Text",
"Bool",
"Bool",
"Text",
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "3474805749cc2c0ccee995690e7a83bad2af8f3f57a0b5b713e38db473a38507"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO unique_ext_jwt_token (jwt_hash, last_used_at)\n VALUES ($1, NOW())\n ON CONFLICT (jwt_hash)\n DO UPDATE SET last_used_at = NOW()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b"
}
@@ -0,0 +1,68 @@
{
"db_name": "PostgreSQL",
"query": "SELECT jwt_hash, email, username, is_admin, is_operator, workspace_id, label, scopes, last_used_at\n FROM unique_ext_jwt_token\n WHERE last_used_at > NOW() - INTERVAL '30 days'\n ORDER BY last_used_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "jwt_hash",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "is_operator",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "workspace_id",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "label",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 8,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "c547516873b02e06d229b1c7f8e6619f1e1300f9f9969911608f690b78d93099"
}
+1 -1
View File
@@ -1 +1 @@
d9d68c2406df0b59f413ea0b2cb24780a9817d04
62a462461271b900351c18b0ab1ca78651154b2a
@@ -0,0 +1,10 @@
-- Remove metadata columns from unique_ext_jwt_token
ALTER TABLE unique_ext_jwt_token
DROP COLUMN IF EXISTS email,
DROP COLUMN IF EXISTS username,
DROP COLUMN IF EXISTS is_admin,
DROP COLUMN IF EXISTS is_operator,
DROP COLUMN IF EXISTS workspace_id,
DROP COLUMN IF EXISTS label,
DROP COLUMN IF EXISTS scopes;
@@ -0,0 +1,10 @@
-- Add metadata columns to unique_ext_jwt_token for listing external JWTs
ALTER TABLE unique_ext_jwt_token
ADD COLUMN IF NOT EXISTS email TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS username TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS is_operator BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS workspace_id TEXT,
ADD COLUMN IF NOT EXISTS label TEXT,
ADD COLUMN IF NOT EXISTS scopes TEXT[];
+1 -1
View File
@@ -161,7 +161,7 @@ token: token_hash(char), token_prefix(char), token(char), label(char), expiratio
token_expiry_notification: token_hash(char), expiration(ts)
INDEX: idx_token_expiry_notification_expiration (expiration)
tutorial_progress: email(char), progress(bit64), skipped_all(bool)
unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts)
unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts), email(text), username(text), is_admin(bool), is_operator(bool), workspace_id(text?), label(text?), scopes(text[]?)
usage: id(char), is_workspace(bool), month_(int), usage(int)
usr: workspace_id(char), username(char), email(char), is_admin(bool), created_at(ts), operator(bool), disabled(bool), role(char), added_via(jsonb)
FK: (workspace_id) -> workspace(id)
+63
View File
@@ -726,6 +726,36 @@ paths:
items:
$ref: "#/components/schemas/ExportedUser"
/users/ext_jwt_tokens:
get:
summary: list external JWT tokens (ee only)
operationId: listExtJwtTokens
tags:
- user
parameters:
- name: page
in: query
schema:
type: integer
- name: per_page
in: query
schema:
type: integer
- name: active_only
in: query
description: only tokens used in the last 30 days
schema:
type: boolean
responses:
"200":
description: list of external JWT tokens
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ExternalJwtToken"
/users/onboarding:
post:
summary: Submit user onboarding data
@@ -21469,6 +21499,39 @@ components:
- created_at
- last_used_at
ExternalJwtToken:
type: object
properties:
jwt_hash:
type: integer
format: int64
email:
type: string
username:
type: string
is_admin:
type: boolean
is_operator:
type: boolean
workspace_id:
type: string
label:
type: string
scopes:
type: array
items:
type: string
last_used_at:
type: string
format: date-time
required:
- jwt_hash
- email
- username
- is_admin
- is_operator
- last_used_at
NewToken:
type: object
properties:
+52 -1
View File
@@ -15,7 +15,7 @@ use crate::db::ApiAuthed;
use crate::secret_backend_ext::rename_vault_secrets_with_prefix;
use argon2::Argon2;
use axum::{
extract::{Extension, Path},
extract::{Extension, Path, Query},
routing::{get, post},
Json, Router,
};
@@ -52,6 +52,7 @@ pub fn global_service() -> Router {
.route("/create", post(create_user))
.route("/rename/{user}", post(rename_user))
.route("/onboarding", post(submit_onboarding_data))
.route("/ext_jwt_tokens", get(list_ext_jwt_tokens))
.route(
"/offboard_preview/{user}",
get(crate::offboarding::global_offboard_preview),
@@ -86,6 +87,56 @@ async fn submit_onboarding_data(
crate::users_oss::submit_onboarding_data(authed, Extension(db), Json(data)).await
}
#[derive(serde::Serialize)]
pub struct ExternalJwtToken {
pub jwt_hash: i64,
pub email: String,
pub username: String,
pub is_admin: bool,
pub is_operator: bool,
pub workspace_id: Option<String>,
pub label: Option<String>,
pub scopes: Option<Vec<String>>,
pub last_used_at: chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Deserialize)]
struct ListExtJwtTokensQuery {
page: Option<usize>,
per_page: Option<usize>,
#[serde(default)]
active_only: bool,
}
async fn list_ext_jwt_tokens(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Query(query): Query<ListExtJwtTokensQuery>,
) -> Result<Json<Vec<ExternalJwtToken>>> {
require_super_admin(&db, &authed.email).await?;
let (per_page, offset) = windmill_common::utils::paginate(windmill_common::utils::Pagination {
page: query.page,
per_page: query.per_page,
});
let rows = sqlx::query_as!(
ExternalJwtToken,
"SELECT jwt_hash, email, username, is_admin, is_operator, workspace_id, label, scopes, last_used_at
FROM unique_ext_jwt_token
WHERE NOT $3 OR last_used_at > NOW() - INTERVAL '30 days'
ORDER BY last_used_at DESC
LIMIT $1 OFFSET $2",
per_page as i64,
offset as i64,
query.active_only,
)
.fetch_all(&db)
.await?;
Ok(Json(rows))
}
async fn set_password(
Extension(db): Extension<DB>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
+1
View File
@@ -21,6 +21,7 @@ otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep
smtp = ["dep:mail-send"]
scoped_cache = []
cloud = []
dev_override = []
openidconnect = ["dep:openidconnect"]
bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"]
python = ["dep:windmill-parser-py"]
@@ -1,5 +1,6 @@
<script lang="ts">
import { UserService, type GlobalUserInfo, SettingService } from '$lib/gen'
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'
@@ -49,6 +50,7 @@
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('')
@@ -89,6 +91,31 @@
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(() => {
@@ -291,312 +318,337 @@
</div>
{/if}
<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'
}}
/>
{#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}
<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, role_source, disabled }, i (email)}
<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">
<a href="mailto:{email}" title={email} class="truncate block"
>{email}</a
>
{#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 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, role_source, disabled }, i (email)}
<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">
<a href="mailto:{email}" title={email} class="truncate block"
>{email}</a
>
{/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=""
{#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 operator_only}
Operator only
{:else}
Developer
{/if}
</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)
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>
</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}
isConflict
on:refresh={() => {
listUsers(activeOnly)
}}
on:save={(e) => {
updateName(e.detail, email)
}}
on:renamed={() => {
listUsers(activeOnly)
}}
{automateUsernameCreation}
/>
{/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 operator_only}
Operator only
{:else}
Developer
{/if}
</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)
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>
</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)
</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
}
}
]}
/>
</div>
</Cell>
</tr>
{/each}
{/if}
</tbody>
</DataTable>
</div>
]}
/>
</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
@@ -0,0 +1,97 @@
<script lang="ts">
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 SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import type { ExternalJwtToken } from '$lib/gen'
import { displayDate } from '$lib/utils'
import { Check, X } from 'lucide-svelte'
interface Props {
tokens: ExternalJwtToken[]
hasMore: boolean
loading: boolean
activeOnly: boolean
onLoadMore: () => void
onActiveOnlyChange: (v: boolean) => void
}
let { tokens, hasMore, loading, activeOnly, onLoadMore, onActiveOnlyChange }: Props = $props()
const loadMoreSize = 50
</script>
<SettingsPageHeader
title="External JWTs"
description="External JWT tokens that have authenticated against this instance, deduplicated by their claims."
/>
<div class="flex flex-row gap-2 items-center mb-2">
<Toggle
checked={activeOnly}
on:change={(e) => onActiveOnlyChange(e.detail)}
options={{
left: 'Recently active only',
leftTooltip: 'Show only tokens used in the last 30 days'
}}
/>
</div>
<DataTable
shouldLoadMore={hasMore}
loadMore={loadMoreSize}
{loading}
on:loadMore={() => onLoadMore()}
>
<Head>
<tr>
<Cell head first>Email</Cell>
<Cell head>Username</Cell>
<Cell head>Admin</Cell>
<Cell head>Operator</Cell>
<Cell head>Workspace</Cell>
<Cell head>Label</Cell>
<Cell head>Scopes</Cell>
<Cell head last>Last Used</Cell>
</tr>
</Head>
<tbody>
{#each tokens as token, i (token.jwt_hash)}
<tr class={i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'}>
{#if token.email === ''}
<Cell first colspan={7}>
<span class="text-tertiary italic">Legacy entry — details unavailable</span>
</Cell>
<Cell last><span class="whitespace-nowrap">{displayDate(token.last_used_at)}</span></Cell>
{:else}
<Cell first><span class="font-mono text-xs">{token.email}</span></Cell>
<Cell>{token.username}</Cell>
<Cell>
{#if token.is_admin}
<Check size={14} class="text-green-600" />
{:else}
<X size={14} class="text-tertiary" />
{/if}
</Cell>
<Cell>
{#if token.is_operator}
<Check size={14} class="text-green-600" />
{:else}
<X size={14} class="text-tertiary" />
{/if}
</Cell>
<Cell>{token.workspace_id ?? '-'}</Cell>
<Cell>{token.label ?? '-'}</Cell>
<Cell>
{#if token.scopes && token.scopes.length > 0}
{token.scopes.join(', ')}
{:else}
-
{/if}
</Cell>
<Cell last><span class="whitespace-nowrap">{displayDate(token.last_used_at)}</span></Cell>
{/if}
</tr>
{/each}
</tbody>
</DataTable>
@@ -351,7 +351,10 @@
)
} catch (e: any) {
const msg = e?.body?.message || e?.body || e?.message || 'An error occurred'
if (typeof msg === 'string' && msg.includes('User creation is not implemented in the open-source version')) {
if (
typeof msg === 'string' &&
msg.includes('User creation is not implemented in the open-source version')
) {
ossAccountError = msg
showOssAccountDialog = true
} else {
@@ -723,8 +726,8 @@
{ossAccountError}
</Alert>
<span>
Click "Continue" to finish setup and log in with the default credentials
(admin@windmill.dev / changeme).
Click "Continue" to finish setup and log in with the default credentials (admin@windmill.dev
/ changeme).
</span>
</div>
</ConfirmationModal>