Initial
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="EK-DOS WEB – Büroanwendung der Elektro Krüger GmbH." />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" href="/ek-dos-favicon.svg" />
|
||||
<link rel="shortcut icon" href="/ek-dos-favicon.svg" />
|
||||
<title>EK-DOS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "ek-dos-web-frontend",
|
||||
"displayName": "EK-DOS WEB",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.16.1",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"eslint": "9.39.4",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.0.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="13" fill="#12344D"/>
|
||||
<path d="M11 13h25l-3.6 8H20.7l-1.4 5h11.3l-3.4 8H17l-1.6 6h12.8l-3.6 9H8z" fill="#fff"/>
|
||||
<path d="M39 13h13L42 31l12 20H42l-8.4-14-5.5 14H16l12.1-30H39z" fill="#93EC67"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="13" fill="#12344D"/>
|
||||
<path d="M11 13h25l-3.6 8H20.7l-1.4 5h11.3l-3.4 8H17l-1.6 6h12.8l-3.6 9H8z" fill="#fff"/>
|
||||
<path d="M39 13h13L42 31l12 20H42l-8.4-14-5.5 14H16l12.1-30H39z" fill="#93EC67"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 323 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1,28 @@
|
||||
import { AuthProvider, useAuth } from '@/auth/AuthProvider';
|
||||
import { LoadingScreen, LoginScreen } from '@/auth/LoginScreen';
|
||||
import Dashboard from '@/views/Dashboard';
|
||||
|
||||
/**
|
||||
* Der Auth-Gate.
|
||||
*
|
||||
* Drei Zustände, mehr gibt es nicht: es ist noch nicht entschieden, niemand ist
|
||||
* angemeldet, oder jemand ist angemeldet. Erst im dritten Fall wird überhaupt
|
||||
* eine Fachansicht gerendert -- so kann keine Ansicht in einen Zustand ohne
|
||||
* Benutzer geraten.
|
||||
*/
|
||||
function Gate() {
|
||||
const { user, ready } = useAuth();
|
||||
|
||||
if (!ready) return <LoadingScreen />;
|
||||
if (!user) return <LoginScreen />;
|
||||
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Gate />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { config } from '@/config';
|
||||
|
||||
/**
|
||||
* Der einzige Weg nach draussen.
|
||||
*
|
||||
* `credentials: 'include'` sorgt dafuer, dass das Sitzungscookie mitgeht. Es gibt
|
||||
* bewusst keinen Authorization-Header und kein Token im Browser: die Sitzung
|
||||
* liegt vollstaendig im Backend, der Browser haelt nur die undurchsichtige Kennung.
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
|
||||
/** Die Sitzung ist abgelaufen oder wurde beendet. */
|
||||
get isUnauthorized(): boolean {
|
||||
return this.status === 401;
|
||||
}
|
||||
|
||||
/** Angemeldet, aber ohne das noetige Recht. */
|
||||
get isForbidden(): boolean {
|
||||
return this.status === 403;
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/** Wird ausgeloest, sobald das Backend 401 meldet, damit die Anmeldemaske erscheint. */
|
||||
type UnauthorizedListener = () => void;
|
||||
const unauthorizedListeners = new Set<UnauthorizedListener>();
|
||||
|
||||
export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
||||
unauthorizedListeners.add(listener);
|
||||
return () => unauthorizedListeners.delete(listener);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, signal } = options;
|
||||
|
||||
const response = await fetch(`${config.api.baseUrl}${path}`, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
headers: body === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).catch(() => {
|
||||
throw new ApiError('Das Backend ist nicht erreichbar.', 0);
|
||||
});
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
// 401 auf der Auth-Sonde ist eine Antwort, kein Fehler: dort steht,
|
||||
// dass niemand angemeldet ist. Ueberall sonst heisst es, dass die
|
||||
// Sitzung gerade abgelaufen ist.
|
||||
if (response.status === 401 && path !== '/api/auth/me') {
|
||||
unauthorizedListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
const message = typeof payload?.error === 'string' ? payload.error : `Das Backend antwortet mit HTTP ${response.status}.`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T,>(path: string, signal?: AbortSignal) => request<T>(path, { signal }),
|
||||
post: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
|
||||
put: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body }),
|
||||
patch: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
|
||||
delete: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
|
||||
};
|
||||
|
||||
/** Adresse fuer eine PDF-Anzeige im Browser. Die Sitzung traegt ueber das Cookie. */
|
||||
export function fileUrl(path: string): string {
|
||||
return `${config.api.baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Übergangsbrücke für Dashboard.tsx.
|
||||
*
|
||||
* Die große Dashboard-Komponente ruft noch direkt `fetch("/api/…")` auf, geht
|
||||
* also nicht durch `api` oben. Ohne diesen Aufsatz bliebe eine abgelaufene
|
||||
* Sitzung dort unbemerkt und die Ansichten zeigten Fehlermeldungen, statt zur
|
||||
* Anmeldung zurückzufallen.
|
||||
*
|
||||
* Diese Funktion darf ersatzlos entfallen, sobald alle Ansichten den Client
|
||||
* oben verwenden.
|
||||
*/
|
||||
let intercepted = false;
|
||||
|
||||
export function watchLegacyFetchForExpiry(): void {
|
||||
if (intercepted) return;
|
||||
intercepted = true;
|
||||
|
||||
const original = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async (input, init) => {
|
||||
const response = await original(input, init);
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
|
||||
// Nur eigene API-Aufrufe, und die Auth-Sonde bleibt außen vor: dort ist
|
||||
// 401 die reguläre Antwort für "niemand angemeldet".
|
||||
if (response.status === 401 && url.includes('/api/') && !url.includes('/api/auth/me')) {
|
||||
unauthorizedListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { api, onUnauthorized, watchLegacyFetchForExpiry } from '@/api/client';
|
||||
import { config } from '@/config';
|
||||
import type { CurrentUser, PermissionName } from '@/auth/types';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
type AuthState = {
|
||||
user: CurrentUser | null;
|
||||
/** Solange false, ist noch nicht entschieden, ob jemand angemeldet ist. */
|
||||
ready: boolean;
|
||||
signIn: (username: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
can: (permission: PermissionName) => boolean;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
/**
|
||||
* Der Auth-Gate der Oberflaeche.
|
||||
*
|
||||
* Es wird nichts geraten: beim Start fragt die Anwendung /api/auth/me. 200
|
||||
* bedeutet angemeldet, 401 bedeutet Anmeldemaske. Danach halten zwei Uhren die
|
||||
* Sitzung im Griff -- eine verlaengert sie, solange jemand arbeitet, die andere
|
||||
* meldet nach einer Stunde ohne Eingabe ab.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const lastActivity = useRef(Date.now());
|
||||
|
||||
const probe = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<{ user: CurrentUser | null }>('/api/auth/me');
|
||||
setUser(data.user);
|
||||
} catch {
|
||||
// 401 und "Backend nicht erreichbar" fuehren beide zur Anmeldemaske.
|
||||
setUser(null);
|
||||
} finally {
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void probe();
|
||||
}, [probe]);
|
||||
|
||||
// Meldet das Backend irgendwo 401, faellt die Oberflaeche sofort zurueck.
|
||||
// Der Aufsatz erfasst dabei auch die direkten fetch-Aufrufe in Dashboard.tsx.
|
||||
useEffect(() => {
|
||||
watchLegacyFetchForExpiry();
|
||||
|
||||
return onUnauthorized(() => setUser(null));
|
||||
}, []);
|
||||
|
||||
// Jede Eingabe zaehlt als Aktivitaet.
|
||||
useEffect(() => {
|
||||
const note = () => {
|
||||
lastActivity.current = Date.now();
|
||||
};
|
||||
|
||||
const events = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
|
||||
events.forEach((event) => window.addEventListener(event, note, { passive: true }));
|
||||
|
||||
return () => events.forEach((event) => window.removeEventListener(event, note));
|
||||
}, []);
|
||||
|
||||
// Sitzung verlaengern, solange gearbeitet wird; sonst abmelden.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (Date.now() - lastActivity.current > config.session.idleMs) {
|
||||
void api.post('/api/auth/logout').catch(() => undefined);
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void api
|
||||
.post<{ user: CurrentUser | null }>('/api/auth/refresh')
|
||||
.then((data) => setUser(data.user))
|
||||
.catch(() => setUser(null));
|
||||
}, config.session.refreshMs);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [user]);
|
||||
|
||||
const signIn = useCallback(async (username: string, password: string) => {
|
||||
const data = await api.post<{ user: CurrentUser }>('/api/auth/login', { username, password });
|
||||
lastActivity.current = Date.now();
|
||||
setUser(data.user);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await api.post('/api/auth/logout').catch(() => undefined);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
() => ({
|
||||
user,
|
||||
ready,
|
||||
signIn,
|
||||
signOut,
|
||||
can: (permission) => user?.permissions.includes(permission) ?? false,
|
||||
}),
|
||||
[user, ready, signIn, signOut],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (context === null) throw new Error('useAuth benötigt einen AuthProvider.');
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useAuth } from '@/auth/AuthProvider';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Anmeldemaske.
|
||||
*
|
||||
* Die beiden fest verdrahteten Schaltflaechen "Svenja" und "Sascha" sind einem
|
||||
* Benutzernamensfeld gewichen -- Konten kommen jetzt aus der eigenen Datenbank
|
||||
* und werden in der Benutzerverwaltung gepflegt. Das uebrige Gestaltungsbild
|
||||
* bleibt unveraendert.
|
||||
*/
|
||||
export function LoginScreen() {
|
||||
const { signIn } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await signIn(username.trim(), password);
|
||||
} catch (problem) {
|
||||
setError(problem instanceof Error ? problem.message : 'Die Anmeldung ist fehlgeschlagen.');
|
||||
setPassword('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<div className="login-panel">
|
||||
<div className="login-brand" aria-label="Elektro Krüger – Wir setzen Sie unter Strom">
|
||||
<strong>EK</strong>
|
||||
<span>
|
||||
<b>Elektro Krüger</b>
|
||||
<small>Wir setzen Sie unter Strom</small>
|
||||
</span>
|
||||
</div>
|
||||
<p className="login-kicker">EK-DOS</p>
|
||||
<h1>Willkommen</h1>
|
||||
<p className="login-copy">Bitte melde dich an, um die digitale Schaltzentrale zu öffnen.</p>
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
Benutzername
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? 'Anmeldung läuft …' : 'Anmelden →'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="login-visual">
|
||||
<img src="/hero-ek-dos.png" alt="" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<main className="login-page">
|
||||
<div className="login-loading">EK-DOS wird geladen …</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type Role = 'admin' | 'inhaber' | 'buero';
|
||||
|
||||
/** Genau die Rechte, die das Backend in Permission.php fuehrt. */
|
||||
export const Permission = {
|
||||
UsersManage: 'users.manage',
|
||||
OffersDelete: 'offers.delete',
|
||||
CustomersDelete: 'customers.delete',
|
||||
TasksDelete: 'tasks.delete',
|
||||
PurchasesCreate: 'purchases.create',
|
||||
PurchasesDelete: 'purchases.delete',
|
||||
PurchasesComplete: 'purchases.complete',
|
||||
InvoicesProcess: 'invoices.process',
|
||||
} as const;
|
||||
|
||||
export type PermissionName = (typeof Permission)[keyof typeof Permission];
|
||||
|
||||
export type CurrentUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
/** Anzeigename, z. B. "Svenja". Die Oberflaeche zeigt ausschliesslich diesen. */
|
||||
name: string;
|
||||
role: Role;
|
||||
roleLabel: string;
|
||||
permissions: PermissionName[];
|
||||
canManageUsers: boolean;
|
||||
};
|
||||
|
||||
export type ManagedUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
roleLabel: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
permissions: PermissionName[];
|
||||
};
|
||||
|
||||
export type RoleOption = {
|
||||
value: Role;
|
||||
label: string;
|
||||
permissions: string[];
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Baukonfiguration.
|
||||
*
|
||||
* Im Betrieb liefert dasselbe nginx die Oberflaeche und /api aus, deshalb ist
|
||||
* die Basis leer und alle Aufrufe sind gleichursprünglich (same-origin). Damit
|
||||
* traegt das Sitzungscookie ohne CORS, und es gibt keine Herkunftsliste zu pflegen.
|
||||
*
|
||||
* Sollte die Oberflaeche einmal auf einem eigenen Host liegen, wird hier die
|
||||
* absolute Backend-Adresse eingetragen; dann braucht das Backend zusaetzlich
|
||||
* eine CORS-Freigabe mit Anmeldedaten und beide Hosts muessen dieselbe Site sein.
|
||||
*/
|
||||
type AppConfig = {
|
||||
readonly api: { readonly baseUrl: string };
|
||||
readonly session: {
|
||||
/** Sitzung im Hintergrund am Leben halten. */
|
||||
readonly refreshMs: number;
|
||||
/** Nach dieser Zeit ohne Eingabe wird abgemeldet. */
|
||||
readonly idleMs: number;
|
||||
};
|
||||
readonly polling: {
|
||||
readonly ticketsMs: number;
|
||||
};
|
||||
};
|
||||
|
||||
const shared = {
|
||||
session: { refreshMs: 5 * 60 * 1000, idleMs: 60 * 60 * 1000 },
|
||||
polling: { ticketsMs: 20_000 },
|
||||
} as const;
|
||||
|
||||
const development: AppConfig = { api: { baseUrl: '' }, ...shared };
|
||||
const production: AppConfig = { api: { baseUrl: '' }, ...shared };
|
||||
|
||||
export const config: AppConfig = import.meta.env.PROD ? production : development;
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Datums- und Zeitformate, wie sie im Büro gelesen werden. */
|
||||
|
||||
export function formatDateTime(value?: string): string {
|
||||
if (!value) return '–';
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
return Number.isNaN(date.getTime())
|
||||
? value
|
||||
: new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(date);
|
||||
}
|
||||
|
||||
export function formatDate(value?: string): string {
|
||||
if (!value) return '–';
|
||||
|
||||
// Mittag statt Mitternacht: so kippt ein reines Datum nicht über die Zeitzone.
|
||||
const date = new Date(`${value.slice(0, 10)}T12:00:00`);
|
||||
|
||||
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(date);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Bei jedem EK-DOS-WEB-Update hier die sichtbare Versionsnummer erhöhen.
|
||||
export const EKDOS_VERSION = "V3.0";
|
||||
@@ -0,0 +1,17 @@
|
||||
import { App } from '@/App';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
import '@/styles/dashboard-fixes.css';
|
||||
import '@/styles/users.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
|
||||
if (container === null) throw new Error('Das Wurzelelement #root fehlt in index.html.');
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Dashboard-Korrekturen nach Designabnahme */
|
||||
.app-nav button:nth-of-type(even)>span:first-child{background:#173a57;color:#62b7ff}
|
||||
.helga-card{grid-column:3;grid-row:1}
|
||||
.dashboard-inbox{grid-column:2}
|
||||
.helga-photo{display:block;overflow:hidden;background:#102e49}
|
||||
.helga-photo img{display:block;width:100%;height:100%;object-fit:cover;object-position:center 24%}
|
||||
.helga-card{grid-template-columns:309px minmax(0,1fr);min-height:369px}.helga-photo{width:309px;height:369px}
|
||||
.app-nav button .nav-chevron{margin-left:auto}
|
||||
.app-nav button .nav-chevron+.nav-badge{margin-left:8px}
|
||||
.dashboard-offers{display:grid;grid-column:3;grid-row:2;align-content:start}.dashboard-offers>button{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;border:0;border-top:1px solid #233748;padding:15px 0;background:transparent;color:inherit;text-align:left;cursor:pointer}.dashboard-offers b{display:block;font-size:13px}.dashboard-offers small{display:block;margin-top:3px;color:#849aaa;font-size:11px}.dashboard-offers em{color:#ffa85b;font-size:10px;font-style:normal;font-weight:800}.dashboard-offers>button:hover b{color:#65bbff}
|
||||
.customer-row-actions{display:flex;justify-content:flex-end;gap:8px;white-space:nowrap}
|
||||
.customers-table .customer-row-actions button{border:1px solid #4a7690;border-radius:7px;padding:7px 9px;background:#183645;color:#d8edf5;font-size:10px;font-weight:900;cursor:pointer}
|
||||
.customers-table .customer-row-actions button:first-child{border-color:#79c96c;background:#19412d;color:#d9ffc7}
|
||||
.customers-table .customer-row-actions button:hover{background:#245168;color:#fff}.customers-table .customer-row-actions button:first-child:hover{background:#256b36}
|
||||
.invoice-modal{width:min(760px,100%)}.invoice-modal-hint{margin:-10px 0 0;color:#a9c1ce;font-size:13px}.invoice-property-list{display:grid;gap:10px}.invoice-property-card{overflow:hidden;border:1px solid #3c6172;border-radius:10px;background:#102633}.invoice-property-card summary{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;padding:15px 16px;cursor:pointer;list-style:none}.invoice-property-card summary::-webkit-details-marker{display:none}.invoice-property-card summary:hover{background:#173442}.invoice-property-card summary span:nth-child(2){display:grid;gap:3px}.invoice-property-card summary b{color:#f0f8fb;font-size:16px}.invoice-property-card summary small{color:#9be97c;font-size:11px;font-weight:800;text-transform:uppercase}.invoice-property-icon{display:grid;place-items:center;width:33px;height:33px;border:1px solid #75d862;border-radius:8px;background:#19412d;color:#a9f78a;font-size:19px}.invoice-property-card summary i{color:#9be97c;font-size:19px;font-style:normal}.invoice-property-card[open] summary{border-bottom:1px solid #3c6172}.invoice-items{display:grid;gap:8px;padding:12px;background:#0c1d27}.invoice-item{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px;border:1px solid #314e5d;border-radius:8px;background:#0d202a}.invoice-item span{display:grid;gap:3px}.invoice-item b{color:#e9f4f8;font-size:13px}.invoice-item small{color:#a9c1ce;font-size:11px}.invoice-item a,.invoice-modal-actions button{display:inline-flex;justify-content:center;align-items:center;border:1px solid #9be97c;border-radius:8px;padding:9px 12px;background:#184b2a;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;cursor:pointer}.invoice-item a:hover{background:#256b36;color:#fff}.invoice-modal-actions{display:flex;justify-content:flex-end;padding-top:2px}.invoice-modal-actions button{border-color:#608091;background:transparent;color:#dcecf3}@media(max-width:700px){.invoice-item{align-items:stretch;flex-direction:column}.invoice-item a{width:100%}}
|
||||
@media(max-width:1270px){.helga-card{grid-column:2;grid-row:auto;grid-template-columns:180px minmax(0,1fr);min-height:216px}.helga-photo{width:180px;height:216px}.dashboard-offers{grid-column:1;grid-row:auto}}
|
||||
@media(max-width:880px){.helga-card,.dashboard-inbox,.dashboard-offers{grid-column:auto;grid-row:auto}}
|
||||
.activity-list .activity-status.orange{color:#ffa254}
|
||||
.activity-list .activity-status.green{color:#62d589;text-shadow:0 0 8px #62d58980}
|
||||
.activity-list .activity-status.blue{color:#4db5ff}
|
||||
.invoices-overview-page{display:grid;gap:18px;padding:30px;min-width:0}.invoices-overview-page>.offers-heading>button{border:1px solid #4a7690;border-radius:8px;padding:10px 14px;background:#183645;color:#d8edf5;font-weight:900;cursor:pointer}.invoices-overview-page>.offers-heading>button:hover{background:#245168;color:#fff}.invoice-overview-tabs{display:flex;gap:9px;border-bottom:1px solid #294353;padding-bottom:12px}.invoice-overview-tabs button{border:1px solid #426778;border-radius:8px;padding:9px 14px;background:#102633;color:#abc2ce;font-size:12px;font-weight:900;cursor:pointer}.invoice-overview-tabs button.active,.invoice-overview-tabs button:hover{border-color:#67b9ff;background:#173a57;color:#e8f6ff}.invoice-overview-list{display:grid;gap:9px}.invoice-overview-item{display:grid;grid-template-columns:.8fr 1.4fr 1.4fr .8fr auto;align-items:center;gap:16px;padding:16px;border:1px solid #315667;border-radius:10px;background:#112b39}.invoice-overview-item>div{display:grid;gap:4px;min-width:0}.invoice-overview-item small{color:#8fb5c7;font-size:10px;font-weight:900;letter-spacing:.08em}.invoice-overview-item b{overflow:hidden;color:#edf8fb;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item span{overflow:hidden;color:#b8cbd4;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item a{display:inline-flex;align-items:center;justify-content:center;border:1px solid #8fd979;border-radius:8px;padding:9px 12px;background:#19412d;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;white-space:nowrap}.invoice-overview-item a:hover{background:#256b36;color:#fff}@media(max-width:900px){.invoices-overview-page{padding:20px}.invoice-overview-item{grid-template-columns:1fr 1fr}.invoice-overview-item a{grid-column:1/-1;width:100%}}@media(max-width:520px){.invoice-overview-item{grid-template-columns:1fr}.invoice-overview-tabs{flex-direction:column}.invoice-overview-tabs button{width:100%}}
|
||||
@@ -0,0 +1,286 @@
|
||||
/* Benutzerverwaltung. Setzt auf den vorhandenen Listenstil der Ticketansicht auf
|
||||
und ergaenzt nur, was es dort noch nicht gibt: Formular, Rollenchips, Protokoll. */
|
||||
|
||||
.users-notice {
|
||||
max-width: 720px;
|
||||
margin: 0 0 18px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid #9fd6a4;
|
||||
border-radius: 10px;
|
||||
background: #f1fbef;
|
||||
color: #216c2c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.users-form {
|
||||
margin: 0 0 22px;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid #c7dce8;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px #294f670e;
|
||||
}
|
||||
|
||||
.users-form h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.users-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px 18px;
|
||||
}
|
||||
|
||||
.users-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #3d596d;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.users-form input,
|
||||
.users-form select {
|
||||
width: 100%;
|
||||
border: 1px solid #b6cfdd;
|
||||
border-radius: 8px;
|
||||
padding: 11px 12px;
|
||||
background: #fbfdff;
|
||||
color: #17384e;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.users-form input:focus,
|
||||
.users-form select:focus {
|
||||
border-color: #5fb85f;
|
||||
box-shadow: 0 0 0 3px #9be97c33;
|
||||
}
|
||||
|
||||
.users-form input:disabled,
|
||||
.users-form select:disabled {
|
||||
background: #eef3f6;
|
||||
color: #6b8091;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.users-form label small {
|
||||
color: #6a8290;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.users-form > button,
|
||||
.users-form-actions button {
|
||||
margin-top: 18px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 18px;
|
||||
background: #2f8744;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.users-form > button:disabled,
|
||||
.users-form-actions button:disabled {
|
||||
opacity: 0.62;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.users-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.users-form-actions .users-secondary {
|
||||
background: #fff;
|
||||
border: 1px solid #9fbedb;
|
||||
color: #1b3047;
|
||||
}
|
||||
|
||||
.users-permissions {
|
||||
margin-top: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: #f2f9fd;
|
||||
color: #3d596d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.users-permissions b {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.users-permissions ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Rollen als farbige Chips, damit die Tabelle auf einen Blick lesbar ist. */
|
||||
.users-role-admin {
|
||||
background: #ffe9c9;
|
||||
color: #8a5411;
|
||||
}
|
||||
|
||||
.users-role-inhaber {
|
||||
background: #dbe7ff;
|
||||
color: #24478f;
|
||||
}
|
||||
|
||||
.users-role-buero {
|
||||
background: #e6f7dd;
|
||||
color: #25753a;
|
||||
}
|
||||
|
||||
.users-state-active {
|
||||
color: #25753a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.users-state-inactive {
|
||||
color: #a82028;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.users-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.users-actions button {
|
||||
border: 1px solid #9fbedb;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
background: #fff;
|
||||
color: #1b3047;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.users-actions button:hover:not(:disabled) {
|
||||
border-color: #6cc55a;
|
||||
background: #f1fbf3;
|
||||
}
|
||||
|
||||
.users-actions button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.users-actions .users-danger {
|
||||
border-color: #e0a9a4;
|
||||
color: #a1332d;
|
||||
}
|
||||
|
||||
.users-actions .users-danger:hover:not(:disabled) {
|
||||
border-color: #d66b6b;
|
||||
background: #fff4f3;
|
||||
}
|
||||
|
||||
.users-audit {
|
||||
margin-top: 26px;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid #c7dce8;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.users-audit h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.users-audit ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.users-audit li {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: baseline;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e6eef3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.users-audit li:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.users-audit b {
|
||||
flex: 0 0 150px;
|
||||
color: #3d596d;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.users-audit span {
|
||||
color: #244258;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.users-audit li {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.users-audit b {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dunkles Farbschema, passend zum Rest der Anwendung. */
|
||||
:root[data-theme='dark'] .users-form,
|
||||
:root[data-theme='dark'] .users-audit {
|
||||
border-color: #34505f;
|
||||
background: #172a36;
|
||||
color: #e5eff5;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .users-form input,
|
||||
:root[data-theme='dark'] .users-form select {
|
||||
border-color: #3a5868;
|
||||
background: #132630;
|
||||
color: #e3eff5;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .users-form label,
|
||||
:root[data-theme='dark'] .users-permissions,
|
||||
:root[data-theme='dark'] .users-audit span {
|
||||
color: #b4c5cf;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .users-permissions {
|
||||
background: #1d3544;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .users-notice {
|
||||
border-color: #39704a;
|
||||
background: #163023;
|
||||
color: #d7f3d9;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .users-actions button {
|
||||
border-color: #3a5868;
|
||||
background: #132630;
|
||||
color: #dceaf2;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .users-audit li {
|
||||
border-color: #294553;
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { api } from '@/api/client';
|
||||
import { useAuth } from '@/auth/AuthProvider';
|
||||
import type { ManagedUser, Role, RoleOption } from '@/auth/types';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
|
||||
type UsersPayload = { users: ManagedUser[]; roles: RoleOption[] };
|
||||
type AuditEntry = { actor: string; action: string; subject: string; detail: Record<string, unknown>; createdAt: string };
|
||||
|
||||
const emptyDraft = { username: '', displayName: '', role: 'buero' as Role, password: '' };
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung.
|
||||
*
|
||||
* Nur fuer die Rolle Administration sichtbar. Das Backend setzt dieselben Regeln
|
||||
* noch einmal durch -- diese Ansicht macht sie nur sichtbar, sie ersetzt sie nicht.
|
||||
*/
|
||||
export function UsersView() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [editing, setEditing] = useState<ManagedUser | null>(null);
|
||||
const [editDraft, setEditDraft] = useState({ username: '', displayName: '', role: 'buero' as Role, password: '' });
|
||||
const [deleteConfirming, setDeleteConfirming] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await api.get<UsersPayload>('/api/users');
|
||||
setUsers(data.users);
|
||||
setRoles(data.roles);
|
||||
} catch (problem) {
|
||||
setError(problem instanceof Error ? problem.message : 'Die Benutzer konnten nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshAudit = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<{ entries: AuditEntry[] }>('/api/users/audit');
|
||||
setAudit(data.entries);
|
||||
} catch {
|
||||
// Das Protokoll ist eine Beigabe. Faellt es aus, bleibt die Verwaltung nutzbar.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
void refreshAudit();
|
||||
}, [refresh, refreshAudit]);
|
||||
|
||||
const run = async (id: string | null, action: () => Promise<string>) => {
|
||||
setBusyId(id ?? 'form');
|
||||
setError('');
|
||||
setNotice('');
|
||||
|
||||
try {
|
||||
setNotice(await action());
|
||||
await refresh();
|
||||
await refreshAudit();
|
||||
} catch (problem) {
|
||||
setError(problem instanceof Error ? problem.message : 'Die Aktion ist fehlgeschlagen.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const create = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
void run(null, async () => {
|
||||
await api.post('/api/users', draft);
|
||||
setDraft(emptyDraft);
|
||||
setCreating(false);
|
||||
|
||||
return `Benutzer „${draft.displayName}“ wurde angelegt.`;
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!editing) return;
|
||||
|
||||
void run(editing.id, async () => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
|
||||
if (editDraft.displayName !== editing.displayName) changes.displayName = editDraft.displayName;
|
||||
if (editDraft.username !== editing.username) changes.username = editDraft.username;
|
||||
if (editDraft.role !== editing.role) changes.role = editDraft.role;
|
||||
if (editDraft.password !== '') changes.password = editDraft.password;
|
||||
|
||||
if (Object.keys(changes).length === 0) {
|
||||
setEditing(null);
|
||||
return 'Es wurde nichts geändert.';
|
||||
}
|
||||
|
||||
const result = await api.patch<{ endedSessions: number }>(`/api/users/${editing.id}`, changes);
|
||||
setEditing(null);
|
||||
|
||||
return result.endedSessions > 0
|
||||
? `Gespeichert. ${result.endedSessions} offene Sitzung(en) wurden beendet.`
|
||||
: 'Gespeichert.';
|
||||
});
|
||||
};
|
||||
|
||||
const toggleActive = (user: ManagedUser) =>
|
||||
void run(user.id, async () => {
|
||||
await api.patch(`/api/users/${user.id}`, { isActive: !user.isActive });
|
||||
|
||||
return user.isActive ? `„${user.displayName}“ ist jetzt deaktiviert.` : `„${user.displayName}“ ist wieder aktiv.`;
|
||||
});
|
||||
|
||||
const remove = (user: ManagedUser) =>
|
||||
void run(user.id, async () => {
|
||||
await api.delete(`/api/users/${user.id}`);
|
||||
setDeleteConfirming(null);
|
||||
|
||||
return `Benutzer „${user.displayName}“ wurde gelöscht.`;
|
||||
});
|
||||
|
||||
const startEdit = (user: ManagedUser) => {
|
||||
setEditing(user);
|
||||
setEditDraft({ username: user.username, displayName: user.displayName, role: user.role, password: '' });
|
||||
setCreating(false);
|
||||
setNotice('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const activeAdmins = users.filter((user) => user.role === 'admin' && user.isActive).length;
|
||||
|
||||
return (
|
||||
<section className="tickets-page users-page">
|
||||
<div className="tickets-heading">
|
||||
<div>
|
||||
<p className="home-kicker">Verwaltung</p>
|
||||
<h2>Benutzer</h2>
|
||||
<p>
|
||||
Konten, Rollen und Rechte für EK-DOS. Jede Änderung an Rolle, Passwort oder Status beendet die offenen
|
||||
Sitzungen des betroffenen Kontos sofort.
|
||||
</p>
|
||||
</div>
|
||||
<div className="tickets-actions">
|
||||
<span>{users.length} Konten · {activeAdmins} aktive Administration</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreating((open) => !open);
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
{creating ? 'Abbrechen' : 'Benutzer anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="tickets-error">{error}</p>}
|
||||
{notice && <p className="users-notice">{notice}</p>}
|
||||
|
||||
{creating && (
|
||||
<form className="users-form" onSubmit={create}>
|
||||
<h3>Neuer Benutzer</h3>
|
||||
<div className="users-form-grid">
|
||||
<label>
|
||||
Benutzername
|
||||
<input
|
||||
value={draft.username}
|
||||
onChange={(event) => setDraft({ ...draft, username: event.target.value })}
|
||||
placeholder="z. B. svenja"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Anzeigename
|
||||
<input
|
||||
value={draft.displayName}
|
||||
onChange={(event) => setDraft({ ...draft, displayName: event.target.value })}
|
||||
placeholder="z. B. Svenja"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Rolle
|
||||
<select value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value as Role })}>
|
||||
{roles.map((role) => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input
|
||||
type="password"
|
||||
value={draft.password}
|
||||
onChange={(event) => setDraft({ ...draft, password: event.target.value })}
|
||||
autoComplete="new-password"
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
<small>Mindestens 12 Zeichen.</small>
|
||||
</label>
|
||||
</div>
|
||||
<RolePermissions roles={roles} selected={draft.role} />
|
||||
<button type="submit" disabled={busyId === 'form'}>
|
||||
{busyId === 'form' ? 'Wird angelegt …' : 'Benutzer anlegen'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<form className="users-form" onSubmit={saveEdit}>
|
||||
<h3>{editing.displayName} bearbeiten</h3>
|
||||
<div className="users-form-grid">
|
||||
<label>
|
||||
Benutzername
|
||||
<input
|
||||
value={editDraft.username}
|
||||
onChange={(event) => setEditDraft({ ...editDraft, username: event.target.value })}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Anzeigename
|
||||
<input
|
||||
value={editDraft.displayName}
|
||||
onChange={(event) => setEditDraft({ ...editDraft, displayName: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Rolle
|
||||
<select
|
||||
value={editDraft.role}
|
||||
onChange={(event) => setEditDraft({ ...editDraft, role: event.target.value as Role })}
|
||||
disabled={editing.id === currentUser?.id}
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{editing.id === currentUser?.id && <small>Die eigene Rolle kann nicht geändert werden.</small>}
|
||||
</label>
|
||||
<label>
|
||||
Neues Passwort
|
||||
<input
|
||||
type="password"
|
||||
value={editDraft.password}
|
||||
onChange={(event) => setEditDraft({ ...editDraft, password: event.target.value })}
|
||||
autoComplete="new-password"
|
||||
placeholder="leer lassen, um es beizubehalten"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<RolePermissions roles={roles} selected={editDraft.role} />
|
||||
<div className="users-form-actions">
|
||||
<button type="submit" disabled={busyId === editing.id}>
|
||||
{busyId === editing.id ? 'Wird gespeichert …' : 'Speichern'}
|
||||
</button>
|
||||
<button type="button" className="users-secondary" onClick={() => setEditing(null)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="tickets-table-wrap">
|
||||
<table className="tickets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th>Letzte Anmeldung</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5}>Benutzer werden geladen …</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5}>Es ist noch kein Benutzer angelegt.</td>
|
||||
</tr>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<b>{user.displayName}</b>
|
||||
<small>{user.username}{user.id === currentUser?.id ? ' · das bist du' : ''}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`ticket-status users-role-${user.role}`}>{user.roleLabel}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={user.isActive ? 'users-state-active' : 'users-state-inactive'}>
|
||||
{user.isActive ? 'aktiv' : 'deaktiviert'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : 'noch nie'}</td>
|
||||
<td className="users-actions">
|
||||
<button onClick={() => startEdit(user)} disabled={busyId !== null}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleActive(user)}
|
||||
disabled={busyId !== null || user.id === currentUser?.id}
|
||||
title={user.id === currentUser?.id ? 'Das eigene Konto kann nicht deaktiviert werden.' : ''}
|
||||
>
|
||||
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
{deleteConfirming === user.id ? (
|
||||
<>
|
||||
<button className="users-danger" onClick={() => remove(user)} disabled={busyId !== null}>
|
||||
Wirklich löschen
|
||||
</button>
|
||||
<button onClick={() => setDeleteConfirming(null)}>Abbrechen</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="users-danger"
|
||||
onClick={() => setDeleteConfirming(user.id)}
|
||||
disabled={busyId !== null || user.id === currentUser?.id}
|
||||
title={user.id === currentUser?.id ? 'Das eigene Konto kann nicht gelöscht werden.' : ''}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{audit.length > 0 && (
|
||||
<div className="users-audit">
|
||||
<h3>Letzte Änderungen</h3>
|
||||
<ul>
|
||||
{audit.map((entry, index) => (
|
||||
<li key={`${entry.createdAt}-${index}`}>
|
||||
<b>{formatDateTime(entry.createdAt)}</b>
|
||||
<span>
|
||||
{entry.actor} · {describeAction(entry.action)} · {entry.subject}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RolePermissions({ roles, selected }: { roles: RoleOption[]; selected: Role }) {
|
||||
const role = roles.find((candidate) => candidate.value === selected);
|
||||
|
||||
if (!role) return null;
|
||||
|
||||
return (
|
||||
<div className="users-permissions">
|
||||
<b>Diese Rolle darf zusätzlich:</b>
|
||||
{role.permissions.length === 0 ? (
|
||||
<span>nur die allgemeinen Büroansichten</span>
|
||||
) : (
|
||||
<ul>
|
||||
{role.permissions.map((permission) => (
|
||||
<li key={permission}>{permission}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeAction(action: string): string {
|
||||
switch (action) {
|
||||
case 'user.create':
|
||||
return 'angelegt';
|
||||
case 'user.update':
|
||||
return 'geändert';
|
||||
case 'user.delete':
|
||||
return 'gelöscht';
|
||||
case 'user.password':
|
||||
return 'Passwort zurückgesetzt';
|
||||
case 'password.self':
|
||||
return 'eigenes Passwort geändert';
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { defineConfig, type UserConfig } from 'vite';
|
||||
|
||||
/**
|
||||
* Funktionsform, damit ein Produktionsbau (Gitea-Runner) nichts vom
|
||||
* Entwicklungsserver anfasst. Der Bau erzeugt ausschliesslich statische
|
||||
* Dateien; auf dem Zielsystem laeuft kein Node.
|
||||
*/
|
||||
export default defineConfig(({ command }) => {
|
||||
const config: UserConfig = {
|
||||
plugins: [react()],
|
||||
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
// Quellkarten bleiben aus: das Buendel liegt auf einem erreichbaren
|
||||
// nginx und soll den Quelltext nicht mitliefern.
|
||||
sourcemap: false,
|
||||
target: 'es2022',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// React getrennt halten, damit ein Oberflaechen-Update nicht
|
||||
// den gesamten Cache der Anwender entwertet.
|
||||
manualChunks: { react: ['react', 'react-dom'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Ab hier nur noch Entwicklungsserver. Ein `vite build` erreicht diese
|
||||
// Zeilen nie und braucht deshalb weder Proxy noch laufendes Backend.
|
||||
if (command !== 'serve') return config;
|
||||
|
||||
config.server = {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
// Im Betrieb macht das nginx. Lokal zeigt der Proxy auf
|
||||
// `php -S 127.0.0.1:8080 -t backend/public`.
|
||||
'/api': {
|
||||
target: process.env.EKDOS_BACKEND ?? 'http://127.0.0.1:8080',
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
});
|
||||