feat: add user groups

This commit is contained in:
Elias Schneider
2024-10-02 08:43:44 +02:00
parent 7a54d3ae20
commit 24c948e6a6
40 changed files with 1142 additions and 37 deletions

View File

@@ -18,6 +18,7 @@
links = [
...links,
{ href: '/settings/admin/users', label: 'Users' },
{ href: '/settings/admin/user-groups', label: 'User Groups' },
{ href: '/settings/admin/oidc-clients', label: 'OIDC Clients' },
{ href: '/settings/admin/application-configuration', label: 'Application Configuration' }
];

View File

@@ -8,7 +8,7 @@
id,
imageClass,
label,
image = $bindable<File | null>(null),
image = $bindable(),
imageURL,
accept = 'image/png, image/jpeg, image/svg+xml',
...restProps

View File

@@ -0,0 +1,8 @@
import UserGroupService from '$lib/services/user-group-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const userGroupService = new UserGroupService(cookies.get('access_token'));
const userGroups = await userGroupService.list();
return userGroups;
};

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroupCreate, UserGroupWithUserCount } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import UserGroupForm from './user-group-form.svelte';
import UserGroupList from './user-group-list.svelte';
let { data } = $props();
let userGroups: Paginated<UserGroupWithUserCount> = $state(data);
let expandAddUserGroup = $state(false);
const userGroupService = new UserGroupService();
async function createUserGroup(userGroup: UserGroupCreate) {
let success = true;
await userGroupService
.create(userGroup)
.then((createdUserGroup) => {
toast.success('User group created successfully');
goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
}
</script>
<svelte:head>
<title>User Groups</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create User Group</Card.Title>
<Card.Description>Create a new group that can be assigned to users.</Card.Description>
</div>
{#if !expandAddUserGroup}
<Button on:click={() => (expandAddUserGroup = true)}>Add Group</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUserGroup = false)}>
<LucideMinus class="h-5 w-5" />
</Button>
{/if}
</div>
</Card.Header>
{#if expandAddUserGroup}
<div transition:slide>
<Card.Content>
<UserGroupForm callback={createUserGroup} />
</Card.Content>
</div>
{/if}
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Manage User Groups</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupList {userGroups} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,9 @@
import UserGroupService from '$lib/services/user-group-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const userGroupService = new UserGroupService(cookies.get('access_token'));
const userGroup = await userGroupService.get(params.id);
return { userGroup };
};

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import UserGroupService from '$lib/services/user-group-service';
import UserService from '$lib/services/user-service';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte';
let { data } = $props();
let userGroup = $state({
...data.userGroup,
userIds: data.userGroup.users.map((u) => u.id)
});
const userGroupService = new UserGroupService();
const userService = new UserService();
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
let success = true;
await userGroupService
.update(userGroup.id, updatedUserGroup)
.then(() => toast.success('User Group updated successfully'))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
}
async function updateUserGroupUsers(userIds: string[]) {
await userGroupService
.updateUsers(userGroup.id, userIds)
.then(() => toast.success('Users updated successfully'))
.catch((e) => {
axiosErrorToast(e);
});
}
</script>
<svelte:head>
<title>User Group Details {userGroup.name}</title>
</svelte:head>
<div>
<a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
><LucideChevronLeft class="h-5 w-5" /> Back</a
>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Meta data</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupForm existingUserGroup={userGroup} callback={updateUserGroup} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Users</Card.Title>
<Card.Description>Assign users to this group.</Card.Description>
</Card.Header>
<Card.Content>
{#await userService.list() then users}
<UserSelection {users} bind:selectedUserIds={userGroup.userIds} />
{/await}
<div class="mt-5 flex justify-end">
<Button on:click={() => updateUserGroupUsers(userGroup.userIds)}>Save</Button>
</div>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
let {
callback,
existingUserGroup
}: {
existingUserGroup?: UserGroupCreate;
callback: (userGroup: UserGroupCreate) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
let hasManualNameEdit = $state(!!existingUserGroup?.friendlyName);
const userGroup = {
name: existingUserGroup?.name || '',
friendlyName: existingUserGroup?.friendlyName || ''
};
const formSchema = z.object({
friendlyName: z.string().min(2).max(30),
name: z
.string()
.min(2)
.max(30)
.regex(/^[a-z0-9_]+$/, 'Name can only contain lowercase letters, numbers, and underscores')
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, userGroup);
function onFriendlyNameInput(e: any) {
if (!hasManualNameEdit) {
$inputs.name.value = e.target!.value.toLowerCase().replace(/[^a-z0-9_]/g, '_');
}
}
function onNameInput(_: Event) {
hasManualNameEdit = true;
}
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
// Reset form if user group was successfully created
if (success && !existingUserGroup) {
form.reset();
hasManualNameEdit = false;
}
isLoading = false;
}
</script>
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput
label="Friendly Name"
description="Name that will be displayed in the UI"
bind:input={$inputs.friendlyName}
onInput={onFriendlyNameInput}
/>
</div>
<div class="w-full">
<FormInput
label="Name"
description={`Name that will be in the "userGroup" claim`}
bind:input={$inputs.name}
onInput={onNameInput}
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
</div>
</form>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroup, UserGroupWithUserCount } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from 'lucide-svelte';
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { toast } from 'svelte-sonner';
let { userGroups: initialUserGroups }: { userGroups: Paginated<UserGroupWithUserCount> } =
$props();
let userGroups = $state<Paginated<UserGroupWithUserCount>>(initialUserGroups);
const userGroupService = new UserGroupService();
async function deleteUserGroup(userGroup: UserGroup) {
openConfirmDialog({
title: `Delete ${userGroup.name}`,
message: 'Are you sure you want to delete this user group?',
confirm: {
label: 'Delete',
destructive: true,
action: async () => {
try {
await userGroupService.remove(userGroup.id);
userGroups = await userGroupService.list();
} catch (e) {
axiosErrorToast(e);
}
toast.success('User group deleted successfully');
}
}
});
}
async function fetchItems(search: string, page: number, limit: number) {
return userGroupService.list(search, { page, limit });
}
</script>
<AdvancedTable items={userGroups} {fetchItems} columns={['Friendly Name', 'Name', 'User Count', {label: "Actions", hidden: true}]}>
{#snippet rows({ item })}
<Table.Cell>{item.friendlyName}</Table.Cell>
<Table.Cell>{item.name}</Table.Cell>
<Table.Cell>{item.userCount}</Table.Cell>
<Table.Cell class="flex justify-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item href="/settings/admin/user-groups/{item.id}"
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
>
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
on:click={() => deleteUserGroup(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</AdvancedTable>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import UserService from '$lib/services/user-service';
import type { Paginated } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type';
let {
users: initialUsers,
selectedUserIds = $bindable()
}: { users: Paginated<User>; selectedUserIds: string[] } = $props();
const userService = new UserService();
let users = $state(initialUsers);
function fetchItems(search: string, page: number, limit: number) {
return userService.list(search, { page, limit });
}
</script>
<AdvancedTable
items={users}
{fetchItems}
columns={['Name', 'Email']}
bind:selectedIds={selectedUserIds}
>
{#snippet rows({ item })}
<Table.Cell>{item.firstName} {item.lastName}</Table.Cell>
<Table.Cell>{item.email}</Table.Cell>
{/snippet}
</AdvancedTable>

View File

@@ -9,7 +9,7 @@
import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import CreateUser from './user-form.svelte';
import UserForm from './user-form.svelte';
import UserList from './user-list.svelte';
let { data } = $props();
@@ -56,7 +56,7 @@
{#if expandAddUser}
<div transition:slide>
<Card.Content>
<CreateUser callback={createUser} />
<UserForm callback={createUser} />
</Card.Content>
</div>
{/if}