feat: User-Management mit Token-basierter Authentifizierung

Backend:
- UserRecord Model + Users-Tabelle in DB
- AuthService: SHA256-Passwort-Hashing, Token-Verwaltung (ConcurrentDictionary)
- AuthMiddleware: Schützt alle /api/-Routes (außer /api/auth/login)
- AuthEndpoint: Login, Logout, Me
- Default-User admin/admin wird automatisch erstellt

Frontend:
- Login-Page mit Formular (Enter-Key-Support)
- Globaler Fetch-Interceptor: 401 → automatisch Login-Redirect
- Auth-Check beim App-Start und bei Hash-Änderungen
- Logout-Link im Dashboard-Header
- Login-CSS: Zentrierte Login-Box
This commit is contained in:
Meleki committed 2026-03-02 11:29:09 +01:00
1 parent a1c54bf376
commit 9a59ae686d
13 files changed
+446 -3

No files matched your search

+11
View File
@@ -2,6 +2,13 @@
padding: var(--page-padding);
}
.dashboard-links {
display: flex;
gap: 1.25rem;
align-items: center;
flex: 1;
}
.dashboard-link {
color: var(--text);
text-decoration: none;
@@ -15,6 +22,10 @@
color: var(--text-accent);
}
.dashboard-link:last-child {
margin-left: auto;
}
.dashboard section {
margin-bottom: var(--page-spacing);
width: 100%;
+36
View File
@@ -0,0 +1,36 @@
/* ── Login Page ── */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
padding: 1.5rem;
}
.login-box {
width: 100%;
max-width: 340px;
}
.login-title {
text-align: center;
font-size: calc(var(--font-size) * 2);
margin-bottom: var(--box-spacing);
color: var(--fg);
}
.login-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login-error {
background: rgb(from var(--dangerous) r g b / 0.15);
padding: var(--box-padding);
color: var(--dangerous);
border-radius: var(--border-radius);
text-align: center;
}
+20 -1
View File
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/login.css">
<link rel="stylesheet" href="css/form.css">
<link rel="stylesheet" href="css/dashboard.css">
<link rel="stylesheet" href="css/chat.css">
@@ -28,12 +29,29 @@
<!-- Templates -->
<template id="tpl-login">
<div class="page login-page">
<div class="login-box">
<h1 class="login-title">// gott</h1>
<div class="login-form">
<input type="text" id="loginUsername" placeholder="Benutzername" autocomplete="username" autofocus>
<input type="password" id="loginPassword" placeholder="Passwort" autocomplete="current-password">
<div class="login-error" id="loginError" hidden></div>
<button id="loginBtn">Anmelden</button>
</div>
</div>
</div>
</template>
<template id="tpl-dashboard">
<div class="page dashboard">
<header>
<button class="back-btn icon-btn" disabled></button>
<h1 class="dashboard-title">// gott</h1>
<a id="dashboardSettingsLink" class="dashboard-link">Einstellungen</a>
<div class="dashboard-links">
<a id="dashboardSettingsLink" class="dashboard-link">Einstellungen</a>
<a id="dashboardLogoutLink" class="dashboard-link">Logout</a>
</div>
</header>
<div class="page-content">
<section>
@@ -287,6 +305,7 @@
<script src="js/pages/agent-page.js"></script>
<script src="js/pages/session-page.js"></script>
<script src="js/pages/chat-page.js"></script>
<script src="js/pages/login-page.js"></script>
<script src="js/pages/settings-page.js"></script>
<script src="js/app.js"></script>
</body>
+41
View File
@@ -1,3 +1,44 @@
// ── Global 401 Handler ──
(function() {
var _origFetch = window.fetch;
window.fetch = function(url, opts) {
return _origFetch.apply(this, arguments).then(function(resp) {
if (resp.status === 401 && typeof url === 'string' && !url.includes('/api/auth/')) {
if (typeof currentPage !== 'undefined' && currentPage !== 'login') {
navigateTo('login');
}
}
return resp;
});
};
})();
// ── Auth API ──
async function apiLogin(username, password) {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'Login failed');
}
return resp.json();
}
async function apiLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
}
async function apiMe() {
const resp = await fetch('/api/auth/me');
if (!resp.ok) return null;
return resp.json();
}
// ── API Calls ──
async function apiChat(messages, sessionId, signal) {
+17 -1
View File
@@ -1,12 +1,28 @@
// ── App Init ──
document.addEventListener('DOMContentLoaded', async () => {
// Auth-Check: Eingeloggt?
var user = await apiMe();
if (!user) {
await navigateTo('login');
return;
}
const { page, id } = parseHash();
await navigateTo(page, { id });
var target = page === 'login' ? 'dashboard' : page;
await navigateTo(target, { id });
});
// Hash-Änderungen abfangen
window.addEventListener('hashchange', async () => {
const { page, id } = parseHash();
// Login-Seite immer erlauben, alles andere nur wenn eingeloggt
if (page !== 'login') {
var user = await apiMe();
if (!user) {
await navigateTo('login');
return;
}
}
await navigateTo(page, { id });
});
+7
View File
@@ -8,6 +8,13 @@ registerPage('dashboard', {
navigateTo('settings');
});
// Logout-Link
document.getElementById('dashboardLogoutLink').addEventListener('click', async (e) => {
e.preventDefault();
await apiLogout();
navigateTo('login');
});
// Globaler Click-Handler zum Schließen aller Card-Menüs
document.addEventListener('click', this._closeCardMenus = () => closeAllCardMenus());
+49
View File
@@ -0,0 +1,49 @@
// ── Login Page ──
registerPage('login', {
init() {
const usernameInput = $('#loginUsername');
const passwordInput = $('#loginPassword');
const loginBtn = $('#loginBtn');
const errorDiv = $('#loginError');
async function doLogin() {
errorDiv.hidden = true;
loginBtn.disabled = true;
loginBtn.textContent = 'Anmelden...';
try {
var username = usernameInput.value.trim();
var password = passwordInput.value;
if (!username || !password) {
errorDiv.textContent = 'Benutzername und Passwort eingeben';
errorDiv.hidden = false;
return;
}
await apiLogin(username, password);
navigateTo('dashboard');
} catch (err) {
errorDiv.textContent = err.message || 'Login fehlgeschlagen';
errorDiv.hidden = false;
passwordInput.value = '';
passwordInput.focus();
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Anmelden';
}
}
loginBtn.addEventListener('click', doLogin);
usernameInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') passwordInput.focus();
});
passwordInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') doLogin();
});
},
destroy() {}
});