mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
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:
co-authored by
Claude Opus 4.6
windmill-internal-app[bot]
parent
4fff89f98c
commit
ce3e676f4a
+72
@@ -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"
|
||||
}
|
||||
+21
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
+68
@@ -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 @@
|
||||
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[];
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<'_>>>,
|
||||
|
||||
@@ -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 "User" 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 "User" 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>
|
||||
|
||||
Reference in New Issue
Block a user