feat: add CSV member import tab to Edit Group modal

Validate CSV on select, look up users in batches, and add valid users to the group.
This commit is contained in:
seokjin kim 2026-08-07 16:50:47 +09:00
parent 717d24db38
commit f9775d3f89
3 changed files with 169 additions and 1 deletions

View file

@ -9,10 +9,12 @@
import Permissions from './Permissions.svelte';
import Users from './Users.svelte';
import GroupPreviewPanel from './GroupPreviewPanel.svelte';
import Import from './Import.svelte';
import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions';
import { getUserDefaultPermissions, getUserDefaultPermissionsDefaults } from '$lib/apis/users';
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import DocumentArrowDown from '$lib/components/icons/DocumentArrowDown.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@ -263,6 +265,24 @@
<div class=" self-center">{$i18n.t('Preview')}</div>
</button>
{/if}
{#if tabs.includes('import')}
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'import'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'import';
}}
type="button"
>
<div class=" self-center mr-2">
<DocumentArrowDown />
</div>
<div class=" self-center">{$i18n.t('CSV Import')}</div>
</button>
{/if}
</div>
<div class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] flex flex-col">
@ -283,6 +303,12 @@
<Users bind:userCount groupId={group?.id} />
{:else if selectedTab == 'preview'}
<GroupPreviewPanel groupId={group?.id} />
{:else if selectedTab == 'import'}
<Import
groupId={group?.id}
bind:userCount
bind:loading
/>
{/if}
</div>

View file

@ -61,7 +61,7 @@
edit
{group}
{defaultPermissions}
tabs={['general', 'permissions', 'users', 'preview']}
tabs={['general', 'permissions', 'users', 'preview', 'import']}
onSubmit={updateHandler}
onDelete={deleteHandler}
/>

View file

@ -0,0 +1,142 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { getUsers } from '$lib/apis/users';
import { addUserToGroup } from '$lib/apis/groups';
import { WEBUI_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
export let groupId: string | undefined = undefined;
export let userCount = 0;
export let loading = false;
let inputFiles: FileList | null = null;
let fileInput: HTMLInputElement | undefined;
const clearFileInput = () => {
inputFiles = null;
if (fileInput) {
fileInput.value = '';
}
};
const importCsv = async (file: File) => {
if (!groupId) {
clearFileInput();
return;
}
loading = true;
try {
const csv = await file.text();
const validRows: { idx: number; email: string }[] = [];
for (const [idx, row] of csv.split(/\r?\n/).entries()) {
if (idx === 0) continue;
const columns = row.split(',').map((col) => col.trim());
if (columns.length === 1 && columns[0] === '') continue;
if (columns.length !== 2 || !columns[1]) {
toast.error(`Row ${idx + 1}: invalid format.`);
continue;
}
validRows.push({ idx, email: columns[1].toLowerCase() });
}
const userIds: string[] = [];
const BATCH_SIZE = 10;
for (let i = 0; i < validRows.length; i += BATCH_SIZE) {
const batch = validRows.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map(({ idx, email }) =>
getUsers(localStorage.token, email)
.then((res) => {
const user = res?.users?.find((u) => u.email?.toLowerCase() === email);
if (user?.id) {
return user.id;
}
toast.error(`Row ${idx + 1}: ${$i18n.t('User not found.')}`);
return null;
})
.catch((error) => {
toast.error(`Row ${idx + 1}: ${error}`);
return null;
})
)
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
userIds.push(result.value);
}
}
}
if (userIds.length > 0) {
await addUserToGroup(localStorage.token, groupId, userIds);
userCount += userIds.length;
toast.success(
$i18n.t('Successfully imported {{userCount}} users.', { userCount: userIds.length })
);
}
} catch (error) {
toast.error(`${error}`);
} finally {
clearFileInput();
loading = false;
}
};
const handleFileChange = async () => {
const file = inputFiles?.[0];
if (!file || loading) {
return;
}
await importCsv(file);
};
</script>
<div>
<div class="mb-3 w-full">
<input
bind:this={fileInput}
hidden
bind:files={inputFiles}
type="file"
accept=".csv"
disabled={loading}
on:change={handleFileChange}
/>
<button
class="w-full text-sm font-normal py-3 bg-transparent hover:bg-gray-100 border border-dashed dark:border-gray-850 dark:hover:bg-gray-850 text-center rounded-xl {loading
? 'cursor-not-allowed opacity-60'
: ''}"
type="button"
disabled={loading}
on:click={() => {
fileInput?.click();
}}
>
{#if loading}
{$i18n.t('Importing...')}
{:else}
{$i18n.t('Click here to select a csv file.')}
{/if}
</button>
</div>
<div class=" text-xs text-gray-500">
ⓘ {$i18n.t('Ensure your CSV file includes 2 columns in this order: Name, Email.')}
<a class="underline dark:text-gray-200" href="{WEBUI_BASE_URL}/static/group-import.csv">
{$i18n.t('Click here to download group import template file.')}
</a>
</div>
</div>