Compare commits
20 Commits
wmill-scri
...
glm/select
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa22ec69d8 | ||
|
|
da460fa6b4 | ||
|
|
f03bd04cac | ||
|
|
e11a333998 | ||
|
|
2ff72e34dd | ||
|
|
732aff1d5c | ||
|
|
13ff7f418e | ||
|
|
d89d36728b | ||
|
|
47c027716b | ||
|
|
9347a04ed5 | ||
|
|
360b43caba | ||
|
|
1dd2b0a1b2 | ||
|
|
19f30ee6d5 | ||
|
|
90139c7754 | ||
|
|
4aa1f01210 | ||
|
|
bfb65fc8b4 | ||
|
|
b54bea7192 | ||
|
|
793bb7b405 | ||
|
|
fb0f0031d9 | ||
|
|
077eb91c0f |
20
backend/.sqlx/query-f11d3a43f804a33e412a69be54824d7666d4dac0b824095c4c4fefb1f802c6f7.json
generated
Normal file
20
backend/.sqlx/query-f11d3a43f804a33e412a69be54824d7666d4dac0b824095c4c4fefb1f802c6f7.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ pub fn workspaced_service() -> Router {
|
|||||||
.route("/whoami", get(whoami))
|
.route("/whoami", get(whoami))
|
||||||
.route("/leave", post(leave_workspace))
|
.route("/leave", post(leave_workspace))
|
||||||
.route("/username_to_email/:username", get(username_to_email))
|
.route("/username_to_email/:username", get(username_to_email))
|
||||||
|
.route("/list_instance_emails", get(list_instance_emails))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn global_service() -> Router {
|
pub fn global_service() -> Router {
|
||||||
@@ -420,6 +421,22 @@ async fn list_users_as_super_admin(
|
|||||||
Ok(Json(rows))
|
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)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct Progress {
|
struct Progress {
|
||||||
progress: u64,
|
progress: u64,
|
||||||
|
|||||||
@@ -2397,6 +2397,24 @@ paths:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/GlobalUserInfo"
|
$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:
|
/w/{workspace}/workspaces/list_pending_invites:
|
||||||
get:
|
get:
|
||||||
summary: list pending invites for a workspace
|
summary: list pending invites for a workspace
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher, untrack } from 'svelte'
|
||||||
import { globalEmailInvite, superadmin, workspaceStore } from '$lib/stores'
|
import { globalEmailInvite, superadmin, workspaceStore } from '$lib/stores'
|
||||||
import { SettingService, UserService, WorkspaceService } from '$lib/gen'
|
import { SettingService, UserService, WorkspaceService } from '$lib/gen'
|
||||||
import { Button } from './common'
|
import { Button } from './common'
|
||||||
@@ -10,9 +10,14 @@
|
|||||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||||
import { UserPlus } from 'lucide-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()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
let { workspaceEmails = [] }: { workspaceEmails?: string[] } = $props()
|
||||||
|
|
||||||
let email: string | undefined = $state()
|
let email: string | undefined = $state()
|
||||||
let username: string | undefined = $state()
|
let username: string | undefined = $state()
|
||||||
|
|
||||||
@@ -31,6 +36,36 @@
|
|||||||
}
|
}
|
||||||
getAutomateUsernameCreationSetting()
|
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() {
|
async function addUser() {
|
||||||
await WorkspaceService.addUser({
|
await WorkspaceService.addUser({
|
||||||
workspace: $workspaceStore!,
|
workspace: $workspaceStore!,
|
||||||
@@ -70,6 +105,13 @@
|
|||||||
dispatch('new')
|
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')
|
let selected: 'operator' | 'developer' | 'admin' = $state('developer')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -84,11 +126,38 @@
|
|||||||
<span class="text-sm mb-2 leading-6 font-semibold">Add a new user</span>
|
<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>
|
<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}
|
{#if !automateUsernameCreation}
|
||||||
<span class="text-xs mb-1 pt-2 leading-6">Username</span>
|
<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}
|
{/if}
|
||||||
|
|
||||||
<span class="text-xs mb-1 pt-6 leading-6">Role</span>
|
<span class="text-xs mb-1 pt-6 leading-6">Role</span>
|
||||||
@@ -125,7 +194,9 @@
|
|||||||
username = undefined
|
username = undefined
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
disabled={email === undefined || (!automateUsernameCreation && username === undefined)}
|
disabled={email === undefined ||
|
||||||
|
!!emailError ||
|
||||||
|
(!automateUsernameCreation && username === undefined)}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
181
frontend/src/lib/components/select/AutocompleteSelect.svelte
Normal file
181
frontend/src/lib/components/select/AutocompleteSelect.svelte
Normal 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>
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
class="bg-transparent text-secondary hover:text-primary"
|
class="bg-transparent text-secondary hover:text-primary"
|
||||||
noBg
|
noBg
|
||||||
small
|
small
|
||||||
on:close={clearValue}
|
onClick={clearValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if RightIcon}
|
{:else if RightIcon}
|
||||||
|
|||||||
@@ -724,6 +724,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<AddUser
|
<AddUser
|
||||||
|
workspaceEmails={users?.map((u) => u.email) ?? []}
|
||||||
on:new={() => {
|
on:new={() => {
|
||||||
listUsers()
|
listUsers()
|
||||||
listInvites()
|
listInvites()
|
||||||
|
|||||||
Reference in New Issue
Block a user