Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
077eb91
feat: add list instance emails endpoint and use Select in AddUser
github-actions[bot] Feb 11, 2026
fb0f003
fix: filter out existing workspace members from instance emails select
Guilhem-lm Feb 16, 2026
793bb7b
fix: restrict list_instance_emails to workspace admins
Guilhem-lm Feb 16, 2026
b54bea7
feat: add email validation and use TextInput in AddUser
Guilhem-lm Feb 16, 2026
bfb65fc
fix: allow editing custom email in Select component
Guilhem-lm Feb 16, 2026
4aa1f01
refactor: replace Select placeholder hack with autocomplete model
Guilhem-lm Feb 16, 2026
90139c7
Merge remote-tracking branch 'origin/main' into glm/select-users
Guilhem-lm Feb 16, 2026
19f30ee
Merge remote-tracking branch 'origin/main' into glm/select-users
Guilhem-lm Feb 16, 2026
1dd2b0a
refactor: extract AutocompleteSelect from Select component
Guilhem-lm Feb 16, 2026
360b43c
nit
Guilhem-lm Feb 16, 2026
9347a04
style: format test_dev select page
Guilhem-lm Feb 16, 2026
47c0277
chore: remove test_dev select page
Guilhem-lm Feb 16, 2026
d89d367
chore: update ee-repo-ref to c927593b0b49867284fc64d50c59daba6c70da84
windmill-internal-app[bot] Feb 16, 2026
13ff7f4
Merge branch 'main' into claude/issue-7902-20260211-1113
Guilhem-lm Feb 16, 2026
732aff1
Merge branch 'main' into claude/issue-7902-20260211-1113
Guilhem-lm Feb 16, 2026
2ff72e3
Merge remote-tracking branch 'origin/main' into glm/select-users
Guilhem-lm Feb 16, 2026
e11a333
fix: address PR review comments
Guilhem-lm Feb 17, 2026
f03bd04
fix: use AutocompleteSelect loading prop instead of separate branch
Guilhem-lm Feb 17, 2026
da460fa
nit
Guilhem-lm Feb 17, 2026
fa22ec6
fix: only show email validation error after dropdown closes
Guilhem-lm Feb 17, 2026
6df1e13
nit
Guilhem-lm Feb 17, 2026
945188d
Merge branch 'main' into claude/issue-7902-20260211-1113
Guilhem-lm Feb 17, 2026
09d280c
nit
Guilhem-lm Feb 17, 2026
958b8ed
fix check
Guilhem-lm Feb 17, 2026
6c226c6
chore: update ee-repo-ref to 592848d59ca2304926fb2bd85d000668a7f46a77
windmill-internal-app[bot] Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions backend/windmill-api-users/src/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ pub fn workspaced_service() -> Router {
.route("/whoami", get(whoami))
.route("/leave", post(leave_workspace))
.route("/username_to_email/:username", get(username_to_email))
.route("/list_instance_emails", get(list_instance_emails))
}

pub fn global_service() -> Router {
Expand Down Expand Up @@ -420,6 +421,22 @@ async fn list_users_as_super_admin(
Ok(Json(rows))
}

async fn list_instance_emails(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<Vec<String>> {
if *CLOUD_HOSTED {
return Err(Error::BadRequest(
"This endpoint is not available on cloud hosted instances".to_string(),
));
}
require_admin(authed.is_admin, &authed.username)?;
let rows = sqlx::query_scalar!("SELECT email FROM password ORDER BY email")
.fetch_all(&db)
.await?;
Ok(Json(rows))
}

#[derive(Serialize, Deserialize)]
struct Progress {
progress: u64,
Expand Down
18 changes: 18 additions & 0 deletions backend/windmill-api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2397,6 +2397,24 @@ paths:
items:
$ref: "#/components/schemas/GlobalUserInfo"

/w/{workspace}/users/list_instance_emails:
get:
summary: list all instance user emails (only on non-cloud instances, requires workspace admin)
operationId: listInstanceEmails
tags:
- user
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of instance user emails
content:
application/json:
schema:
type: array
items:
type: string

/w/{workspace}/workspaces/list_pending_invites:
get:
summary: list pending invites for a workspace
Expand Down
59 changes: 56 additions & 3 deletions frontend/src/lib/components/AddUser.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { UserPlus } from 'lucide-svelte'
import AutocompleteSelect from './select/AutocompleteSelect.svelte'
import InputError from './InputError.svelte'
import TextInput from './text_input/TextInput.svelte'

const dispatch = createEventDispatcher()

let { workspaceEmails = [] }: { workspaceEmails?: string[] } = $props()

let email: string | undefined = $state()
let username: string | undefined = $state()

Expand All @@ -31,6 +36,26 @@
}
getAutomateUsernameCreationSetting()

let allInstanceEmails: string[] | undefined = $state(undefined)

let instanceEmails = $derived.by(() => {
if (!allInstanceEmails) return undefined
const workspaceSet = new Set(workspaceEmails)
return allInstanceEmails
.filter((e) => !workspaceSet.has(e))
.map((e) => ({ label: e, value: e }))
})

async function loadInstanceEmails() {
if (isCloudHosted()) return
try {
allInstanceEmails = await UserService.listInstanceEmails({ workspace: $workspaceStore! })
} catch {
allInstanceEmails = undefined
}
}
loadInstanceEmails()

async function addUser() {
await WorkspaceService.addUser({
workspace: $workspaceStore!,
Expand Down Expand Up @@ -70,6 +95,12 @@
dispatch('new')
}

let emailError = $derived.by(() => {
if (!email) return undefined
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email) ? undefined : 'Please enter a valid email address'
})

let selected: 'operator' | 'developer' | 'admin' = $state('developer')
</script>

Expand All @@ -84,11 +115,31 @@
<span class="text-sm mb-2 leading-6 font-semibold">Add a new user</span>

<span class="text-xs mb-1 leading-6">Email</span>
<input type="email mb-1" onkeyup={handleKeyUp} placeholder="email" bind:value={email} />
{#if instanceEmails}
<AutocompleteSelect
items={instanceEmails}
bind:value={email}
placeholder="Select or type an email"
disablePortal={true}
error={!!emailError}
/>
{:else}
<TextInput
inputProps={{ type: 'email', onkeyup: handleKeyUp, placeholder: 'email' }}
bind:value={email}
error={!!emailError}
/>
{/if}
{#if emailError}
<InputError error={emailError} />
{/if}

{#if !automateUsernameCreation}
<span class="text-xs mb-1 pt-2 leading-6">Username</span>
<input type="text" onkeyup={handleKeyUp} placeholder="username" bind:value={username} />
<TextInput
inputProps={{ type: 'text', onkeyup: handleKeyUp, placeholder: 'username' }}
bind:value={username}
/>
{/if}

<span class="text-xs mb-1 pt-6 leading-6">Role</span>
Expand Down Expand Up @@ -125,7 +176,9 @@
username = undefined
})
}}
disabled={email === undefined || (!automateUsernameCreation && username === undefined)}
disabled={email === undefined ||
!!emailError ||
(!automateUsernameCreation && username === undefined)}
>
Add
</Button>
Expand Down
178 changes: 178 additions & 0 deletions frontend/src/lib/components/select/AutocompleteSelect.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<script lang="ts">
import { clickOutside } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import CloseButton from '../common/CloseButton.svelte'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import { getLabel, processItems, type ProcessedItem } from './utils.svelte'
import SelectDropdown from './SelectDropdown.svelte'
import {
inputBaseClass,
inputBorderClass,
inputSizeClasses
} from '../text_input/TextInput.svelte'
import { ButtonType } from '../common/button/model'

type Item = { label?: string; value: string; subtitle?: string; disabled?: boolean }

let {
items,
placeholder = 'Please select',
value = $bindable(),
class: className = '',
clearable = false,
disabled: _disabled = false,
containerStyle = '',
inputClass = '',
disablePortal = false,
loading = false,
error = false,
autofocus,
noItemsMsg,
id,
size = 'md',
transformInputSelectedText,
onFocus,
onBlur,
onClear
}: {
items?: Item[]
value: string | undefined
placeholder?: string
class?: string
clearable?: boolean
disabled?: boolean
containerStyle?: string
inputClass?: string
disablePortal?: boolean
loading?: boolean
error?: boolean
autofocus?: boolean
noItemsMsg?: string
id?: string
size?: 'sm' | 'md' | 'lg'
transformInputSelectedText?: (text: string) => string
onFocus?: () => void
onBlur?: () => void
onClear?: () => void
} = $props()

let disabled = $derived(_disabled || (loading && !value))
let iconSize = $derived(ButtonType.UnifiedIconSizes[size])

let inputEl: HTMLInputElement | undefined = $state()
let open = $state(false)
let filterText = $state('')

let processedItems: ProcessedItem<string>[] = $derived.by(() => {
let args = { items, filterText }
return untrack(() => processItems(args))
})

let rawLabel = $derived.by(() => {
let entry = value ? processedItems?.find((item) => item.value === value) : undefined
return entry?.label ?? getLabel({ value }) ?? ''
})

let displayText = $derived(transformInputSelectedText?.(rawLabel) ?? rawLabel)

let inputValue = $derived(open ? filterText : displayText)

let hasFilteredItems = $derived(
!filterText ||
processedItems?.some((item) => item.label?.toLowerCase().includes(filterText.toLowerCase()))
)

let dropdownVisible = $derived(open && hasFilteredItems)

$effect(() => {
if (filterText) open = true
})

$effect(() => {
if (!open) {
filterText = ''
} else {
untrack(() => {
if (rawLabel) {
filterText = rawLabel
}
})
}
})

function setValue(item: ProcessedItem<string>) {
value = item.value
filterText = ''
open = false
}

function clearValue() {
filterText = ''
if (onClear) onClear()
else value = undefined
}
</script>

<div
class={`relative ${className}`}
use:clickOutside={{ onClickOutside: () => (open = false) }}
onpointerdown={() => onFocus?.()}
onfocus={() => onFocus?.()}
onblur={() => onBlur?.()}
>
{#if loading}
<div class="absolute z-10 right-2 h-full flex items-center">
<Loader2 size={iconSize} class="animate-spin" />
</div>
{:else if clearable && !disabled && value}
<div class="absolute z-10 right-2 h-full flex items-center">
<CloseButton
class="bg-transparent text-secondary hover:text-primary"
noBg
small
onClick={clearValue}
/>
</div>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
{autofocus}
{disabled}
type="text"
value={inputValue}
placeholder={loading && !value ? 'Loading...' : placeholder}
style={containerStyle}
class={twMerge(
inputBaseClass,
inputSizeClasses[size],
ButtonType.UnifiedHeightClasses[size],
inputBorderClass({ error, forceFocus: open }),
'w-full',
open ? '' : 'cursor-pointer',
'placeholder-hint',
clearable && !disabled && value ? 'pr-8' : '',
inputClass ?? ''
)}
autocomplete="off"
oninput={(e) => {
if (!open) open = true
filterText = e.currentTarget.value
value = filterText || undefined
}}
onpointerdown={() => (open = true)}
bind:this={inputEl}
{id}
/>
<SelectDropdown
{disablePortal}
onSelectValue={setValue}
open={dropdownVisible}
{processedItems}
{value}
{disabled}
{filterText}
getInputRect={inputEl && (() => inputEl!.getBoundingClientRect())}
{noItemsMsg}
/>
</div>
2 changes: 1 addition & 1 deletion frontend/src/lib/components/select/Select.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@
class="bg-transparent text-secondary hover:text-primary"
noBg
small
on:close={clearValue}
onClick={clearValue}
/>
</div>
{:else if RightIcon}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@
{/if}

<AddUser
workspaceEmails={users?.map((u) => u.email) ?? []}
on:new={() => {
listUsers()
listInvites()
Expand Down
Loading