Compare commits

...

18 Commits

Author SHA1 Message Date
b6fad90daf fix admin sidebar not showing items after new login
All checks were successful
delpoy / build-and-deploy (push) Successful in 39s
2023-08-29 14:30:25 +02:00
8259c387e5 make admin users table resizable 2023-08-29 14:23:06 +02:00
d0c40e1d81 show admin pages only when permissions are correct 2023-08-29 13:59:30 +02:00
4830551edc show count of users and admin on admin page 2023-08-29 04:46:42 +02:00
10b1c01d51 add admin user page 2023-08-29 04:24:56 +02:00
1fb71fe899 make admin table resizable 2023-08-29 00:43:15 +02:00
3e259872b3 make input size changeable 2023-08-28 19:09:37 +02:00
407fb22a0e fix navbar menu position 2023-08-28 18:53:49 +02:00
6d4ad29379 add more responsiveness when updating self admin 2023-08-28 18:46:41 +02:00
90cb1fea87 update all sessions when changing admin permissions instead of deleting 2023-08-28 18:05:14 +02:00
1b6e3c522f update session structure 2023-08-28 17:58:21 +02:00
a88ae62edf show disabled cursor when hovering over disabled buttons in admin admin view 2023-08-28 17:21:54 +02:00
01df127430 fix logout not possible if admin write permission is not present 2023-08-28 17:20:01 +02:00
4d42a5d440 show menu on right side when on admin page 2023-08-28 17:11:34 +02:00
0ab03dd9dc add admin logout 2023-08-28 17:01:53 +02:00
5a1fa2cc95 delete sessions when admin is deleted 2023-08-28 16:41:41 +02:00
0958ff21b6 add admin admin settings 2023-08-28 04:31:58 +02:00
4b84c475b8 add admin login 2023-08-27 23:33:22 +02:00
32 changed files with 1636 additions and 57 deletions

View File

@ -1,3 +1,5 @@
DATABASE_URI=sqlite://./database.db DATABASE_URI=sqlite://./database.db
ADMIN_USER=admin
ADMIN_PASSWORD=admin
PUBLIC_START_DATE=2023-12-26T00:00:00+0200 PUBLIC_START_DATE=2023-12-26T00:00:00+0200
PUBLIC_BASE_PATH= PUBLIC_BASE_PATH=

View File

@ -34,5 +34,7 @@ Configurations can be done with env variables
| `HOST` | Host the server should listen on | | `HOST` | Host the server should listen on |
| `PORT` | Port the server should listen on | | `PORT` | Port the server should listen on |
| `DATABASE_URI` | URI to the database as a connection string. Supported databases are [sqlite](https://www.sqlite.org/index.html) and [mariadb](https://mariadb.org/) | | `DATABASE_URI` | URI to the database as a connection string. Supported databases are [sqlite](https://www.sqlite.org/index.html) and [mariadb](https://mariadb.org/) |
| `ADMIN_USER` | Name for the root admin user. The admin user won't be available if `ADMIN_USER` or `ADMIN_PASSWORD` is set |
| `ADMIN_PASSWORD` | Password for the root admin user defined via `ADMIN_USER`. The admin user won't be available if `ADMIN_USER` or `ADMIN_PASSWORD` is set |
| `PUBLIC_BASE_PATH` | If running the website on a sub-path, set this variable to the path so that assets etc. can find the correct location | | `PUBLIC_BASE_PATH` | If running the website on a sub-path, set this variable to the path so that assets etc. can find the correct location |
| `PUBLIC_START_DATE` | The start date when the event starts | | `PUBLIC_START_DATE` | The start date when the event starts |

113
package-lock.json generated
View File

@ -8,29 +8,36 @@
"name": "website", "name": "website",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"mariadb": "^3.2.0", "mariadb": "^3.2.0",
"sequelize": "^6.32.1", "sequelize": "^6.32.1",
"sequelize-typescript": "^2.1.5",
"sqlite3": "^5.1.6" "sqlite3": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^1.3.1", "@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.20.4", "@sveltejs/kit": "^1.20.4",
"@types/bcrypt": "^5.0.0",
"@types/node": "^20.5.6",
"@types/validator": "^13.11.1",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"daisyui": "^3.5.0", "daisyui": "^3.6.3",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0", "eslint-plugin-svelte": "^2.30.0",
"postcss": "^8.4.27", "postcss": "^8.4.27",
"prettier": "^2.8.0", "prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1", "prettier-plugin-svelte": "^2.10.1",
"sass": "^1.66.1",
"svelte": "^4.0.5", "svelte": "^4.0.5",
"svelte-check": "^3.4.3", "svelte-check": "^3.4.3",
"svelte-heros-v2": "^0.9.3", "svelte-heros-v2": "^0.9.3",
"svelte-local-storage-store": "^0.6.0", "svelte-local-storage-store": "^0.6.0",
"svelte-multicssclass": "^2.1.1", "svelte-multicssclass": "^2.1.1",
"svelte-preprocess": "^5.0.4",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
@ -946,6 +953,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/@types/bcrypt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.0.tgz",
"integrity": "sha512-agtcFKaruL8TmcvqbndlqHPSJgsolhf/qPWchFlgnW1gECTN/nKbFcoFnvKAQRFfKbh+BO6A3SWdJu9t+xF3Lw==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "4.3.5", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz",
@ -998,9 +1014,9 @@
"integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==" "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA=="
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.4.8", "version": "20.5.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.8.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.6.tgz",
"integrity": "sha512-0mHckf6D2DiIAzh8fM8f3HQCvMKDpK94YQ0DSVkfWTG9BZleYIWudw9cJxX8oCk9bM+vAkDyujDV6dmKHbvQpg==" "integrity": "sha512-Gi5wRGPbbyOTX+4Y2iULQ27oUPrefaB0PxGQJnfyWN3kvEDGM3mIB5M/gQLmitZf7A9FmLeaqxD3L1CXpm3VKQ=="
}, },
"node_modules/@types/pug": { "node_modules/@types/pug": {
"version": "2.0.6", "version": "2.0.6",
@ -1540,6 +1556,24 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
}, },
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
"integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
"hasInstallScript": true,
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.11",
"node-addon-api": "^5.0.0"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/bcrypt/node_modules/node-addon-api": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@ -1951,9 +1985,9 @@
} }
}, },
"node_modules/daisyui": { "node_modules/daisyui": {
"version": "3.5.0", "version": "3.6.3",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-3.5.0.tgz", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-3.6.3.tgz",
"integrity": "sha512-wSaeXwaYdMv4yURv9wj7kKQQN9Jyumfh/skIpZfCNkCb2jLf/so+iNKSM8l4rDN0TRvB5OccMlAvsf2UAtk2gg==", "integrity": "sha512-VNWogAjx37H8kNYd2E/+r1OXc6dOvJTKlKltqIKAlNMFVfx2BIKPcmnVxaHQLfj2vhv1mYDBjgWj+1enQ+4yZA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"colord": "^2.9", "colord": "^2.9",
@ -2822,6 +2856,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immutable": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz",
"integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==",
"dev": true
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -4190,6 +4230,12 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/reflect-metadata": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
"integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==",
"peer": true
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.2", "version": "1.22.2",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz",
@ -4353,6 +4399,23 @@
"rimraf": "bin.js" "rimraf": "bin.js"
} }
}, },
"node_modules/sass": {
"version": "1.66.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.66.1.tgz",
"integrity": "sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==",
"dev": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.5.4", "version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@ -4436,6 +4499,42 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/sequelize-typescript": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/sequelize-typescript/-/sequelize-typescript-2.1.5.tgz",
"integrity": "sha512-x1CNODct8gJyfZPwEZBU5uVGNwgJI2Fda913ZxD5ZtCSRyTDPBTS/0uXciF+MlCpyqjpmoCAPtudQWzw579bzA==",
"dependencies": {
"glob": "7.2.0"
},
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"@types/node": "*",
"@types/validator": "*",
"reflect-metadata": "*",
"sequelize": ">=6.20.1"
}
},
"node_modules/sequelize-typescript/node_modules/glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/set-blocking": { "node_modules/set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",

View File

@ -15,21 +15,26 @@
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^1.3.1", "@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.20.4", "@sveltejs/kit": "^1.20.4",
"@types/bcrypt": "^5.0.0",
"@types/node": "^20.5.6",
"@types/validator": "^13.11.1",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"daisyui": "^3.5.0", "daisyui": "^3.6.3",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0", "eslint-plugin-svelte": "^2.30.0",
"postcss": "^8.4.27", "postcss": "^8.4.27",
"prettier": "^2.8.0", "prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1", "prettier-plugin-svelte": "^2.10.1",
"sass": "^1.66.1",
"svelte": "^4.0.5", "svelte": "^4.0.5",
"svelte-check": "^3.4.3", "svelte-check": "^3.4.3",
"svelte-heros-v2": "^0.9.3", "svelte-heros-v2": "^0.9.3",
"svelte-local-storage-store": "^0.6.0", "svelte-local-storage-store": "^0.6.0",
"svelte-multicssclass": "^2.1.1", "svelte-multicssclass": "^2.1.1",
"svelte-preprocess": "^5.0.4",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
@ -38,9 +43,11 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"mariadb": "^3.2.0", "mariadb": "^3.2.0",
"sequelize": "^6.32.1", "sequelize": "^6.32.1",
"sequelize-typescript": "^2.1.5",
"sqlite3": "^5.1.6" "sqlite3": "^5.1.6"
} }
} }

View File

@ -1,4 +1,25 @@
import { sequelize } from '$lib/server/database'; import { sequelize } from '$lib/server/database';
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import { getSession } from '$lib/server/session';
// make sure that the database and tables exist // make sure that the database and tables exist
await sequelize.sync(); await sequelize.sync();
export const handle: Handle = async ({ event, resolve }) => {
if (
event.url.pathname.startsWith(`${env.PUBLIC_BASE_PATH}/admin`) &&
event.url.pathname != `${env.PUBLIC_BASE_PATH}/admin/login`
) {
if (getSession(event.cookies.get('session') || '') == null) {
return new Response(null, {
status: 302,
headers: {
location: `${env.PUBLIC_BASE_PATH}/admin/login`
}
});
}
}
return resolve(event);
};

View File

@ -0,0 +1,56 @@
<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 = false;
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" class:brightness-[60%]={disabled}>
<span class:cursor-not-allowed={disabled}>
<button
{disabled}
class:pointer-events-none={disabled}
on:click={() => {
value.splice(i, 1);
value = value;
}}>✕</button
>
</span>
{Object.keys(available)[Object.values(available).indexOf(badge)]}
</div>
{/if}
{/each}
</div>
</div>

View File

@ -1,24 +1,49 @@
<svelte:options accessors={true} />
<script lang="ts"> <script lang="ts">
import { IconSolid } from 'svelte-heros-v2'; import { IconSolid } from 'svelte-heros-v2';
import { createEventDispatcher } from 'svelte';
export let id: string; export let id: string | null = null;
export let name: string | null = null; export let name: string | null = null;
export let type: string; export let type: string;
export let value: string | null = null; export let value: string | null = null;
export let placeholder: string | null = null;
export let required = false; export let required = false;
export let disabled = false; export let disabled = false;
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
export let inputElement: HTMLInputElement | undefined = undefined; export let inputElement: HTMLInputElement | undefined = undefined;
const dispatch = createEventDispatcher();
function input(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
dispatch('input', e);
}
let initialType = type; let initialType = type;
let passwordEyeSize = {
xs: '14',
sm: '18',
md: '24',
lg: '30'
};
</script> </script>
<!-- the cursor-not-allowed class must be set here because a disabled button does not respect the 'cursor' css property --> <!-- 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' : ''}> <div class={type === 'submit' && disabled ? 'cursor-not-allowed' : ''}>
{#if type === 'submit'} {#if type === 'submit'}
<input class="btn" {id} type="submit" {value} {disabled} bind:this={inputElement} /> <input
class="btn"
class:btn-xs={size === 'xs'}
class:btn-sm={size === 'sm'}
class:btn-md={size === 'md'}
class:btn-lg={size === 'lg'}
{id}
type="submit"
{disabled}
bind:value
bind:this={inputElement}
on:input={input}
/>
{:else} {:else}
<div> <div>
{#if $$slots.label} {#if $$slots.label}
@ -31,32 +56,56 @@
</span> </span>
</label> </label>
{/if} {/if}
<div class="flex items-center"> <div class="relative flex items-center" class:sm:max-w-[16rem]={type !== 'checkbox'}>
<input <input
class:checkbox={type === 'checkbox'} class:checkbox={type === 'checkbox'}
class:input,input-bordered,w-[100%],sm:max-w-[16rem]={type !== 'checkbox'} class:checkbox-xs={type === 'checkbox' && size === 'xs'}
class:checkbox-sm={type === 'checkbox' && size === 'sm'}
class:checkbox-md={type === 'checkbox' && size === 'md'}
class:checkbox-lg={type === 'checkbox' && size === 'lg'}
class:input,w-full={type !== 'checkbox'}
class:input-xs={type !== 'checkbox' && size === 'xs'}
class:input-sm={type !== 'checkbox' && size === 'sm'}
class:input-md={type !== 'checkbox' && size === 'md'}
class:input-lg={type !== 'checkbox' && size === 'lg'}
class:input-bordered={type !== 'checkbox'}
class:pr-11={initialType === 'password'} class:pr-11={initialType === 'password'}
{id} {id}
{name} {name}
{type} {type}
{value} {value}
{placeholder}
{required} {required}
{disabled} {disabled}
autocomplete="off"
bind:this={inputElement} bind:this={inputElement}
autocomplete="off"
on:input={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
value = e.target?.value;
input(e);
}}
/> />
{#if initialType === 'password'} {#if initialType === 'password'}
<button <button
class="relative right-9" class="absolute right-3"
type="button" type="button"
on:click={() => { on:click={() => {
type = type === 'password' ? 'text' : 'password'; type = type === 'password' ? 'text' : 'password';
}} }}
> >
{#if type === 'password'} {#if type === 'password'}
<IconSolid name="eye-slash-solid" /> <IconSolid
name="eye-slash-solid"
width={passwordEyeSize[size]}
height={passwordEyeSize[size]}
/>
{:else} {:else}
<IconSolid name="eye-solid" /> <IconSolid
name="eye-solid"
width={passwordEyeSize[size]}
height={passwordEyeSize[size]}
/>
{/if} {/if}
</button> </button>
{/if} {/if}

View File

@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
export let id: string; export let id: string;
export let name: string | null = null; export let name: string | null = null;
export let value: string; export let value: string | null = null;
export let label: string | null = null; export let label: string | null = null;
export let notice: string | null = null; export let notice: string | null = null;
export let required = false; export let required = false;
export let disabled = false; export let disabled = false;
export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
</script> </script>
<div> <div>
@ -20,7 +21,11 @@
</label> </label>
{/if} {/if}
<select <select
class="input input-bordered w-[100%] sm:max-w-[16rem]" class="select select-bordered w-[100%] sm:max-w-[16rem]"
class:select-xs={size === 'xs'}
class:select-sm={size === 'sm'}
class:select-md={size === 'md'}
class:select-lg={size === 'lg'}
{id} {id}
{name} {name}
{required} {required}

View File

@ -0,0 +1,58 @@
<script lang="ts">
import { IconOutline } from 'svelte-heros-v2';
import { fly } from 'svelte/transition';
import { onDestroy } from 'svelte';
export let timeout = 2000;
export let show = false;
export function reset() {
progressValue = 1;
}
let progressValue = 100;
let intervalClear: ReturnType<typeof setInterval> | undefined;
function startTimout() {
intervalClear = setInterval(() => {
if (++progressValue > 100) {
clearInterval(intervalClear);
show = false;
progressValue = 100;
}
}, timeout / 100);
}
$: if (show) {
progressValue = 0;
startTimout();
}
onDestroy(() => clearInterval(intervalClear));
</script>
{#if show && progressValue !== 0}
<div
in:fly={{ x: 0, duration: 200 }}
out:fly={{ x: 400, duration: 400 }}
class="toast"
on:mouseenter={() => {
clearInterval(intervalClear);
progressValue = 1;
}}
on:mouseleave={startTimout}
role="alert"
>
<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" />
<slot />
</div>
<progress
class="progress progress-error absolute bottom-0 h-[3px] w-full bg-[rgba(0,0,0,0.6)]"
value={progressValue}
max="100"
/>
</div>
</div>
{/if}

View File

@ -0,0 +1,58 @@
export async function buttonTriggeredRequest<T>(e: MouseEvent, promise: Promise<T>) {
(e.target as HTMLButtonElement).disabled = true;
await promise;
(e.target as HTMLButtonElement).disabled = false;
}
export function resizeTableColumn(event: MouseEvent, dragOffset: number) {
const element = event.target as HTMLTableCellElement;
const rect = element.getBoundingClientRect();
const posX = event.clientX - rect.left;
const offset = rect.width - event.clientX;
if (posX <= dragOffset || posX >= rect.width - dragOffset) {
// do not resize if resize request is on the table left or right
if (
(posX <= dragOffset && !element.previousElementSibling) ||
(posX >= rect.width - dragOffset && !element.nextElementSibling)
) {
return;
}
const table = element.parentElement!.parentElement!.parentElement as HTMLTableElement;
let resizeRow: HTMLTableRowElement;
if (table.tBodies[0].rows[0].hasAttribute('resize-row')) {
resizeRow = table.tBodies[0].rows[0];
} else {
resizeRow = table.tBodies[0].insertRow(0);
resizeRow.setAttribute('resize-row', '');
resizeRow.style.height = '0';
resizeRow.style.border = '0';
resizeRow.style.overflow = 'hidden';
for (let i = 0; i < table.rows[0].cells.length; i++) {
const cell = resizeRow.insertCell();
cell.style.padding = '0';
}
// insert an additional to keep the zebra in place pattern which might be applied
const zebraGhostRow = table.tBodies[0].insertRow(1);
zebraGhostRow.hidden = true;
}
const resizeElement =
resizeRow.cells[element.cellIndex - ((posX <= dragOffset) as unknown as number)];
// eslint-disable-next-line svelte/no-inner-declarations,no-inner-declarations
function resize(e: MouseEvent) {
document.body.style.cursor = 'col-resize';
resizeElement.style.width = `${offset + e.clientX}px`;
}
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', resize);
document.body.style.cursor = 'initial';
});
}
}

View File

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

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

@ -0,0 +1,58 @@
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[] | null) {
if (typeof value == 'number') {
this.value = value;
} else if (value == null) {
this.value = 0;
} 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

@ -1,37 +1,72 @@
import { DataTypes, Sequelize } from 'sequelize'; import { DataTypes } from 'sequelize';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { building, dev } from '$app/environment'; import { building, dev } from '$app/environment';
import * as bcrypt from 'bcrypt';
import {
BeforeCreate,
BeforeUpdate,
Column,
Model,
Sequelize,
Table,
Unique
} from 'sequelize-typescript';
import { Permissions } from '$lib/permissions';
@Table({ modelName: 'user' })
export class User extends Model {
@Column({ type: DataTypes.STRING, allowNull: false })
declare firstname: string;
@Column({ type: DataTypes.STRING, allowNull: false })
declare lastname: string;
@Column({ type: DataTypes.DATE, allowNull: false })
declare birthday: Date;
@Column({ type: DataTypes.STRING })
declare telephone: string;
@Column({ type: DataTypes.STRING, allowNull: false })
declare username: string;
@Column({ type: DataTypes.ENUM('java', 'bedrock', 'cracked'), allowNull: false })
declare playertype: 'java' | 'bedrock' | 'cracked';
@Column({ type: DataTypes.STRING })
declare password: string;
@Column({ type: DataTypes.UUIDV4 })
declare uuid: string;
}
@Table({ modelName: 'admin' })
export class Admin extends Model {
@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,
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.changed() || []).indexOf('password') != -1) {
instance.password = bcrypt.hashSync(instance.password, 10);
}
}
validatePassword(password: string): boolean {
return bcrypt.compareSync(password, this.password);
}
}
export const sequelize = new Sequelize(building ? 'sqlite::memory:' : env.DATABASE_URI, { export const sequelize = new Sequelize(building ? 'sqlite::memory:' : env.DATABASE_URI, {
// only log sql queries in dev mode // only log sql queries in dev mode
logging: dev ? console.log : false logging: dev ? console.log : false,
}); models: [User, Admin]
export const User = sequelize.define('user', {
firstname: {
type: DataTypes.STRING,
allowNull: false
},
lastname: {
type: DataTypes.STRING,
allowNull: false
},
birthday: {
type: DataTypes.DATE,
allowNull: false
},
telephone: DataTypes.STRING,
username: {
type: DataTypes.STRING,
allowNull: false
},
playertype: {
type: DataTypes.ENUM('java', 'bedrock', 'cracked'),
allowNull: false
},
password: DataTypes.TEXT,
uuid: {
type: DataTypes.UUIDV4,
allowNull: false
}
}); });

64
src/lib/server/session.ts Normal file
View File

@ -0,0 +1,64 @@
import type { Permissions } from '$lib/permissions';
import type { Cookies } from '@sveltejs/kit';
import * as crypto from 'crypto';
import type { Admin } from '$lib/server/database';
export interface Session {
sessionId: string;
userId: number;
permissions: Permissions;
}
let sessions: Session[] = [];
function sessionFromId(sessionId: string | Cookies): Session | null {
const sessId = sessionIdFromStringOrCookies(sessionId);
return sessions.find((v) => v.sessionId == sessId) || null;
}
function sessionIdFromStringOrCookies(input: string | Cookies): string | null {
return typeof input == 'string' ? input : input.get('session') || null;
}
export function addSession(user: { id: number; permissions: Permissions } | Admin): string {
const session = crypto.randomBytes(16).toString('hex');
sessions.push({
sessionId: session,
userId: user.id,
permissions: user.permissions
});
return session;
}
export function getSession(
sessionId: string | Cookies,
options?: { permissions?: number[] }
): Session | null {
const session = sessionFromId(sessionId);
if (!session) {
return null;
}
for (const perm of options?.permissions || []) {
if ((session.permissions.value & perm) == 0) {
return null;
}
}
return session;
}
export function updateAllUserSessions(userId: number, options: { permissions: Permissions }) {
for (const session of sessions.filter((v) => v.userId == userId)) {
session.permissions = options.permissions;
}
}
export function deleteSession(sessionId: string | Cookies) {
const session = sessionFromId(sessionId);
if (session) {
sessions.splice(sessions.indexOf(session), 1);
}
}
export function deleteAllUserSessions(userId: number) {
sessions = sessions.filter((v) => v.userId != userId);
}

View File

@ -1,4 +1,7 @@
import { persisted } from 'svelte-local-storage-store'; import { persisted } from 'svelte-local-storage-store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export const playAudio: Writable<boolean> = persisted('playAudio', false); export const playAudio: Writable<boolean> = persisted('playAudio', false);
export const adminCount: Writable<number> = writable(0);

View File

@ -4,6 +4,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Settings from './Settings.svelte'; import Settings from './Settings.svelte';
import { playAudio } from '$lib/stores'; import { playAudio } from '$lib/stores';
import { page } from '$app/stores';
let navPaths = [ let navPaths = [
{ {
@ -56,6 +57,11 @@
navPaths[menuButtonScrollIndex].active = true; navPaths[menuButtonScrollIndex].active = true;
} }
let onAdminPage = false;
$: onAdminPage =
$page.url.pathname.startsWith(`${env.PUBLIC_BASE_PATH}/admin`) &&
$page.url.pathname !== `${env.PUBLIC_BASE_PATH}/admin/login`;
let isTouch = false; let isTouch = false;
let nav: HTMLDivElement; let nav: HTMLDivElement;
let settings: HTMLDialogElement; let settings: HTMLDialogElement;
@ -80,11 +86,14 @@
/> />
<main> <main>
<div class="h-screen w-full">
<slot /> <slot />
</div>
</main> </main>
<nav> <nav>
<div <div
class="fixed bottom-4 right-4 sm:right-[initial] sm:left-4 group/menu-bar flex flex-col-reverse justify-center items-center" class="fixed bottom-4 right-4 sm:left-4 sm:right-[initial] group/menu-bar flex flex-col-reverse justify-center items-center"
class:hidden={onAdminPage}
bind:this={nav} bind:this={nav}
> >
<button <button

View File

@ -0,0 +1,12 @@
import type { LayoutServerLoad } from './$types';
import { Admin, User } from '$lib/server/database';
import { getSession } from '$lib/server/session';
export const load: LayoutServerLoad = async ({ cookies }) => {
const session = getSession(cookies);
return {
userCount: session?.permissions.userRead() ? await User.count() : null,
adminCount: session?.permissions.adminRead() ? await Admin.count() : null
};
};

View File

@ -0,0 +1,63 @@
<script lang="ts">
import { page } from '$app/stores';
import { env } from '$env/dynamic/public';
import { IconOutline } from 'svelte-heros-v2';
import { buttonTriggeredRequest } from '$lib/components/utils';
import { goto } from '$app/navigation';
import type { LayoutData } from './$types';
import { adminCount } from '$lib/stores';
async function logout() {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/logout`, {
method: 'POST'
});
if (response.ok) {
await goto(`${env.PUBLIC_BASE_PATH}/`);
} else {
throw new Error();
}
}
export let data: LayoutData;
if (data.adminCount) $adminCount = data.adminCount;
</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-max h-full bg-base-200 text-base-content">
{#if data.userCount != null}
<li>
<a href="{env.PUBLIC_BASE_PATH}/admin/users">
<IconOutline name="user-group-outline" />
<span class="ml-1">Registrierte Nutzer</span>
<div class="badge">{data.userCount}</div>
</a>
</li>
{/if}
{#if data.adminCount != null}
<li>
<a href="{env.PUBLIC_BASE_PATH}/admin/admin">
<IconOutline name="users-outline" />
<span class="ml-1">Website Admins</span>
<div class="badge">{$adminCount}</div>
</a>
</li>
{/if}
<li class="mt-auto">
<button on:click={(e) => buttonTriggeredRequest(e, logout())}>
<IconOutline name="arrow-left-on-rectangle-outline" />
<span class="ml-1">Ausloggen</span>
</button>
</li>
</ul>
</div>
<div class="h-full w-full overflow-scroll">
<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,28 @@
import type { PageServerLoad } from './$types';
import { Admin } from '$lib/server/database';
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
export const load: PageServerLoad = async ({ parent, cookies }) => {
const { adminCount } = await parent();
if (adminCount == null) throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
let admins: (typeof Admin.prototype)[] = [];
if (getSession(cookies, { permissions: [Permissions.AdminRead] }) != null) {
admins = await Admin.findAll({ raw: true, attributes: { exclude: ['password'] } });
}
const session = getSession(cookies);
return {
admins: admins.map((v) => {
const vv = JSON.parse(JSON.stringify(v));
vv.permissions = new Permissions(v.permissions as unknown as number).asArray();
return vv;
}),
id: session?.userId,
permissions: session?.permissions.value || 0
};
};

View File

@ -0,0 +1,268 @@
<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';
import { buttonTriggeredRequest, resizeTableColumn } from '$lib/components/utils';
import { goto } from '$app/navigation';
import { adminCount } from '$lib/stores';
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 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) {
let res = await response.json();
$adminCount += 1;
res.permissions = new Permissions(res.permissions).asArray();
data.admins.push(res);
data.admins = data.admins;
} else {
throw new Error();
}
}
async function updateAdmin(
id: number,
username: string | null,
password: string | null,
updatePermissions: Permissions | null
) {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/admin`, {
method: 'PATCH',
body: JSON.stringify({
id: id,
username: username,
password: password,
permissions: updatePermissions?.value
})
});
if (response.ok) {
if (id === data.id && updatePermissions) {
permissions = updatePermissions;
}
} else {
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) {
if (id === data.id) {
await goto(`${env.PUBLIC_BASE_PATH}/`);
} else {
$adminCount -= 1;
data.admins.splice(
data.admins.findIndex((v) => 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 w-full">
<thead>
<tr>
<th on:mousedown={(e) => resizeTableColumn(e, 5)} />
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>Benutzername</th>
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>Passwort</th>
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>Berechtigungen</th>
<th on:mousedown={(e) => resizeTableColumn(e, 5)} />
</tr>
</thead>
<tbody>
{#each data.admins as admin, i}
<tr>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{i + 1}</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input
type="text"
bind:value={admin.username}
disabled={!permissions.adminWrite() || !admin.edit}
size="sm"
/></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input
type="password"
bind:value={admin.password}
placeholder="Neues Passwort..."
disabled={!permissions.adminWrite() || !admin.edit}
size="sm"
/></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Badges
bind:value={admin.permissions}
available={allPermissionBadges}
disabled={!permissions.adminWrite() || !admin.edit}
/></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<div>
{#if admin.edit}
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
<button
class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()}
on:click={async (e) => {
await buttonTriggeredRequest(
e,
updateAdmin(
admin.id,
admin.username,
admin.password,
new Permissions(admin.permissions)
)
);
admin.password = '';
admin.edit = false;
}}
>
<IconOutline name="check-outline" width="18" height="18" />
</button>
</span>
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
<button
class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()}
on:click={() => {
admin.edit = false;
admin = admin.before;
}}
>
<IconOutline name="no-symbol-outline" width="18" height="18" />
</button>
</span>
{:else}
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
<button
class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()}
on:click={() => {
admin.edit = true;
admin.before = structuredClone(admin);
}}
>
<IconOutline name="pencil-square-outline" width="18" height="18" />
</button>
</span>
<span class="w-min" class:cursor-not-allowed={!permissions.adminWrite()}>
<button
class="btn btn-sm btn-square"
disabled={!permissions.adminWrite()}
on:click={(e) => buttonTriggeredRequest(e, deleteAdmin(admin.id))}
>
<IconOutline name="trash-outline" width="18" height="18" />
</button>
</span>
{/if}
</div>
</td>
</tr>
{/each}
<tr>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{data.admins.length + 1}</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" bind:value={newAdminUsername} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="password" bind:value={newAdminPassword} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Badges
bind:value={newAdminPermissions}
available={allPermissionBadges}
disabled={!permissions.adminWrite()}
/></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<span
class="w-min"
class:cursor-not-allowed={!permissions.adminWrite() ||
!newAdminUsername ||
!newAdminPassword}
>
<button
class="btn btn-sm 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="18" height="18" />
</button>
</span>
</td>
</tr>
</tbody>
</table>
<ErrorToast show={errorMessage !== ''}>
<span />
</ErrorToast>
<style lang="scss">
thead tr th,
tbody tr td {
@apply relative;
&:not(:first-child) {
@apply border-l-[1px] border-dashed;
&::before {
@apply absolute left-0 bottom-0 h-full w-[5px] cursor-col-resize;
content: '';
}
}
&:not(:last-child) {
@apply border-r-[1px] border-dashed border-base-300;
&::after {
@apply absolute right-0 bottom-0 h-full w-[5px] cursor-col-resize;
content: '';
}
}
}
</style>

View File

@ -0,0 +1,97 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Permissions } from '$lib/permissions';
import {
addSession,
deleteAllUserSessions,
deleteSession,
getSession,
updateAllUserSessions
} from '$lib/server/session';
import { Admin } from '$lib/server/database';
import { env as publicEnv } from '$env/dynamic/public';
export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [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)
});
delete admin.dataValues.password;
return new Response(JSON.stringify(admin), {
status: 201
});
}) satisfies RequestHandler;
export const PATCH = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [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 user = await Admin.findOne({ where: { id: id } });
if (!user) {
return new Response(null, {
status: 400
});
}
if (data['username']) user.username = data['username'];
if (data['password']) user.password = data['password'];
if (data['permissions']) user.permissions = new Permissions(data['permissions']);
await user.save();
updateAllUserSessions(user.id, { permissions: user.permissions });
return new Response();
}) satisfies RequestHandler;
export const DELETE = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.AdminWrite] }) == null) {
return new Response(null, {
status: 401
});
}
const data = await request.json();
const id = data['id'] as number | null;
if (id == null) {
return new Response(null, {
status: 400
});
}
await Admin.destroy({ where: { id: id } });
deleteAllUserSessions(id);
return new Response();
}) satisfies RequestHandler;

View File

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

View File

@ -0,0 +1,88 @@
<script lang="ts">
import Input from '$lib/components/Input/Input.svelte';
import { env } from '$env/dynamic/public';
import ErrorToast from '$lib/components/Toast/ErrorToast.svelte';
import { goto } from '$app/navigation';
let passwordValue: string;
async function login() {
// eslint-disable-next-line no-async-promise-executor
loginRequest = new Promise(async (resolve, reject) => {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/login`, {
method: 'POST',
body: new FormData(document.forms[0])
});
if (response.ok) {
await goto(`${env.PUBLIC_BASE_PATH}/admin`, { invalidateAll: true });
resolve();
} else if (response.status == 403) {
passwordValue = '';
showError = true;
errorToastElement.reset();
resolve();
} else {
reject(Error(`${response.statusText} (${response.status})`));
}
loginRequest = null;
});
}
let loginRequest: Promise<void> | null = null;
let showError = false;
let errorToastElement: ErrorToast;
</script>
<div class="card px-14 py-6 shadow-lg">
<h1 class="text-center text-4xl mt-2 mb-4">Craftattack Admin Login</h1>
<form class="flex flex-col items-center" on:submit|preventDefault={login}>
<div class="flex flex-col justify-center items-center">
<div class="grid gap-4">
<Input id="username" name="username" type="text" required={true}>
<span slot="label">Nutzername</span>
</Input>
<Input
id="password"
name="password"
type="password"
required={true}
bind:value={passwordValue}
>
<span slot="label">Passwort</span>
</Input>
</div>
</div>
<div class="grid w-min mt-6">
<div class="row-[1] col-[1]">
<Input id="submit" type="submit" value="Login" disabled={loginRequest !== null} />
</div>
{#key loginRequest}
{#if loginRequest}
{#await loginRequest}
<span
class="relative top-[calc(50%-12px)] left-[calc(50%-12px)] row-[1] col-[1] loading loading-ring"
/>
{:catch error}
<dialog
class="modal"
on:close={() => setTimeout(() => (loginRequest = null), 200)}
open
>
<form method="dialog" class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<h3 class="font-bold text-lg">Error</h3>
<p class="py-4">{error.message}</p>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.2)]">
<button>close</button>
</form>
</dialog>
{/await}
{/if}
{/key}
</div>
</form>
</div>
<ErrorToast timeout={2000} bind:show={showError} bind:this={errorToastElement}>
<span>Nutzername oder Passwort falsch</span>
</ErrorToast>

View File

@ -0,0 +1,52 @@
import type { RequestHandler } from '@sveltejs/kit';
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();
const username = data.get('username') as string | null;
const password = data.get('password') as string | null;
if (username == null || password == null) {
return new Response(null, {
status: 401
});
}
if (
env.ADMIN_USER &&
env.ADMIN_PASSWORD &&
username == env.ADMIN_USER &&
password == env.ADMIN_PASSWORD
) {
cookies.set(
'session',
addSession({ id: -1, permissions: new Permissions(Permissions.allPermissions()) }),
{
path: `${publicEnv.PUBLIC_BASE_PATH}/admin`,
maxAge: 60 * 60 * 24 * 90,
httpOnly: true,
secure: true
}
);
return new Response();
}
const user = await Admin.findOne({ where: { username: username } });
if (user && user.validatePassword(password)) {
cookies.set('session', addSession(user), {
path: `${publicEnv.PUBLIC_BASE_PATH}/admin`,
maxAge: 60 * 60 * 24 * 90,
httpOnly: true,
secure: true
});
return new Response();
} else {
return new Response(null, {
status: 401
});
}
}) satisfies RequestHandler;

View File

@ -0,0 +1,15 @@
import type { RequestHandler } from '@sveltejs/kit';
import { deleteSession, getSession } from '$lib/server/session';
export const POST = (async ({ cookies }) => {
if (getSession(cookies) == null) {
return new Response(null, {
status: 401
});
}
deleteSession(cookies);
cookies.delete('session');
return new Response();
}) satisfies RequestHandler;

View File

@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { User } from '$lib/server/database';
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
export const load: PageServerLoad = async ({ parent, cookies }) => {
const { userCount } = await parent();
if (userCount == null) throw redirect(302, `${env.PUBLIC_BASE_PATH}/admin`);
return {
count:
getSession(cookies, { permissions: [Permissions.UserRead] }) != null ? await User.count() : 0
};
};

View File

@ -0,0 +1,329 @@
<script lang="ts">
import type { PageData } from './$types';
import { IconOutline, IconSolid } from 'svelte-heros-v2';
import Input from '$lib/components/Input/Input.svelte';
import Select from '$lib/components/Input/Select.svelte';
import { env } from '$env/dynamic/public';
import type { User } from '$lib/server/database';
import { buttonTriggeredRequest, resizeTableColumn } from '$lib/components/utils';
import { browser } from '$app/environment';
export let data: PageData;
let headers = [
{
name: 'Vorname',
key: 'firstname',
asc: false
},
{
name: 'Nachname',
key: 'lastname',
asc: false
},
{ name: 'Geburtstag', key: 'birthday', asc: false, sort: (a, b) => a.birthday - b.birthday },
{ name: 'Telefon', key: 'telephone', asc: false, sort: (a, b) => a.telephone - b.telephone },
{
name: 'Username',
key: 'username',
asc: false
},
{
name: 'Minecraft Edition',
key: 'playertype',
asc: false
},
{
name: 'Passwort',
key: 'password',
asc: false
},
{ name: 'UUID', key: 'uuid', asc: false }
];
let ascHeader: (typeof headers)[0] | null = null;
let currentPageUsers: (typeof User.prototype.dataValues)[] = [];
let currentPageUsersRequest: Promise<void> = new Promise((resolve) => resolve());
let usersCache: (typeof User.prototype.dataValues)[][] = [];
let usersPerPage = 50;
let userPage = 0;
let userTableContainerElement: HTMLDivElement;
function fetchPageUsers(page: number) {
if (!browser) return;
if (userTableContainerElement) userTableContainerElement.scrollTop = 0;
if (usersCache[page]) {
currentPageUsers = usersCache[page];
return;
}
// eslint-disable-next-line no-async-promise-executor
currentPageUsersRequest = new Promise(async (resolve, reject) => {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
method: 'POST',
body: JSON.stringify({
limit: usersPerPage,
from: usersPerPage * page
})
});
if (response.ok) {
const pageUsers = await response.json();
currentPageUsers = usersCache[page] = pageUsers;
resolve();
} else {
reject(Error());
}
});
}
async function sortUsers(key: string, reverse: boolean) {
const multiplyValue = reverse ? -1 : 1;
currentPageUsers.sort((entryA, entryB) => {
const a = entryA[key];
const b = entryB[key];
switch (typeof a) {
case 'number':
return (a - b) * multiplyValue;
case 'string':
return a.localeCompare(b) * multiplyValue;
default:
return (a - b) * multiplyValue;
}
});
currentPageUsers = currentPageUsers;
}
$: fetchPageUsers(userPage);
async function updateUser(user: typeof User.prototype.dataValues) {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
method: 'PATCH',
body: JSON.stringify(user)
});
if (!response.ok) {
throw new Error();
}
}
async function deleteUser(id: number) {
const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
method: 'DELETE',
body: JSON.stringify({
id: id
})
});
if (response.ok) {
currentPageUsers.splice(
currentPageUsers.findIndex((v) => v.id == id),
1
);
currentPageUsers = currentPageUsers;
} else {
throw new Error();
}
}
</script>
<div>
<div class="h-[90vh] overflow-scroll" bind:this={userTableContainerElement}>
<table class="table relative">
<thead>
<tr class="[&>th]:bg-base-100 [&>th]:z-[1] [&>th]:sticky [&>th]:top-0">
<th />
{#each headers as header}
<th on:mousedown={(e) => resizeTableColumn(e, 5)}>
<button
class="flex items-center"
on:click={() => {
sortUsers(header.key, (ascHeader = ascHeader == header ? null : header));
}}
>
{header.name}
<span class="ml-1">
<IconSolid
name={ascHeader === header ? 'chevron-up-solid' : 'chevron-down-solid'}
width="12"
height="12"
/>
</span>
</button>
</th>
{/each}
<th />
</tr>
</thead>
<tbody>
{#key currentPageUsersRequest}
{#await currentPageUsersRequest}
{#each Array(usersPerPage) as _, i}
<tr class="animate-pulse text-transparent">
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{i + 1}</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="date" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="tel" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Select id="edition" disabled={true} size="sm">
<option value="java">Java Edition</option>
<option value="bedrock">Bedrock Edition</option>
<option value="cracked">Java cracked</option>
</Select></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><Input type="text" disabled={true} size="sm" /></td
>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}
><div class="flex gap-1">
<button class="btn btn-sm btn-square" disabled />
</div></td
>
</tr>
{/each}
{:then _}
{#each currentPageUsers as user, i}
<tr>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>{i + 1}</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="text" bind:value={user.firstname} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="text" bind:value={user.lastname} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input
type="date"
value={new Date(user.birthday).toISOString().split('T')[0]}
on:input={(e) => (user.birthday = e.detail.target.valueAsDate.toISOString())}
disabled={!user.edit}
size="sm"
/>
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="tel" bind:value={user.telephone} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="text" bind:value={user.username} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Select id="edition" bind:value={user.playertype} disabled={!user.edit} size="sm">
<option value="java">Java Edition</option>
<option value="bedrock">Bedrock Edition</option>
<option value="cracked">Java cracked</option>
</Select>
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input type="text" bind:value={user.password} disabled={!user.edit} size="sm" />
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<Input
id="uuid"
type="text"
bind:value={user.uuid}
disabled={!user.edit}
size="sm"
/>
</td>
<td on:mousedown={(e) => resizeTableColumn(e, 5)}>
<div class="flex gap-1">
{#if user.edit}
<button
class="btn btn-sm btn-square"
on:click={async (e) => {
await buttonTriggeredRequest(e, updateUser(user));
user.edit = false;
}}
>
<IconOutline name="check-outline" width="18" height="18" />
</button>
<button
class="btn btn-sm btn-square"
on:click={() => {
user.edit = false;
user = user.before;
}}
>
<IconOutline name="no-symbol-outline" width="18" height="18" />
</button>
{:else}
<button
class="btn btn-sm btn-square"
on:click={() => {
user.before = structuredClone(user);
user.edit = true;
}}
>
<IconOutline name="pencil-square-outline" width="18" height="18" />
</button>
<button
class="btn btn-sm btn-square"
on:click={(e) => buttonTriggeredRequest(e, deleteUser(user.id))}
>
<IconOutline name="trash-outline" width="18" height="18" />
</button>
{/if}
</div>
</td>
</tr>
{/each}
{/await}
{/key}
</tbody>
</table>
</div>
<div class="flex justify-center w-full mt-4 mb-6">
<div class="join">
{#each Array(Math.ceil(data.count / usersPerPage) || 1) as _, i}
<button
class="join-item btn"
class:btn-active={i === userPage}
on:click={() => {
userPage = i;
}}>{i + 1}</button
>
{/each}
</div>
</div>
</div>
<style lang="scss">
thead tr th,
tbody tr td {
@apply relative;
&:not(:first-child) {
@apply border-l-[1px] border-dashed;
&::before {
@apply absolute left-0 bottom-0 h-full w-[5px] cursor-col-resize;
content: '';
}
}
&:not(:last-child) {
@apply border-r-[1px] border-dashed border-base-300;
&::after {
@apply absolute right-0 bottom-0 h-full w-[5px] cursor-col-resize;
content: '';
}
}
}
</style>

View File

@ -0,0 +1,77 @@
import { getSession } from '$lib/server/session';
import { Permissions } from '$lib/permissions';
import type { RequestHandler } from '@sveltejs/kit';
import { Admin, User } from '$lib/server/database';
export const POST = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserRead] }) == null) {
return new Response(null, {
status: 401
});
}
const data = await request.json();
const limit = data['limit'] || 100;
const from = data['from'] || 0;
const users = await User.findAll({ offset: from, limit: limit });
return new Response(JSON.stringify(users));
}) satisfies RequestHandler;
export const PATCH = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserWrite] }) == 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 user = await User.findOne({ where: { id: id } });
if (!user) {
return new Response(null, {
status: 400
});
}
if (data['firstname']) user.firstname = data['firstname'];
if (data['lastname']) user.lastname = data['lastname'];
if (data['birthday']) user.birthday = data['birthday'];
if (data['telephone']) user.telephone = data['telephone'];
if (data['username']) user.username = data['username'];
if (data['playertype']) user.playertype = data['playertype'];
if (data['password']) user.password = data['password'];
if (data['uuid']) user.uuid = data['uuid'];
await user.save();
return new Response();
}) satisfies RequestHandler;
export const DELETE = (async ({ request, cookies }) => {
if (getSession(cookies, { permissions: [Permissions.UserWrite] }) == null) {
return new Response(null, {
status: 401
});
}
const data = await request.json();
const id = (data['id'] as number) || null;
if (id == null) {
return new Response(null, {
status: 400
});
}
await User.destroy({ where: { id: id } });
return new Response();
}) satisfies RequestHandler;

View File

@ -1,11 +1,11 @@
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite'; import preprocess from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors // Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: preprocess(),
kit: { kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.

View File

@ -8,7 +8,10 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true "strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "es2017"
} }
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// //