diff --git a/src/lib/permissions.config.json b/src/lib/permissions.config.json index 0844928..0cf2da1 100644 --- a/src/lib/permissions.config.json +++ b/src/lib/permissions.config.json @@ -6,7 +6,7 @@ "position": 0, "name": "Anzeigen" }, - "ADD": { + "CREATE": { "position": 1, "name": "Anlegen" }, @@ -25,7 +25,7 @@ }, "meta_permissions": { "MANAGE": { - "permissions": ["VIEW", "ADD", "DELETE", "EDIT"], + "permissions": ["VIEW", "CREATE", "DELETE", "EDIT"], "name": "Verwalten" } } diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 0d50315..0c6eeb0 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -15,17 +15,40 @@ const CHECK_QUERY: string = const USER_DATABASE_SETUP: string[] = [ "PRAGMA foreign_keys = ON;", + "CREATE TABLE meta (key TEXT PRIMARY KEY NOT NULL, value NUMBER);", + "INSERT INTO meta(key, value) VALUES ('triggerActive', 1);", + `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT, gender TEXT, address TEXT, - username TEXT, + username TEXT UNIQUE, password TEXT, permissions INTEGER DEFAULT 0, created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL );`, + `CREATE TABLE IF NOT EXISTS users_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + user_id INTEGER, + name TEXT, + gender TEXT, + address TEXT, + username TEXT, + permissions INTEGER DEFAULT 0, + created DATETIME NOT NULL, + deleted DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + );`, + + `CREATE TRIGGER user_delete_history + BEFORE DELETE ON users + WHEN (SELECT value FROM meta WHERE key = 'triggerActive') = 1 + BEGIN + INSERT INTO users_history(user_id, name, gender, address, username, permissions, created) VALUES (OLD.id, OLD.name, OLD.gender, OLD.address, OLD.username, OLD.permissions, OLD.created); + END;`, + `CREATE TABLE refresh_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER NOT NULL, @@ -65,6 +88,9 @@ const USER_DATABASE_EMPTY: string = const USER_DATABASE_UPDATE_PASSWORD: string = "UPDATE users SET password=$password WHERE id=$id;" +const USER_DATABASE_REMOVE_USER: string = + "DELETE FROM users WHERE id = $id;" + /*const USER_DATABASE_ADD_ACCESS_TOKEN: string = "INSERT INTO access_tokens (user_id, token, expiry_date) VALUES ($user_id, $token, $expiry_date);" @@ -75,7 +101,7 @@ const ENTRY_DATABASE_SETUP: string[] = [ "PRAGMA foreign_keys = ON;", "CREATE TABLE meta (key TEXT PRIMARY KEY NOT NULL, value NUMBER);", - "INSERT INTO meta(key, value) VALUES ('triggerActive', 1)", + "INSERT INTO meta(key, value) VALUES ('triggerActive', 1);", `CREATE TABLE records ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -83,7 +109,8 @@ const ENTRY_DATABASE_SETUP: string[] = [ start VARCHAR(5), end VARCHAR(5), comment TEXT, - created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + UNIQUE (date, start, end) );`, `CREATE TABLE records_history ( @@ -170,6 +197,12 @@ const ENTRY_DATABASE_EDIT_ENTRY: string = const ENTRY_DATABASE_REMOVE_ENTRY: string = "DELETE FROM records WHERE id = $id;"; +const ENTRY_DATABASE_EXPORT: string = + "SELECT date, start, end, comment FROM records;" + +const ENTRY_DATABASE_IMPORT: string = + "INSERT OR IGNORE INTO records(date, start, end, comment) VALUES ($date, $start, $end, $comment);" + const ESTIMATES_DATABASE_GET_ALL: string = "SELECT * FROM estimates ORDER BY year DESC, quarter DESC;" @@ -182,6 +215,12 @@ const ESTIMATES_DATABASE_GET_QUART: string = const ESTIMATES_DATABASE_INSERT: string = "INSERT INTO estimates(year, quarter, estimate_0, estimate_1, estimate_2) VALUES ($year, $quarter, $estimate_0, $estimate_1, $estimate_2);" +const ESTIMATES_DATABASE_EXPORT: string = + "SELECT year, quarter, estimate_0, estimate_1, estimate_2 FROM estimates;" + +const ESTIMATES_DATABASE_IMPORT: string = + "INSERT OR IGNORE INTO estimates(year, quarter, estimate_0, estimate_1, estimate_2) VALUES ($year, $quarter, $estimate_0, $estimate_1, $estimate_2);" + export class User { id: number; gender: string; @@ -219,9 +258,9 @@ export class User { } } - get_months(): { year: string, month: string }[] { + get_months(): { year: number, month: number }[] { const query = this._database.query(ENTRY_DATABASE_GET_MONTHS); - const res = query.all(); + const res = query.all() as { year: string, month: string }[]; const ret = res.map((v) => { return { year: toInt(v.year), month: toInt(v.month) }}) @@ -230,7 +269,7 @@ export class User { get_quarters(): { year: number, quarter: number }[] { const query = this._database.query(ESTIMATES_DATABASE_GET_QUARTERS) - const res = query.all(); + const res = query.all() as { year: number, quarter: number }[]; return res; } @@ -256,14 +295,14 @@ export class User { } const query = this._database.query(ENTRY_DATABASE_GET_ENTRIES_IN_MONTH); - const res = query.all({ year: year.toString(), month: month.toString().padStart(2, '0') }); + const res = query.all({ year: year.toString(), month: month.toString().padStart(2, '0') }) as RecordEntry[]; return res; } get_entry(id: number): RecordEntry { const query = this._database.query(ENTRY_DATABASE_GET_ENTRY_BY_ID); - const res = query.get({ id: id }); + const res = query.get({ id: id }) as RecordEntry; return res; } @@ -305,23 +344,23 @@ export class User { get_estimates(): Array { const query = this._database.query(ESTIMATES_DATABASE_GET_ALL); - const res = query.all(); + const res = query.all() as EstimatesEntry[]; return res; } get_estimate(year: number, quarter: number): EstimatesEntry { const query = this._database.query(ESTIMATES_DATABASE_GET_QUART); - const res = query.get({ year: year, quarter: quarter }); + const res = query.get({ year: year, quarter: quarter }) as EstimatesEntry; return res; } get_estimate_by_month(year: number, month: number): number { const query = this._database.query(ESTIMATES_DATABASE_GET_QUART); - const res = query.all({ year: year, quarter: Math.floor(month / 4 + 1) }); + const res = query.get({ year: year, quarter: Math.floor(month / 4 + 1) }) as EstimatesEntry; - return res[0]?.[`estimate_${month % 3}`] ?? NaN; + return res?.[`estimate_${month % 3}`] ?? NaN; } insert_estimate(year: number, quarter: number, estimate_0: number, estimate_1: number, estimate_2: number) { @@ -331,7 +370,6 @@ export class User { const query = this._database.query(ESTIMATES_DATABASE_INSERT); const res = query.run({ year: year, quarter: quarter, estimate_0: estimate_0, estimate_1: estimate_1, estimate_2: estimate_2 }); - console.log(res) return res.changes > 1; } @@ -352,18 +390,18 @@ function is_db_initialized(db: Database): boolean { throw exception; } - console.log(exception); - return false; } } -function get_user_db_name(user: UserEntry) { - return DATABASES_PATH + "user-" + user.id + ".sqlite" +function get_user_db_name(user_id: number) { + return DATABASES_PATH + "user-" + user_id + ".sqlite" } function setup_db(db: Database, setup_queries: string[]) { - setup_queries.forEach((q) => { /*console.log(q);*/ db.query(q).run(); }); + db.transaction(() => { + setup_queries.forEach((q) => { db.query(q).run(); }); + })() } export async function init_db() { @@ -384,23 +422,15 @@ export function close_db() { } } -export async function create_user(user: { name: string, gender: string, address: string, username: string, password: string }): Promise { +export async function create_user(user: { name: string, gender: string, address: string, username: string, password: string }): Promise { user.password = await Bun.password.hash(user.password, { algorithm: "bcrypt", cost: 11}); - try { - const statement = user_database.query(USER_DATABASE_ADD_USER); - const result = statement.run(user); + const statement = user_database.query(USER_DATABASE_ADD_USER); + const result = statement.run(user); - return result.changes == 1; - } catch (e) { - console.log(e); - if (e instanceof SQLiteError) { - return false; - } + return result.lastInsertRowid; - throw e; - } } export function get_all_user(): { id: number, username: string, name: string }[] { @@ -440,8 +470,8 @@ export function get_user_by_name(username: string): User | null { fs.mkdir(DATABASES_PATH, { recursive: true }); - let userdb = new Database(get_user_db_name(user), { create: true, strict: true }); - + let userdb = new Database(get_user_db_name(user.id), { create: true, strict: true }); + if (!is_db_initialized(userdb)) { setup_db(userdb, ENTRY_DATABASE_SETUP); } @@ -485,3 +515,109 @@ export function update_user_password(user_id: number, password: string) { return result.changes > 0 } + +export function remove_user(user_id: number) { + const query = user_database.prepare(USER_DATABASE_REMOVE_USER) + const result = query.run({ id: user_id }) + + return result.changes > 0 +} + + +type ExportEstimatesEntry = Pick> +type ExportRecordsEntry = Pick> + +export async function export_user_data(user_id: number): Promise { + const db_name = get_user_db_name(user_id) + + if (!(await fs.exists(db_name))) { + return null + } + + const userdb = new Database(db_name, { create: false, strict: true }); + + let user_data: { id: number } & Partial<{ estimates: Array, records: Array }> = { id: user_id } + + try { + user_data.estimates = userdb.query(ESTIMATES_DATABASE_EXPORT).all() as Array + } catch (e) { + if (!(e instanceof SQLiteError)) { + throw e + } + } + + try { + user_data.records = userdb.query(ENTRY_DATABASE_EXPORT).all() as Array + } catch (e) { + if (!(e instanceof SQLiteError)) { + throw e + } + } + + return JSON.stringify(user_data) +} + +export async function import_user_data(data: Partial<{ id: number, estimates: Array, records: Array }>) { + + // null or unknown ar of type object + if (!data.id || !(typeof data.id === "number")) { + throw new Error("Invalid JSON: id") + } + + if (data.estimates != null) { + + if (!(Array.isArray(data.estimates))) { + throw new Error("Invalid JSON: estimates") + } + if (!data.estimates.every((v: unknown): v is ExportEstimatesEntry => + typeof v === "object" + && v != null + && "year" in v + && "quarter" in v + && "estimate_0" in v + && "estimate_1" in v + && "estimate_2" in v + && typeof v.year === "number" + && typeof v.quarter === "number" + && (typeof v.estimate_0 === "number" || v.estimate_0 == null) + && (typeof v.estimate_1 === "number" || v.estimate_1 == null) + && (typeof v.estimate_2 === "number" || v.estimate_2 == null))) { + throw new Error("Invalid JSON: estimates entry") + } + } + + if (data.records != null) { + + if (!(Array.isArray(data.records))) { + throw new Error("Invalid JSON: records") + } + + if (!data.records.every((v: unknown): v is ExportRecordsEntry => + typeof v === "object" + && v != null + && "date" in v + && "start" in v + && "end" in v + && "comment" in v + && typeof v.date === "string" + && typeof v.start === "string" + && typeof v.end === "string" + && typeof v.comment === "string")) { + throw new Error("Invalid JSON: records entry") + } + } + + const user_database_name = get_user_db_name(data.id) + const user_database = new Database(user_database_name, { strict: true }) + + user_database.transaction((records: Array | null, estimates: Array | null) => { + if (records != null) { + const query = user_database.prepare(ENTRY_DATABASE_IMPORT) + for (const record of records) { query.run(record) } + } + if (estimates != null) { + const query = user_database.prepare(ESTIMATES_DATABASE_IMPORT) + for (const estimate of estimates) { query.run(estimate) } + } + })(data.records, data.estimates) +} diff --git a/src/routes/user/+page.server.ts b/src/routes/user/+page.server.ts index 6cbcc73..0e9aca8 100644 --- a/src/routes/user/+page.server.ts +++ b/src/routes/user/+page.server.ts @@ -2,6 +2,7 @@ import type { PageServerLoad, Actions } from "./$types" import type { UserEntry } from "$lib/db_types" +import { SQLiteError } from "bun:sqlite" import { fail, redirect } from "@sveltejs/kit" import Permissions from "$lib/permissions" @@ -9,7 +10,7 @@ import { toInt } from "$lib/util" import Logs from "$lib/server/log" import SessionStore from "$lib/server/session_store" -import { get_user_entry_by_id, updateUser } from "$lib/server/database" +import { get_user_entry_by_id, updateUser, import_user_data, create_user, remove_user } from "$lib/server/database" import { change_password } from "$lib/server/auth" export const load: PageServerLoad = ({ locals, url }) => { @@ -20,7 +21,15 @@ export const load: PageServerLoad = ({ locals, url }) => { let user: UserEntry|null = locals.user.toUserEntry() - if (locals.user.id != (toInt(url.searchParams.get("user") ?? locals.user.id.toFixed(0)))) { + const target_user = url.searchParams.get("user") as string|null + + if (target_user == "new") { + return { + user: null + } + } + + if (locals.user.id != (toInt(target_user ?? locals.user.id.toFixed(0)))) { if (!Permissions.has(locals.user.permissions, Permissions.USERADMIN.VIEW)) { return fail(403, { message: "Insufficient Permissions" }) } @@ -58,7 +67,7 @@ export const actions = { const data = await request.formData(); - const id = toInt((data.get("id") as string|null) ?? "NaN") + const id = data.get("id") == "new" ? -1 : toInt((data.get("id") as string|null) ?? "NaN") const name = data.get("name") as string|null const gender = data.get("gender") as string|null const address = data.get("address") as string|null @@ -73,6 +82,39 @@ export const actions = { return fail(400, { message: "invalid request" }) } + if (id == -1) { + if (!Permissions.has(locals.user.permissions, Permissions.USERADMIN.CREATE)) { + return fail(403, { message: "Keine Berechtigung" }) + } + + if (username.length == 0) { + return fail(400, { message: "Benutzername muss angegeben werden" }) + } + + if (password1 == null || password2 == null || password1.length == 0 || password2.length == 0) { + return fail(400, { message: "Passwort muss für einen neuen Benutzer angegeben werden" }) + } + + if (password1 != password2) { + return fail(400, { message: "Passwörter müssen übereinstimmen" }) + } + + let new_user: number | bigint = -1 + try { + new_user = await create_user({ name, gender, address, username, password: password1 }) + } catch (e) { + if (e instanceof SQLiteError && e.code == "SQLITE_CONSTRAINT_UNIQUE") { + return fail(400, { message: "Benutzername ist bereits vergeben" }) + } + throw e + } + + if (new_user < 0) { + return fail(500, { message: "Interner Fehler" }) + } + return redirect(303, `/user?user=${new_user}`) + } + if (locals.user.id != id && (!Permissions.has(locals.user.permissions, Permissions.USERADMIN.EDIT) || ((password1 != null || password2 != null) && !Permissions.has(locals.user.permissions, Permissions.USERADMIN.ADMIN)))) { @@ -83,6 +125,7 @@ export const actions = { if (password1 != password2) { return fail(400, { message: "Passwörter müssen übereinstimmen" }) } + const result = change_password(id, password1) if (!result) { return fail(500, { message: "Database failure"}) @@ -103,5 +146,86 @@ export const actions = { return { message: "Erfolgreich gespeichert" } + }, + import: async ({ locals, request }) => { + + if (!locals.user) { + return fail(401, { message: "Unauthorized User" }) + } + + const data = await request.formData() + + const id = toInt(data.get("id") as string|null ?? "") + const file = data.get("file") + + if (isNaN(id)) { + return fail(400, { message: "Invalid id" }) + } + if (!file || typeof file === "string") { + return fail(400, { message: "Invalid file" }) + } + + + let import_data + + try { + import_data = await file.json() + } catch (e) { + if (e instanceof SyntaxError) { + console.log(e) + return fail(400, { message: "Uploaded file is erronous" }) + } + throw e + } + + if (!import_data.id || import_data.id != id) { + return fail(400, { message: "Datei für falschen Benutzer hochgeladen" }) + } + + if (locals.user.id != id) { + if (!Permissions.has(locals.user.permissions, Permissions.USERADMIN.ADMIN)) { + Logs.user.warn(`User ${locals.user.id} tried to import data for user ${id} without sufficient permissions`) + return fail(403, { message: "Unzureichend Berechtigung" }) + } + } + + try { + import_user_data(import_data) + } catch (e) { + Logs.user.error(`Failed to import data for user ${id} by user ${locals.user.id}: ${(e as Error).message}`) + return fail(500, { message: "Import fehlgeschlagen" }) + } + + return { message: "Import abgeschlossen"} + }, + delete: async ({ locals, request }) => { + + if (!locals.user) { + return fail(401, { message: "Unauthorized User" }) + } + + const data = await request.formData() + + const user_id = toInt((data.get("user") as string|null) ?? "NaN") + + if (isNaN(user_id)) { + return fail(400, { message: "Bad request" }) + } + + if (!Permissions.has(locals.user.permissions, Permissions.USERADMIN.DELETE)) { + Logs.user.warn(`User ${locals.user.id} tried to delete user ${id} without sufficient permissions`) + return fail(403, { message: "Unzureichend Berechtigung" }) + } + + const result = remove_user(user_id) + if (!result) { + return fail(500, { message: "Fehler beim Löschen" }) + } + + if (Permissions.has(locals.user.permissions, Permissions.USERADMIN.VIEW)) { + return redirect(303, "/useradmin") + } + + return { message: "Erfolgreich gelöscht" } } } satisfies Actions diff --git a/src/routes/user/+page.svelte b/src/routes/user/+page.svelte index 62e5aa2..5cc62a3 100644 --- a/src/routes/user/+page.svelte +++ b/src/routes/user/+page.svelte @@ -7,13 +7,26 @@ import Permissions from "$lib/permissions" const { data, form }: PageProps = $props() + + let file_upload: HTMLButtonElement + $effect(() => { + file_upload.disabled = true + }) + +
+
+
{ + if (!confirm(`Sicher ${data.user?.name} zu löschen?`)) { + event.preventDefault() + }}}>
+
{ return async ({update}) => { update({ reset: false }) } }}> - +
@@ -32,7 +45,7 @@ Name - + Geschlecht @@ -43,7 +56,7 @@ Addresse - + @@ -60,7 +73,7 @@ Benutzername - + Passwort ändern @@ -94,7 +107,7 @@ type="checkbox" name="USERADMIN" value={permission.value} - checked={Permissions.has(data.user.permissions, permission.value)} + checked={Permissions.has(data.user?.permissions ?? 0, permission.value)} disabled={disabled} data-bits={Permissions.deconstruct(permission.value).join(" ")} onclick={(event) => { @@ -131,8 +144,6 @@ } } - - }} /> {permission.name} @@ -145,8 +156,37 @@ {/if} - + + + + + + + + + + + + + + + + + + + + + + + + +
Daten
Exportieren:
Importieren: {file_upload.disabled = ((event.target as HTMLInputElement).files?.length == 0)}} required/>
+ + {#if data.user} + + {/if}
@@ -198,9 +238,28 @@ label:has(input[type="checkbox"][disabled]) { color: gray; } -button { +.save-button { width: 15%; margin-left: 85%; } +table td:has(button) { + text-align: center; +} +table button { + width: 75%; +} + +input[type="file"] { + width: 100%; + padding: 5px; + padding-left: 10px; + border: 1px dotted lightblue; + text-align: center; +} + +.delete_button { + margin-top: 50px; + float: right; +} diff --git a/src/routes/user/export/+server.ts b/src/routes/user/export/+server.ts new file mode 100644 index 0000000..48ee595 --- /dev/null +++ b/src/routes/user/export/+server.ts @@ -0,0 +1,41 @@ +import type { RequestHandler } from "./$types.d" + +import { json, error } from "@sveltejs/kit" + +import Logs from "$lib/server/log" +import { get_user_entry_by_id, export_user_data } from "$lib/server/database" + +import Permissions from "$lib/permissions" +import { toInt } from "$lib/util" + +export const GET: RequestHandler = async ({ locals, url }) => { + if (locals.user == null) { + return error(401, { message: "Unauthorized user" }) + } + + let user_id = locals.user.id + const user_id_param = toInt(url.searchParams.get("user") ?? "") + + if (!isNaN(user_id_param) && user_id != user_id_param) { + if (!Permissions.has(locals.user.permissions, Permissions.USERADMIN.ADMIN)) { + Logs.user.warn(`User ${locals.user.id} tried to export records for user ${url.searchParams.get("user")} without sufficient permissions`) + return error(403, { message: "Insufficient permissions" }) + } + + const new_user = get_user_entry_by_id(user_id_param) + if (new_user == null) { + return error(400, { message: "invalid user" }) + } + + user_id = new_user.id + } + + Logs.user.info(`User ${locals.user.id} exported userdata for user ${user_id}`) + + const data = await export_user_data(user_id) + return new Response(data, { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename=Export-${locals.user.username}-${(new Date()).toJSON()}.json` + }}) +} diff --git a/src/routes/useradmin/+page.svelte b/src/routes/useradmin/+page.svelte index 374fd9a..62a3dd5 100644 --- a/src/routes/useradmin/+page.svelte +++ b/src/routes/useradmin/+page.svelte @@ -3,13 +3,14 @@ import type { PageProps } from "./$types" import { enhance } from "$app/forms"; - const { data }: PageProps = $props(); + import Permissions from "$lib/permissions" - console.log(data) + const { data }: PageProps = $props();
+

Benutzerverwaltung

@@ -20,6 +21,9 @@ {/each} + {#if Permissions.has(data.loggedInAs.permissions, Permissions.USERADMIN.CREATE)} + + {/if}