mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-29 10:46:38 +00:00
initial commit
This commit is contained in:
8
frontend/src/routes/settings/admin/users/+page.server.ts
Normal file
8
frontend/src/routes/settings/admin/users/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const userService = new UserService(cookies.get('access_token'));
|
||||
const users = await userService.list();
|
||||
return users;
|
||||
};
|
||||
74
frontend/src/routes/settings/admin/users/+page.svelte
Normal file
74
frontend/src/routes/settings/admin/users/+page.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
|
||||
import type { Paginated } from '$lib/types/pagination.type';
|
||||
import type { User, UserCreate } from '$lib/types/user.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideMinus } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { slide } from 'svelte/transition';
|
||||
import CreateUser from './user-form.svelte';
|
||||
import UserList from './user-list.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let users: Paginated<User> = $state(data);
|
||||
let expandAddUser = $state(false);
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
async function createUser(user: UserCreate) {
|
||||
let success = true;
|
||||
await userService
|
||||
.create(user)
|
||||
.then(() => toast.success('User created successfully'))
|
||||
.catch((e) => {
|
||||
axiosErrorToast(e);
|
||||
success = false;
|
||||
});
|
||||
|
||||
users = await userService.list();
|
||||
return success;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Users</title>
|
||||
</svelte:head>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>Create User</Card.Title>
|
||||
<Card.Description
|
||||
>Add a new user to {$applicationConfigurationStore.appName}.</Card.Description
|
||||
>
|
||||
</div>
|
||||
{#if !expandAddUser}
|
||||
<Button on:click={() => (expandAddUser = true)}>Add User</Button>
|
||||
{:else}
|
||||
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUser = false)}>
|
||||
<LucideMinus class="h-5 w-5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if expandAddUser}
|
||||
<div transition:slide>
|
||||
<Card.Content>
|
||||
<CreateUser callback={createUser} />
|
||||
</Card.Content>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Manage Users</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<UserList {users} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,8 @@
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const userService = new UserService(cookies.get('access_token'));
|
||||
const user = await userService.get(params.id);
|
||||
return user;
|
||||
};
|
||||
46
frontend/src/routes/settings/admin/users/[id]/+page.svelte
Normal file
46
frontend/src/routes/settings/admin/users/[id]/+page.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { UserCreate } from '$lib/types/user.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideChevronLeft } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import UserForm from '../user-form.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let user = $state(data);
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
async function updateUser(updatedUser: UserCreate) {
|
||||
let success = true;
|
||||
await userService
|
||||
.update(user.id, updatedUser)
|
||||
.then(() => toast.success('User updated successfully'))
|
||||
.catch((e) => {
|
||||
axiosErrorToast(e);
|
||||
success = false;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>User Details {user.firstName} {user.lastName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<a class="text-muted-foreground flex text-sm" href="/settings/admin/users"
|
||||
><LucideChevronLeft class="h-5 w-5" /> Back</a
|
||||
>
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{user.firstName} {user.lastName}</Card.Title>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
<UserForm existingUser={user} callback={updateUser} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
|
||||
let {
|
||||
oneTimeLink = $bindable()
|
||||
}: {
|
||||
oneTimeLink: string | null;
|
||||
} = $props();
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
oneTimeLink = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>One Time Link</Dialog.Title>
|
||||
<Dialog.Description
|
||||
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or
|
||||
have lost it.</Dialog.Description
|
||||
>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<Label for="one-time-link">One Time Link</Label>
|
||||
<Input id="one-time-link" value={oneTimeLink} readonly />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
78
frontend/src/routes/settings/admin/users/user-form.svelte
Normal file
78
frontend/src/routes/settings/admin/users/user-form.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type { UserCreate } from '$lib/types/user.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod';
|
||||
|
||||
let {
|
||||
callback,
|
||||
existingUser
|
||||
}: {
|
||||
existingUser?: UserCreate;
|
||||
callback: (user: UserCreate) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
const user = {
|
||||
firstName: existingUser?.firstName || '',
|
||||
lastName: existingUser?.lastName || '',
|
||||
email: existingUser?.email || '',
|
||||
username: existingUser?.username || '',
|
||||
isAdmin: existingUser?.isAdmin || false
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().min(2).max(50),
|
||||
lastName: z.string().min(2).max(50),
|
||||
username: z.string().min(2).max(50),
|
||||
email: z.string().email(),
|
||||
isAdmin: z.boolean()
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
const { inputs, ...form } = createForm<FormSchema>(formSchema, user);
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
isLoading = true;
|
||||
const success = await callback(data);
|
||||
// Reset form if user was successfully created
|
||||
if (success && !existingUser) form.reset();
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<div class="w-full">
|
||||
<FormInput label="Firstname" bind:input={$inputs.firstName} />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<FormInput label="Lastname" bind:input={$inputs.lastName} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
|
||||
<div class="w-full">
|
||||
<FormInput label="Email" bind:input={$inputs.email} />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<FormInput label="Username" bind:input={$inputs.username} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="items-top mt-5 flex space-x-2">
|
||||
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
|
||||
<div class="grid gap-1.5 leading-none">
|
||||
<Label for="admin-privileges" class="text-sm font-medium leading-none mb-0">
|
||||
Admin Privileges
|
||||
</Label>
|
||||
<p class="text-[0.8rem] text-muted-foreground">Admins have full access to the admin panel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
170
frontend/src/routes/settings/admin/users/user-list.svelte
Normal file
170
frontend/src/routes/settings/admin/users/user-list.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||
import { Badge } from '$lib/components/ui/badge/index';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
||||
import type { User } from '$lib/types/user.type';
|
||||
import { debounced } from '$lib/utils/debounce-util';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
|
||||
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import OneTimeLinkModal from './one-time-link-modal.svelte';
|
||||
|
||||
let { users: initialUsers }: { users: Paginated<User> } = $props();
|
||||
let users = $state<Paginated<User>>(initialUsers);
|
||||
let oneTimeLink = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
users = initialUsers;
|
||||
});
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
let pagination = $state<PaginationRequest>({
|
||||
page: 1,
|
||||
limit: 10
|
||||
});
|
||||
let search = $state('');
|
||||
|
||||
const debouncedFetchUsers = debounced(userService.list, 500);
|
||||
|
||||
async function deleteUser(user: User) {
|
||||
openConfirmDialog({
|
||||
title: `Delete ${user.firstName} ${user.lastName}`,
|
||||
message: 'Are you sure you want to delete this user?',
|
||||
confirm: {
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await userService.remove(user.id);
|
||||
users = await userService.list(search, pagination);
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
toast.success('User deleted successfully');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createOneTimeAccessToken(userId: string) {
|
||||
try {
|
||||
const token = await userService.createOneTimeAccessToken(userId);
|
||||
oneTimeLink = `${$page.url.origin}/login/${token}`;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search users"
|
||||
bind:value={search}
|
||||
on:input={async (e) =>
|
||||
(users = await userService.list((e.target as HTMLInputElement).value, pagination))}
|
||||
/>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="hidden md:table-cell">First name</Table.Head>
|
||||
<Table.Head class="hidden md:table-cell">Last name</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
<Table.Head>Username</Table.Head>
|
||||
<Table.Head class="hidden lg:table-cell">Role</Table.Head>
|
||||
<Table.Head>
|
||||
<span class="sr-only">Actions</span>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if users.data.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="text-center">No users found</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each users.data as user}
|
||||
<Table.Row>
|
||||
<Table.Cell class="hidden md:table-cell">{user.firstName}</Table.Cell>
|
||||
<Table.Cell class="hidden md:table-cell">{user.lastName}</Table.Cell>
|
||||
<Table.Cell>{user.email}</Table.Cell>
|
||||
<Table.Cell>{user.username}</Table.Cell>
|
||||
<Table.Cell class="hidden lg:table-cell">
|
||||
<Badge variant="outline">{user.isAdmin ? 'Admin' : 'User'}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<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 on:click={() => createOneTimeAccessToken(user.id)}
|
||||
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Item href="/settings/admin/users/{user.id}"
|
||||
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="text-red-500 focus:!text-red-700"
|
||||
on:click={() => deleteUser(user)}
|
||||
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
{#if users?.data?.length ?? 0 > 0}
|
||||
<Pagination.Root
|
||||
class="mt-5"
|
||||
count={users.pagination.totalItems}
|
||||
perPage={pagination.limit}
|
||||
onPageChange={async (p) =>
|
||||
(users = await userService.list(search, {
|
||||
page: p,
|
||||
limit: pagination.limit
|
||||
}))}
|
||||
bind:page={users.pagination.currentPage}
|
||||
let:pages
|
||||
let:currentPage
|
||||
>
|
||||
<Pagination.Content class="flex justify-end">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={users.pagination.currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
</Pagination.Root>
|
||||
{/if}
|
||||
|
||||
<OneTimeLinkModal {oneTimeLink} />
|
||||
Reference in New Issue
Block a user