add admin admin settings

This commit is contained in:
bytedream 2023-08-28 04:31:58 +02:00
parent 4b84c475b8
commit 0958ff21b6
15 changed files with 524 additions and 28 deletions

View File

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

View File

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

View File

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

View File

@ -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)]"

56
src/lib/permissions.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

View File

@ -0,0 +1,3 @@
<div class="flex justify-center items-center w-full">
<slot />
</div>

View File

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

View File

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

View File

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

View File

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

View File

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