mirror of
https://github.com/nikdoof/pocket-id.git
synced 2025-12-15 15:52:18 +00:00
feat: add user groups
This commit is contained in:
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
id,
|
||||
imageClass,
|
||||
label,
|
||||
image = $bindable<File | null>(null),
|
||||
image = $bindable(),
|
||||
imageURL,
|
||||
accept = 'image/png, image/jpeg, image/svg+xml',
|
||||
...restProps
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
73
frontend/src/routes/settings/admin/user-groups/+page.svelte
Normal file
73
frontend/src/routes/settings/admin/user-groups/+page.svelte
Normal 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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user