Compare commits
20 Commits
fix/inline
...
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("/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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
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"
|
||||
noBg
|
||||
small
|
||||
on:close={clearValue}
|
||||
onClick={clearValue}
|
||||
/>
|
||||
</div>
|
||||
{:else if RightIcon}
|
||||
|
||||
@@ -724,6 +724,7 @@
|
||||
{/if}
|
||||
|
||||
<AddUser
|
||||
workspaceEmails={users?.map((u) => u.email) ?? []}
|
||||
on:new={() => {
|
||||
listUsers()
|
||||
listInvites()
|
||||
|
||||
Reference in New Issue
Block a user