restructure: flatten workspace nesting, move devcontainer to root

- backend/workspaces/backend/ → backend/
- backend/workspaces/frontend/ → frontend/
- backend/.devcontainer/ + .vscode/ → repo root (where VS Code expects them)
- loose scripts/SQL files → scripts/
- replace nested git repo with single repo at project root
- update docker-compose.yml build context and devcontainer.json path
- add root .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-15 11:47:58 +01:00
parent 7e725090fe
commit e63adb964d
155 changed files with 650 additions and 29 deletions

View File

@@ -0,0 +1,141 @@
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export async function load({ fetch, locals }) {
// 1. Check Permissions (Adapt logic to your user object)
const user = locals.user;
// Assuming user.group.permissions is an array of strings
const hasAdmin = user?.groups.some(g => g.permissions.includes("ADMIN"));
if (!hasAdmin) throw error(403, 'Zugriff verweigert');
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
// 2. Load Data
const [usersRes, groupsRes, tagsRes] = await Promise.all([
fetch(baseUrl + '/api/users'),
fetch(baseUrl + '/api/groups'),
fetch(baseUrl + '/api/tags')
]);
return {
users: await usersRes.json(),
groups: await groupsRes.json(),
tags: await tagsRes.json()
};
}
export const actions = {
createUser: async ({ request, fetch }) => {
const data = await request.formData();
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
// Extract array of group IDs
// "groupIds" matches the name attribute in the <select>
const groupIds = data.getAll('groupIds');
const payload = {
username: data.get('username'),
initialPassword: data.get('password'),
groupIds: groupIds // Send array to backend
};
console.log("Payload", JSON.stringify(payload))
const res = await fetch(baseUrl + '/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) return { success: false, message: 'Fehler beim Erstellen' };
return { success: true, message: 'User angelegt' };
},
deleteUser: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id');
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(baseUrl + `/api/users/${id}`, {
method: 'DELETE'
});
if (!res.ok) {
return { success: false, message: 'Fehler beim Löschen des Benutzers' };
}
return { success: true, message: 'Benutzer erfolgreich gelöscht' };
},
updateTag: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id');
const name = data.get('name');
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
await fetch(baseUrl + `/api/tags/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
return { success: true };
},
deleteTag: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id');
await fetch(`/api/tags/${id}`, { method: 'DELETE' });
return { success: true };
},
createGroup: async ({ request, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const data = await request.formData();
const payload = {
name: data.get('name'),
permissions: data.getAll('permissions') // Gets all checked checkboxes
};
const res = await fetch(baseUrl + '/api/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) return { success: false, message: 'Fehler beim Erstellen der Gruppe' };
return { success: true };
},
updateGroup: async ({ request, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const data = await request.formData();
const id = data.get('id');
const payload = {
name: data.get('name'),
permissions: data.getAll('permissions')
};
const res = await fetch(baseUrl + `/api/groups/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) return { success: false, message: 'Fehler beim Aktualisieren' };
return { success: true };
},
deleteGroup: async ({ request, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const data = await request.formData();
const id = data.get('id');
const res = await fetch(baseUrl + `/api/groups/${id}`, { method: 'DELETE' });
if (!res.ok) return { success: false, message: 'Gruppe kann nicht gelöscht werden (evtl. noch Benutzer zugeordnet?)' };
return { success: true };
}
};

View File

@@ -0,0 +1,613 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { slide } from 'svelte/transition';
export let data;
export let form;
console.log(data)
let activeTab = 'users';
let editingTagId: string | null = null;
let editingTagName = '';
let editingUserId: string | null = null;
function startEditTag(tag: any) {
editingTagId = tag.id;
editingTagName = tag.name;
}
function cancelEditTag() {
editingTagId = null;
editingTagName = '';
}
function startEditUser(id: string) {
editingUserId = id;
}
function cancelEditUser() {
editingUserId = null;
}
let editingGroupId: string | null = null;
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
function startEditGroup(id: string) {
editingGroupId = id;
}
function cancelEditGroup() {
editingGroupId = null;
}
</script>
<div class="max-w-7xl mx-auto py-8 sm:px-6 lg:px-8 font-sans">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-serif text-brand-navy">Admin Dashboard</h1>
<!-- Tabs -->
<div class="flex bg-white rounded-lg p-1 shadow-sm border border-gray-200">
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'users'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'users')}>Benutzer</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'groups'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'groups')}>Gruppen</button
>
<button
class="px-4 py-2 text-sm font-bold uppercase tracking-wide rounded-md transition {activeTab ===
'tags'
? 'bg-brand-navy text-white'
: 'text-gray-500 hover:text-brand-navy'}"
on:click={() => (activeTab = 'tags')}>Schlagworte</button
>
</div>
</div>
{#if form?.message}
<div class="bg-brand-mint/20 text-brand-navy p-4 rounded mb-6 border border-brand-mint/50">
{form.message}
</div>
{/if}
{#if activeTab === 'users'}
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 class="text-lg font-bold text-gray-700">Benutzerverwaltung</h2>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Login</th
>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Gruppen</th
>
{#if editingUserId}
<th
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
>Passwort</th
>
{/if}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.users as user}
<tr class="group/row hover:bg-gray-50">
{#if editingUserId === user.id}
<!-- === EDIT MODE === -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.username}
<!-- Hidden ID Input for the form -->
<input
type="hidden"
name="username"
value={user.username}
form="edit-form-{user.id}"
/>
</td>
<td class="px-6 py-4 text-sm">
<!-- Groups Select -->
<select
name="groupIds"
multiple
form="edit-form-{user.id}"
class="block w-full rounded border-brand-mint text-xs p-1 min-h-[80px]"
>
{#each data.groups as group}
<option
value={group.id}
selected={user.groups.some((g) => g.id === group.id)}
>
{group.name}
</option>
{/each}
</select>
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Auswahl</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right align-top">
<!-- Password & Buttons -->
<form
id="edit-form-{user.id}"
method="POST"
action="?/createUser"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditUser();
}}
class="flex flex-col items-end gap-2"
>
<input
type="password"
name="password"
placeholder="Neues PW (optional)"
class="w-32 py-1 px-2 text-xs border border-brand-mint rounded"
/>
<div class="flex gap-2 mt-1">
<button
type="submit"
class="bg-green-600 text-white px-2 py-1 rounded text-xs font-bold uppercase hover:bg-green-700"
>
Speichern
</button>
<button
type="button"
on:click={cancelEditUser}
class="bg-gray-200 text-gray-600 px-2 py-1 rounded text-xs font-bold uppercase hover:bg-gray-300"
>
Abbrechen
</button>
</div>
</form>
</td>
{:else}
<!-- === VIEW MODE === -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.username}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#if user.groups && user.groups.length > 0}
{#each user.groups as group}
<span
class="px-2 py-0.5 text-[10px] font-bold uppercase rounded-full bg-blue-50 text-blue-700 border border-blue-100"
>
{group.name}
</span>
{/each}
{:else}
<span class="text-xs text-gray-400 italic">Keine Gruppen</span>
{/if}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-4">
<!-- Edit Button -->
<button
on:click={() => startEditUser(user.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
>
Bearbeiten
</button>
<!-- Delete Button -->
<form
method="POST"
action="?/deleteUser"
use:enhance={({ cancel }) => {
if (!confirm(`Benutzer '${user.username}' wirklich löschen?`)) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="flex items-center"
>
<input type="hidden" name="id" value={user.id} />
<button
class="text-gray-300 hover:text-red-600 transition-colors p-1"
title="Benutzer löschen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<!-- Create User Form -->
<div class="p-6 bg-gray-50 border-t border-gray-200">
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
Neuen Benutzer anlegen
</h3>
<form
method="POST"
action="?/createUser"
use:enhance
class="grid grid-cols-1 md:grid-cols-6 gap-4 items-start"
>
<input
type="text"
name="username"
placeholder="Login"
required
class="rounded border-gray-300 text-sm w-full"
/>
<input
type="password"
name="password"
placeholder="Passwort"
required
class="rounded border-gray-300 text-sm w-full"
/>
<!-- Multi-Select for Groups -->
<div class="md:col-span-3">
<select
name="groupIds"
multiple
class="rounded border-gray-300 text-sm w-full h-[42px] py-1"
required
title="Strg+Klick für mehrere"
>
{#each data.groups as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
<p class="text-[10px] text-gray-400 mt-1">Strg+Klick für Mehrfachauswahl</p>
</div>
<button
type="submit"
class="bg-brand-navy text-white h-[42px] rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full"
>Anlegen</button
>
</form>
</div>
</div>
{:else if activeTab === 'tags'}
<!-- TAGS SECTION (unchanged logic, just ensuring style consistency) -->
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 bg-yellow-50/50">
<h2 class="text-lg font-bold text-gray-700">Schlagworte</h2>
<p class="text-xs text-yellow-800 mt-1">
Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.
</p>
</div>
<ul class="divide-y divide-gray-100 max-h-[600px] overflow-y-auto">
{#each data.tags as tag}
<li class="px-6 py-3 flex items-center justify-between hover:bg-gray-50 group">
{#if editingTagId === tag.id}
<form
method="POST"
action="?/updateTag"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditTag();
}}
class="flex-1 flex gap-2 items-center"
>
<input type="hidden" name="id" value={tag.id} />
<input
type="text"
name="name"
bind:value={editingTagName}
class="flex-1 border-brand-mint ring-1 ring-brand-mint rounded px-2 py-1 text-sm"
autofocus
/>
<button class="text-green-600 hover:text-green-800"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
></button
>
<button
type="button"
on:click={cancelEditTag}
class="text-gray-400 hover:text-gray-600"
><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
></button
>
</form>
{:else}
<span class="text-sm font-medium text-brand-navy bg-brand-sand/30 px-2 py-1 rounded">
{tag.name}
</span>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
on:click={() => startEditTag(tag)}
class="p-1 text-gray-400 hover:text-brand-navy"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/></svg
>
</button>
<form
method="POST"
action="?/deleteTag"
use:enhance={({ cancel }) => {
// This runs BEFORE the request is sent
if (
!confirm(
'Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.'
)
) {
cancel(); // Stop the request
}
// This runs AFTER the server responds
return async ({ update }) => {
await update();
};
}}
class="inline"
>
<input type="hidden" name="id" value={tag.id} />
<button class="p-1 text-gray-400 hover:text-red-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/></svg
>
</button>
</form>
</div>
{/if}
</li>
{/each}
</ul>
</div>
{:else if activeTab === 'groups'}
<div class="bg-white shadow-sm border border-brand-sand rounded-lg overflow-hidden" in:slide>
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 class="text-lg font-bold text-gray-700">Gruppenverwaltung</h2>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Name</th
>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider"
>Berechtigungen</th
>
<th
class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider"
>Aktionen</th
>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.groups as group}
<tr class="group/row hover:bg-gray-50">
{#if editingGroupId === group.id}
<!-- EDIT MODE -->
<td colspan="3" class="px-6 py-4">
<form
method="POST"
action="?/updateGroup"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditGroup();
}}
class="flex flex-col sm:flex-row items-start gap-4 w-full"
>
<input type="hidden" name="id" value={group.id} />
<!-- Name Input -->
<div class="w-full sm:w-1/3">
<input
type="text"
name="name"
value={group.name}
class="w-full text-sm border-brand-mint rounded"
required
/>
</div>
<!-- Permissions Checkboxes -->
<div class="flex-1 flex flex-wrap gap-4 items-center h-full pt-2">
{#each availablePermissions as perm}
<label
class="inline-flex items-center text-xs font-bold text-gray-600 uppercase"
>
<input
type="checkbox"
name="permissions"
value={perm}
checked={group.permissions.includes(perm)}
class="mr-2 text-brand-navy focus:ring-brand-mint rounded border-gray-300"
/>
{perm.replace('_', ' ')}
</label>
{/each}
</div>
<!-- Actions -->
<div class="flex gap-2 self-start sm:self-center">
<button type="submit" class="text-green-600 hover:text-green-800 p-1">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
>
</button>
<button
type="button"
on:click={cancelEditGroup}
class="text-gray-400 hover:text-red-500 p-1"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
</div>
</form>
</td>
{:else}
<!-- VIEW MODE -->
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-brand-navy">
{group.name}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#each group.permissions as perm}
<span
class="px-2 py-0.5 text-[10px] font-bold uppercase rounded-full
{perm === 'ADMIN'
? 'bg-red-50 text-red-700 border-red-100'
: 'bg-gray-100 text-gray-600 border-gray-200'}"
>
{perm}
</span>
{/each}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-3">
<button
on:click={() => startEditGroup(group.id)}
class="text-brand-mint hover:text-brand-navy text-sm font-bold uppercase tracking-wide"
>
Bearbeiten
</button>
<form
method="POST"
action="?/deleteGroup"
use:enhance={({ cancel }) => {
if (!confirm('Gruppe wirklich löschen?')) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
>
<input type="hidden" name="id" value={group.id} />
<button
class="text-gray-300 hover:text-red-600 p-1 transition-colors"
title="Löschen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<!-- CREATE GROUP FORM -->
<div class="p-6 bg-gray-50 border-t border-gray-200">
<h3 class="text-xs font-bold uppercase text-gray-500 mb-4 tracking-wide">
Neue Gruppe anlegen
</h3>
<form
method="POST"
action="?/createGroup"
use:enhance
class="flex flex-col md:flex-row gap-4 items-start md:items-center"
>
<div class="flex-1 w-full">
<input
type="text"
name="name"
placeholder="Gruppenname (z.B. Editoren)"
required
class="rounded border-gray-300 text-sm w-full"
/>
</div>
<div class="flex gap-4 items-center">
{#each availablePermissions as perm}
<label class="inline-flex items-center text-xs font-bold text-gray-600 uppercase">
<input
type="checkbox"
name="permissions"
value={perm}
class="mr-2 text-brand-navy focus:ring-brand-mint rounded border-gray-300"
/>
{perm.replace('_', ' ')}
</label>
{/each}
</div>
<button
type="submit"
class="bg-brand-navy text-white px-6 py-2 rounded text-sm font-bold uppercase hover:bg-brand-mint hover:text-brand-navy w-full md:w-auto"
>
Anlegen
</button>
</form>
</div>
</div>
{/if}
</div>