From 0958ff21b6f91f3429772dcd8c04d5115316b7ab Mon Sep 17 00:00:00 2001 From: bytedream <bytedream@protonmail.com> Date: Mon, 28 Aug 2023 04:31:58 +0200 Subject: [PATCH] add admin admin settings --- src/hooks.server.ts | 4 +- src/lib/components/Input/Badges.svelte | 53 +++++ src/lib/components/Input/Input.svelte | 20 +- src/lib/components/Toast/ErrorToast.svelte | 2 +- src/lib/permissions.ts | 56 ++++++ src/lib/server/database.ts | 29 ++- src/lib/server/session.ts | 29 ++- src/routes/admin/+layout.svelte | 30 ++- src/routes/admin/+page.svelte | 0 src/routes/admin/admin/+layout.svelte | 3 + src/routes/admin/admin/+page.server.ts | 11 + src/routes/admin/admin/+page.svelte | 222 +++++++++++++++++++++ src/routes/admin/admin/+server.ts | 80 ++++++++ src/routes/admin/login/+page.svelte | 4 +- src/routes/admin/login/+server.ts | 9 +- 15 files changed, 524 insertions(+), 28 deletions(-) create mode 100644 src/lib/components/Input/Badges.svelte create mode 100644 src/lib/permissions.ts create mode 100644 src/routes/admin/+page.svelte create mode 100644 src/routes/admin/admin/+layout.svelte create mode 100644 src/routes/admin/admin/+page.server.ts create mode 100644 src/routes/admin/admin/+page.svelte create mode 100644 src/routes/admin/admin/+server.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6e4794d..d8db728 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,7 +1,7 @@ import { sequelize } from '$lib/server/database'; import type { Handle } from '@sveltejs/kit'; import { env } from '$env/dynamic/public'; -import { hasSession } from '$lib/server/session'; +import { getSession } from '$lib/server/session'; // make sure that the database and tables exist await sequelize.sync(); @@ -11,7 +11,7 @@ export const handle: Handle = async ({ event, resolve }) => { event.url.pathname.startsWith(`${env.PUBLIC_BASE_PATH}/admin`) && event.url.pathname != `${env.PUBLIC_BASE_PATH}/admin/login` ) { - if (!hasSession(event.cookies.get('session') || '')) { + if (getSession(event.cookies.get('session') || '') == null) { return new Response(null, { status: 302, headers: { diff --git a/src/lib/components/Input/Badges.svelte b/src/lib/components/Input/Badges.svelte new file mode 100644 index 0000000..fd5e789 --- /dev/null +++ b/src/lib/components/Input/Badges.svelte @@ -0,0 +1,53 @@ +<script lang="ts"> + // eslint-disable-next-line no-undef + type T = $$Generic; + + export let id: string | null = null; + export let name: string | null = null; + export let disabled = true; + export let available: string[] | { [key: string]: T } = {}; + export let value: T[] = []; +</script> + +<div class="flex items-center gap-4"> + <select + {id} + {name} + class="select select-bordered select-xs" + disabled={disabled || available.length === 0} + on:change={(e) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + value.push(Object.values(available)[Object.keys(available).indexOf(e.target.value)]); + value = value; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + e.target.value = '-'; + }} + > + <option selected hidden>-</option> + {#each Object.keys(available) as badge} + <option + hidden={value.find( + (v) => v === Object.values(available)[Object.keys(available).indexOf(badge)] + ) !== undefined}>{badge}</option + > + {/each} + </select> + <div class="flex flow flex-wrap gap-2"> + {#each value as badge, i} + {#if Object.values(available).indexOf(badge) !== -1} + <div class="badge badge-outline gap-1"> + <button + {disabled} + on:click={() => { + value.splice(i, 1); + value = value; + }}>✕</button + > + {Object.keys(available)[Object.values(available).indexOf(badge)]} + </div> + {/if} + {/each} + </div> +</div> diff --git a/src/lib/components/Input/Input.svelte b/src/lib/components/Input/Input.svelte index 7e4ee9e..8a9999a 100644 --- a/src/lib/components/Input/Input.svelte +++ b/src/lib/components/Input/Input.svelte @@ -3,10 +3,11 @@ <script lang="ts"> import { IconSolid } from 'svelte-heros-v2'; - export let id: string; + export let id: string | null = null; export let name: string | null = null; export let type: string; export let value: string | null = null; + export let placeholder: string | null = null; export let required = false; export let disabled = false; @@ -18,7 +19,7 @@ <!-- the cursor-not-allowed class must be set here because a disabled button does not respect the 'cursor' css property --> <div class={type === 'submit' && disabled ? 'cursor-not-allowed' : ''}> {#if type === 'submit'} - <input class="btn" {id} type="submit" {value} {disabled} bind:this={inputElement} /> + <input class="btn" {id} type="submit" {disabled} bind:value bind:this={inputElement} /> {:else} <div> {#if $$slots.label} @@ -31,24 +32,29 @@ </span> </label> {/if} - <div class="flex items-center" class:sm:max-w-[16rem]={type !== 'checkbox'}> + <div class="relative flex items-center" class:sm:max-w-[16rem]={type !== 'checkbox'}> <input class:checkbox={type === 'checkbox'} - class:input,input-bordered={type !== 'checkbox'} - class:w-[100%]={initialType !== 'password' && initialType !== 'checkbox'} + class:input,input-bordered,w-[100%]={type !== 'checkbox'} class:pr-11={initialType === 'password'} {id} {name} {type} {value} + {placeholder} {required} {disabled} - autocomplete="off" bind:this={inputElement} + autocomplete="off" + on:input={(e) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + value = e.target?.value; + }} /> {#if initialType === 'password'} <button - class="relative right-9" + class="absolute right-3" type="button" on:click={() => { type = type === 'password' ? 'text' : 'password'; diff --git a/src/lib/components/Toast/ErrorToast.svelte b/src/lib/components/Toast/ErrorToast.svelte index ce60aa2..0c270fc 100644 --- a/src/lib/components/Toast/ErrorToast.svelte +++ b/src/lib/components/Toast/ErrorToast.svelte @@ -46,7 +46,7 @@ <div class="alert alert-error border-none relative text-gray-900 overflow-hidden"> <div class="flex gap-2 z-10"> <IconOutline name="exclamation-circle-outline" /> - <span>Nutzername oder Passwort falsch</span> + <slot /> </div> <progress class="progress progress-error absolute bottom-0 h-[3px] w-full bg-[rgba(0,0,0,0.6)]" diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..4cb2866 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,56 @@ +export class Permissions { + static readonly AdminRead = 2; + static readonly AdminWrite = 4; + static readonly UserRead = 8; + static readonly UserWrite = 16; + + readonly value: number; + + constructor(value: number | number[]) { + if (typeof value == 'number') { + this.value = value; + } else { + let finalValue = 0; + for (const v of Object.values(value)) { + finalValue |= v; + } + this.value = finalValue; + } + } + + toJSON() { + return this.value; + } + + static allPermissions(): number[] { + return [ + Permissions.AdminRead, + Permissions.AdminWrite, + Permissions.UserRead, + Permissions.UserWrite + ]; + } + + adminRead(): boolean { + return (this.value & Permissions.AdminRead) != 0; + } + adminWrite(): boolean { + return (this.value & Permissions.AdminWrite) != 0; + } + userRead(): boolean { + return (this.value & Permissions.UserRead) != 0; + } + userWrite(): boolean { + return (this.value & Permissions.UserWrite) != 0; + } + + asArray(): number[] { + const array = []; + for (const perm of Permissions.allPermissions()) { + if ((this.value & perm) != 0) { + array.push(perm); + } + } + return array; + } +} diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 71e601f..dc0b1ad 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -2,7 +2,16 @@ import { DataTypes } from 'sequelize'; import { env } from '$env/dynamic/private'; import { building, dev } from '$app/environment'; import * as bcrypt from 'bcrypt'; -import { BeforeCreate, BeforeUpdate, Column, Model, Sequelize, Table } from 'sequelize-typescript'; +import { + BeforeCreate, + BeforeUpdate, + Column, + Model, + Sequelize, + Table, + Unique +} from 'sequelize-typescript'; +import { Permissions } from '$lib/permissions'; @Table({ modelName: 'user' }) export class User extends Model { @@ -26,18 +35,28 @@ export class User extends Model { @Table({ modelName: 'admin' }) export class Admin extends Model { - @Column({ type: DataTypes.STRING, allowNull: false }) + @Column({ type: DataTypes.STRING, allowNull: false, unique: true }) declare username: string; @Column({ type: DataTypes.STRING, allowNull: false }) declare password: string; - @Column({ type: DataTypes.BIGINT, allowNull: false }) - declare permissions: number; + @Column({ + type: DataTypes.BIGINT, + allowNull: false, + get(this: Admin): Permissions | null { + const permissions = this.getDataValue('permissions'); + return permissions != null ? new Permissions(permissions) : null; + }, + set(this: Admin, value: Permissions) { + this.setDataValue('permissions', value.value); + } + }) + declare permissions: Permissions; @BeforeCreate @BeforeUpdate static hashPassword(instance: Admin) { if (instance.password != null) { - instance.username = bcrypt.hashSync(instance.password, 10); + instance.password = bcrypt.hashSync(instance.password, 10); } } diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts index f032e7f..d999953 100644 --- a/src/lib/server/session.ts +++ b/src/lib/server/session.ts @@ -1,11 +1,30 @@ -const sessions: string[] = []; +import type { Permissions } from '$lib/permissions'; +import type { Cookies } from '@sveltejs/kit'; -export function addSession(): string { +const sessions: Map<string, Permissions> = new Map(); + +export function addSession(permissions: Permissions): string { const session = 'AAA'; - sessions.push(session); + sessions.set(session, permissions); return session; } -export function hasSession(session: string): boolean { - return sessions.find((v) => v == session) != undefined; +export function getSession(session: string | Cookies, permissions?: number[]): Permissions | null { + let sess: Permissions | null; + if (typeof session == 'string') { + sess = sessions.get(session) || null; + } else { + const sessionId = session.get('session'); + sess = sessionId ? sessions.get(sessionId) || null : null; + } + + if (!sess) { + return null; + } + for (const perm of permissions || []) { + if ((sess.value & perm) == 0) { + return null; + } + } + return sess; } diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 8dc00ce..e1a3e02 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -1,3 +1,27 @@ -<div class="h-full"> - <slot /> -</div> +<script lang="ts"> + import { page } from '$app/stores'; + import { env } from '$env/dynamic/public'; + import { IconOutline } from 'svelte-heros-v2'; +</script> + +{#if $page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`} + <div class="flex h-screen"> + <div class="h-full"> + <ul class="menu p-4 w-fit h-full bg-base-200 text-base-content"> + <li> + <a href="{env.PUBLIC_BASE_PATH}/admin/admin"> + <IconOutline name="users-outline" /> + <span class="ml-1">Website Admins</span> + </a> + </li> + </ul> + </div> + <div class="h-full w-full"> + <slot /> + </div> + </div> +{:else} + <div class="h-full w-full"> + <slot /> + </div> +{/if} diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/admin/admin/+layout.svelte b/src/routes/admin/admin/+layout.svelte new file mode 100644 index 0000000..c281020 --- /dev/null +++ b/src/routes/admin/admin/+layout.svelte @@ -0,0 +1,3 @@ +<div class="flex justify-center items-center w-full"> + <slot /> +</div> diff --git a/src/routes/admin/admin/+page.server.ts b/src/routes/admin/admin/+page.server.ts new file mode 100644 index 0000000..f90019b --- /dev/null +++ b/src/routes/admin/admin/+page.server.ts @@ -0,0 +1,11 @@ +import type { PageServerLoad } from './$types'; +import { Admin } from '$lib/server/database'; +import { getSession } from '$lib/server/session'; + +export const load: PageServerLoad = async ({ cookies }) => { + const admins = await Admin.findAll({ attributes: { exclude: ['password'] } }); + return { + admins: JSON.parse(JSON.stringify(admins)), + permissions: getSession(cookies.get('session') || '')!.value + }; +}; diff --git a/src/routes/admin/admin/+page.svelte b/src/routes/admin/admin/+page.svelte new file mode 100644 index 0000000..8b7797b --- /dev/null +++ b/src/routes/admin/admin/+page.svelte @@ -0,0 +1,222 @@ +<script lang="ts"> + import type { PageData } from './$types'; + import Badges from '$lib/components/Input/Badges.svelte'; + import { IconOutline } from 'svelte-heros-v2'; + import Input from '$lib/components/Input/Input.svelte'; + import { Permissions } from '$lib/permissions'; + import { env } from '$env/dynamic/public'; + import ErrorToast from '$lib/components/Toast/ErrorToast.svelte'; + + let allPermissionBadges = { + 'Admin Read': Permissions.AdminRead, + 'Admin Write': Permissions.AdminWrite, + 'User Read': Permissions.UserRead, + 'User Write': Permissions.UserWrite + }; + + let newAdminUsername: string; + let newAdminPassword: string; + let newAdminPermissions: number[]; + + async function buttonTriggeredRequest<T>(e: MouseEvent, promise: Promise<T>) { + (e.target as HTMLButtonElement).disabled = true; + await promise; + (e.target as HTMLButtonElement).disabled = false; + } + + async function addAdmin(username: string, password: string, permissions: Permissions) { + const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/admin`, { + method: 'POST', + body: JSON.stringify({ + username: username, + password: password, + permissions: permissions.value + }) + }); + if (response.ok) { + data.admins.push(await response.json()); + data.admins = data.admins; + } else { + throw new Error(); + } + } + + async function updateAdmin( + id: number, + username: string | null, + password: string | null, + permissions: Permissions | null + ) { + const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/admin`, { + method: 'PATCH', + body: JSON.stringify({ + id: id, + username: username, + password: password, + permissions: permissions?.value + }) + }); + if (!response.ok) { + throw new Error(); + } + } + + async function deleteAdmin(id: number) { + const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/admin`, { + method: 'DELETE', + body: JSON.stringify({ + id: id + }) + }); + if (response.ok) { + data.admins.splice( + data.admins.find((v: typeof data.admins) => v.id == id), + 1 + ); + data.admins = data.admins; + } else { + throw new Error(); + } + } + + let errorMessage = ''; + + export let data: PageData; + let permissions = new Permissions(data.permissions); +</script> + +<table class="table table-zebra"> + <colgroup> + <col span="1" style="width: 5%" /> + <col span="1" style="width: 25%" /> + <col span="1" style="width: 25%" /> + <col span="1" style="width: 30%" /> + <col span="1" style="width: 15%" /> + </colgroup> + <thead> + <tr> + <th /> + <th>Benutzername</th> + <th>Passwort</th> + <th>Berechtigungen</th> + <th /> + </tr> + </thead> + <tbody> + {#each data.admins as admin, i} + <tr> + <td>{i}</td> + <td + ><Input + type="text" + value={admin.username} + disabled={!permissions.adminWrite() || !admin.edit} + /></td + > + <td + ><Input + type="password" + placeholder="Neues Passwort..." + disabled={!permissions.adminWrite() || !admin.edit} + /></td + > + <td + ><Badges + value={new Permissions(admin.permissions).asArray()} + available={allPermissionBadges} + disabled={!permissions.adminWrite() || !admin.edit} + /></td + > + <td> + <div> + {#if admin.edit} + <button + class="btn btn-square" + disabled={!permissions.adminWrite()} + on:click={async (e) => { + await buttonTriggeredRequest( + e, + updateAdmin( + admin.id, + admin.username, + admin.password, + new Permissions(admin.permissions) + ) + ); + admin.edit = false; + }} + > + <IconOutline name="check-outline" width="24" height="24" /> + </button> + <button + class="btn btn-square" + disabled={!permissions.adminWrite()} + on:click={() => { + admin.username = admin.before.username; + admin.permissions = admin.before.permissions; + admin.edit = false; + }} + > + <IconOutline name="no-symbol-outline" width="24" height="24" /> + </button> + {:else} + <button + class="btn btn-square" + disabled={!permissions.adminWrite()} + on:click={() => { + admin.edit = true; + admin.before = { + username: admin.username, + permissions: admin.permissions + }; + }} + > + <IconOutline name="pencil-square-outline" width="24" height="24" /> + </button> + <button + class="btn btn-square" + disabled={!permissions.adminWrite()} + on:click={(e) => buttonTriggeredRequest(e, deleteAdmin(admin.id))} + > + <IconOutline name="trash-outline" width="24" height="24" /> + </button> + {/if} + </div> + </td> + </tr> + {/each} + <tr> + <td>{data.admins.length}</td> + <td><Input type="text" bind:value={newAdminUsername} /></td> + <td><Input type="password" bind:value={newAdminPassword} /></td> + <td + ><Badges + bind:value={newAdminPermissions} + available={allPermissionBadges} + disabled={!permissions.adminWrite()} + /></td + > + <td> + <button + class="btn btn-square" + disabled={!permissions.adminWrite() || !newAdminUsername || !newAdminPassword} + on:click={async (e) => { + await buttonTriggeredRequest( + e, + addAdmin(newAdminUsername, newAdminPassword, new Permissions(newAdminPermissions)) + ); + newAdminUsername = ''; + newAdminPassword = ''; + newAdminPermissions = []; + }} + > + <IconOutline name="user-plus-outline" width="24" height="24" /> + </button> + </td> + </tr> + </tbody> +</table> + +<ErrorToast show={errorMessage !== ''}> + <span /> +</ErrorToast> diff --git a/src/routes/admin/admin/+server.ts b/src/routes/admin/admin/+server.ts new file mode 100644 index 0000000..78ecd39 --- /dev/null +++ b/src/routes/admin/admin/+server.ts @@ -0,0 +1,80 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { Permissions } from '$lib/permissions'; +import { getSession } from '$lib/server/session'; +import { Admin } from '$lib/server/database'; + +export const POST = (async ({ request, cookies }) => { + if (getSession(cookies, [Permissions.AdminWrite]) == null) { + return new Response(null, { + status: 401 + }); + } + + const data = await request.json(); + const username = data['username'] as string | null; + const password = data['password'] as string | null; + const permissions = data['permissions'] as number | null; + + if (username == null || password == null || permissions == null) { + return new Response(null, { + status: 400 + }); + } + + const admin = await Admin.create({ + username: username, + password: password, + permissions: new Permissions(permissions) + }); + + return new Response(JSON.stringify(admin), { + status: 201 + }); +}) satisfies RequestHandler; + +export const PATCH = (async ({ request, cookies }) => { + if (getSession(cookies, [Permissions.AdminWrite]) == null) { + return new Response(null, { + status: 401 + }); + } + + const data = await request.json(); + const id = data['id'] as string | null; + + if (id == null) { + return new Response(null, { + status: 400 + }); + } + + const updatePayload: { [key: string]: any } = {}; + if (data['username']) updatePayload.username = data['username']; + if (data['password']) updatePayload.password = data['password']; + if (data['permissions']) updatePayload.permissions = data['permissions']; + + await Admin.update(updatePayload, { where: { id: id } }); + + return new Response(); +}) satisfies RequestHandler; + +export const DELETE = (async ({ request, cookies }) => { + if (getSession(cookies, [Permissions.AdminWrite]) == null) { + return new Response(null, { + status: 401 + }); + } + + const data = await request.json(); + const id = data['id'] as string | null; + + if (id == null) { + return new Response(null, { + status: 400 + }); + } + + await Admin.destroy({ where: { id: id } }); + + return new Response(); +}) satisfies RequestHandler; diff --git a/src/routes/admin/login/+page.svelte b/src/routes/admin/login/+page.svelte index 3a6a4ed..ac1285a 100644 --- a/src/routes/admin/login/+page.svelte +++ b/src/routes/admin/login/+page.svelte @@ -83,4 +83,6 @@ </form> </div> -<ErrorToast timeout={2000} bind:show={showError} bind:this={errorToastElement} /> +<ErrorToast timeout={2000} bind:show={showError} bind:this={errorToastElement}> + <span>Nutzername oder Passwort falsch</span> +</ErrorToast> diff --git a/src/routes/admin/login/+server.ts b/src/routes/admin/login/+server.ts index 8287b9e..0254eff 100644 --- a/src/routes/admin/login/+server.ts +++ b/src/routes/admin/login/+server.ts @@ -3,6 +3,7 @@ import { Admin } from '$lib/server/database'; import { env as publicEnv } from '$env/dynamic/public'; import { env } from '$env/dynamic/private'; import { addSession } from '$lib/server/session'; +import { Permissions } from '$lib/permissions'; export const POST = (async ({ request, cookies }) => { const data = await request.formData(); @@ -11,7 +12,7 @@ export const POST = (async ({ request, cookies }) => { if (username == null || password == null) { return new Response(null, { - status: 403 + status: 401 }); } @@ -21,7 +22,7 @@ export const POST = (async ({ request, cookies }) => { username == env.ADMIN_USER && password == env.ADMIN_PASSWORD ) { - cookies.set('session', addSession(), { + cookies.set('session', addSession(new Permissions(Permissions.allPermissions())), { path: `${publicEnv.PUBLIC_BASE_PATH}/admin`, maxAge: 60 * 60 * 24 * 90, httpOnly: true, @@ -32,7 +33,7 @@ export const POST = (async ({ request, cookies }) => { const user = await Admin.findOne({ where: { username: username } }); if (user && user.validatePassword(password)) { - cookies.set('session', addSession(), { + cookies.set('session', addSession(user.permissions), { path: `${publicEnv.PUBLIC_BASE_PATH}/admin`, maxAge: 60 * 60 * 24 * 90, httpOnly: true, @@ -41,7 +42,7 @@ export const POST = (async ({ request, cookies }) => { return new Response(); } else { return new Response(null, { - status: 403 + status: 401 }); } }) satisfies RequestHandler;