Compare commits

...

20 Commits

Author SHA1 Message Date
Guilhem
fa22ec69d8 fix: only show email validation error after dropdown closes
Add onClose callback to AutocompleteSelect and use it in AddUser
to defer validation until the user finishes interacting with the
dropdown, avoiding visual noise between the dropdown and error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:33:22 +00:00
Guilhem
da460fa6b4 nit 2026-02-17 09:27:57 +00:00
Guilhem
f03bd04cac fix: use AutocompleteSelect loading prop instead of separate branch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:59:01 +00:00
Guilhem
e11a333998 fix: address PR review comments
- Add LIMIT 1000 to list_instance_emails SQL query
- Add loading state to prevent UI flash in AddUser
- Show email validation error only after blur
- Guard loadInstanceEmails with workspaceStore check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:20:14 +00:00
Guilhem
2ff72e34dd Merge remote-tracking branch 'origin/main' into glm/select-users 2026-02-16 18:38:22 +00:00
Guilhem
732aff1d5c Merge branch 'main' into claude/issue-7902-20260211-1113 2026-02-16 16:32:49 +00:00
Guilhem
13ff7f418e Merge branch 'main' into claude/issue-7902-20260211-1113 2026-02-16 15:50:33 +00:00
windmill-internal-app[bot]
d89d36728b chore: update ee-repo-ref to c927593b0b49867284fc64d50c59daba6c70da84
This commit updates the EE repository reference after PR #415 was merged in windmill-ee-private.

Previous ee-repo-ref: 8c214ec5039be5353f5fea920e27b0c6af61e1fd

New ee-repo-ref: c927593b0b49867284fc64d50c59daba6c70da84

Automated by sync-ee-ref workflow.
2026-02-16 15:48:08 +00:00
Guilhem
47c027716b chore: remove test_dev select page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:39:18 +00:00
Guilhem
9347a04ed5 style: format test_dev select page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:38:41 +00:00
Guilhem
360b43caba nit 2026-02-16 15:38:22 +00:00
Guilhem
1dd2b0a1b2 refactor: extract AutocompleteSelect from Select component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:31:22 +00:00
Guilhem
19f30ee6d5 Merge remote-tracking branch 'origin/main' into glm/select-users 2026-02-16 15:01:41 +00:00
Guilhem
90139c7754 Merge remote-tracking branch 'origin/main' into glm/select-users 2026-02-16 14:51:51 +00:00
Guilhem
4aa1f01210 refactor: replace Select placeholder hack with autocomplete model
Replace the bind:value getter/setter and placeholder-as-display hack with
a standard controlled input pattern (value={inputValue} + oninput handler).

- Remove showPlaceholderOnOpen prop (replaced by default behavior)
- Add allowUserInput prop: typed text syncs to value in real-time
- Separate rawLabel (for filtering) from displayText (for transformed display)
- Hide dropdown when no items match in allowUserInput mode
- Simplify AddUser to use allowUserInput instead of createText/onCreateItem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 14:51:06 +00:00
Guilhem
bfb65fc8b4 fix: allow editing custom email in Select component
When a custom value (not in the items list) is selected, reopening
the dropdown now pre-fills the filter with the current value so users
can directly edit it. Also hide the "Use custom email" create option
when the filter matches the current value, and show the regular
placeholder instead of the value when editing a custom entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:52:54 +00:00
Guilhem
b54bea7192 feat: add email validation and use TextInput in AddUser
Add client-side email format validation with error message display
and disable the Add button when email is invalid. Replace plain
<input> elements with TextInput component for consistent styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:32:31 +00:00
Guilhem
793bb7b405 fix: restrict list_instance_emails to workspace admins
Move the endpoint from global to workspace-scoped route
(/w/{workspace}/users/list_instance_emails) and add require_admin
check so only workspace admins can list instance emails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:54:06 +00:00
Guilhem
fb0f0031d9 fix: filter out existing workspace members from instance emails select
Also fixes "Add new userss" typo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:25:15 +00:00
claude[bot]
077eb91c0f feat: add list instance emails endpoint and use Select in AddUser
Add a new GET /users/list_instance_emails endpoint that returns all
instance user emails (from the password table). This endpoint is only
available on non-cloud instances.

In the frontend AddUser component, when the instance emails are
available (non-cloud), replace the email text input with a Select
component that allows both selecting from existing instance users and
typing custom emails.

Closes #7902

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-02-11 11:24:13 +00:00
7 changed files with 313 additions and 5 deletions

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM password ORDER BY email LIMIT 1000",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "f11d3a43f804a33e412a69be54824d7666d4dac0b824095c4c4fefb1f802c6f7"
}

View File

@@ -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 {
@@ -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 LIMIT 1000")
.fetch_all(&db)
.await?;
Ok(Json(rows))
}
#[derive(Serialize, Deserialize)]
struct Progress {
progress: u64,

View File

@@ -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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, untrack } from 'svelte'
import { globalEmailInvite, superadmin, workspaceStore } from '$lib/stores'
import { SettingService, UserService, WorkspaceService } from '$lib/gen'
import { Button } from './common'
@@ -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()
@@ -31,6 +36,36 @@
}
getAutomateUsernameCreationSetting()
let allInstanceEmails: string[] | undefined = $state(undefined)
let emailsLoading = $state(!isCloudHosted())
let instanceEmails = $derived.by(() => {
if (!allInstanceEmails) return []
const workspaceSet = new Set(workspaceEmails)
return allInstanceEmails
.filter((e) => !workspaceSet.has(e))
.map((e) => ({ label: e, value: e }))
})
async function loadInstanceEmails() {
if (isCloudHosted() || !$workspaceStore) return
try {
allInstanceEmails = await UserService.listInstanceEmails({ workspace: $workspaceStore })
} catch {
allInstanceEmails = undefined
} finally {
emailsLoading = false
}
}
$effect(() => {
if ($workspaceStore) {
untrack(() => {
loadInstanceEmails()
})
}
})
async function addUser() {
await WorkspaceService.addUser({
workspace: $workspaceStore!,
@@ -70,6 +105,13 @@
dispatch('new')
}
let emailTouched = $state(false)
let emailError = $derived.by(() => {
if (!email || !emailTouched) 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>
@@ -84,11 +126,38 @@
<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 !isCloudHosted()}
<AutocompleteSelect
items={instanceEmails}
bind:value={email}
placeholder={emailsLoading ? 'Loading...' : 'Select or type an email'}
loading={emailsLoading}
disablePortal={true}
error={!!emailError}
onClose={() => (emailTouched = true)}
/>
{:else}
<TextInput
inputProps={{
type: 'email',
onkeyup: handleKeyUp,
placeholder: 'email',
onblur: () => (emailTouched = true)
}}
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>
@@ -125,7 +194,9 @@
username = undefined
})
}}
disabled={email === undefined || (!automateUsernameCreation && username === undefined)}
disabled={email === undefined ||
!!emailError ||
(!automateUsernameCreation && username === undefined)}
>
Add
</Button>

View File

@@ -0,0 +1,181 @@
<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,
onClose,
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
onClose?: () => 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 = ''
onClose?.()
} 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>

View File

@@ -147,7 +147,7 @@
class="bg-transparent text-secondary hover:text-primary"
noBg
small
on:close={clearValue}
onClick={clearValue}
/>
</div>
{:else if RightIcon}

View File

@@ -724,6 +724,7 @@
{/if}
<AddUser
workspaceEmails={users?.map((u) => u.email) ?? []}
on:new={() => {
listUsers()
listInvites()