initial commit

This commit is contained in:
Elias Schneider
2024-08-12 11:00:25 +02:00
commit eaff977b22
241 changed files with 14378 additions and 0 deletions

View 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;
};

View 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>

View File

@@ -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;
};

View 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>

View File

@@ -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>

View 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>

View 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} />