Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a9dbbba9f | |||
| 81b7613c73 | |||
| fc778da204 | |||
| d88edcd6ef | |||
| 71ab82b592 | |||
| 75c852625c | |||
| 3140a024e6 | |||
| ae3a3f93e8 | |||
| 36a6e2b66c | |||
| 4eea4b23bd | |||
| e96cd63f33 | |||
| d65585b821 | |||
| a76567969a | |||
| f0b1ca4b68 | |||
| fa19dbe504 | |||
| 29d40c6f10 | |||
| 9133928401 | |||
| 5e40b3105c | |||
| 354e2844d1 | |||
| 597d7ffb6f | |||
| 7072a21ed8 | |||
| 754b023435 | |||
| ac57b932c2 | |||
| e607310689 | |||
| 1f8906bc3d | |||
| d6ddf12c04 | |||
| 994263dc45 | |||
| 23d46e7c40 | |||
| d8e0bdc814 | |||
| f45a2525e6 | |||
| 5852fc6616 | |||
| aaf9afdc9f | |||
| 98a6df5081 | |||
| 2c3379026b | |||
| 84be8e5828 | |||
| 1a5de83d50 | |||
| 476b09499a | |||
| 974462e983 | |||
| 749f04c2c4 | |||
| 7de4e85a38 | |||
| 4266adc853 | |||
| f7be617e1d | |||
| 4dd2ba9d9d | |||
| f8209c3712 | |||
| d776b80fe0 | |||
| dd89d8388e | |||
| f5ec13701b | |||
| bbdbf0176e | |||
| 2fc90639f7 | |||
| 5cf3d8fe70 |
@@ -15,3 +15,23 @@ devscripts
|
||||
backend/data/stackpulse.db
|
||||
backend/data/stackpulse.db-shm
|
||||
backend/data/stackpulse.db-wal
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
!.vscode/extensions.json
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,8 +1,9 @@
|
||||

|
||||
|
||||
# 📦 StackPulse 
|
||||
# 📦 StackPulse 
|
||||
|
||||
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
||||
Aktuell funktioniert StackPulse nur mit der Business-Edition von Portainer. Die Communitiy-Edition wird in einem späteren Release implementiert!
|
||||
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
||||
|
||||
Ziel:
|
||||
@@ -26,7 +27,7 @@ Ziel:
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<details>
|
||||
<summary>✅ v0.2 – Release</summary>
|
||||
|
||||
### Backend
|
||||
@@ -46,40 +47,86 @@ Ziel:
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>🟡 v0.3 – In Entwicklung</summary>
|
||||
<details>
|
||||
<summary>✅ v0.3 – Release</summary>
|
||||
|
||||
### Backend
|
||||
- [ ] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
|
||||
- [ ] Erweiterung der Logs
|
||||
- [x] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
|
||||
- [x] Erweiterung der Logs
|
||||
- [x] API für Filter & Suche (Stacks nach Name/Status abrufen)
|
||||
- [ ] API für Sortierung der Stacks
|
||||
|
||||
### Frontend
|
||||
- [x] Filter: Stacks nach Name oder Status durchsuchen
|
||||
- [ ] Sortierung: Stacks im Frontend sortieren
|
||||
- [ ] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast + Notification-Center)
|
||||
- [ ] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
|
||||
- [x] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast)
|
||||
- [x] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
|
||||
|
||||
### Features
|
||||
- [ ] Frontend-Filter und Sortierungen für schnellere Navigation bei vielen Stacks
|
||||
- [ ] Echtzeit-Feedback im UI (Notifications)
|
||||
- [ ] Datenkonsistenz sichern: keine doppelten Stack-IDs mehr
|
||||
- [x] Frontend-Filter für schnellere Navigation bei vielen Stacks
|
||||
- [x] Echtzeit-Feedback im UI (Notifications)
|
||||
- [x] Datenkonsistenz sichern: keine doppelten Stack-IDs mehr
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🔮 Geplante Features (v0.4+)</summary>
|
||||
<summary>✅ v0.4 – Release</summary>
|
||||
|
||||
- Notifications (z. B. via Webhooks oder Mail)
|
||||
- Authentifizierung & Benutzerverwaltung
|
||||
- Monitoring (Status, CPU/RAM)
|
||||
- Verbesserte UI/UX
|
||||
- Export/Import von Scheduler-Jobs (z. B. JSON)
|
||||
- Health-Checks nach Redeploy (automatisierte Tests, Statusprüfung)
|
||||
### Backend
|
||||
- keine Änderungen
|
||||
|
||||
### Frontend
|
||||
- [x] GUI komplett überarbeitet
|
||||
- [x] Umstellung auf Material Tailwind
|
||||
- [x] Dashboard von Creative Tim als neues Frontend
|
||||
|
||||
### Features
|
||||
- [x] Neue, übersichtliche Oberfläche im Material Design
|
||||
- [x] leicht erweiterbar durch vorgefertigte Komponenten
|
||||
- [x] kleine Bugfixes
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>✅ v0.5 – Release</summary>
|
||||
|
||||
### 🛠️ Backend
|
||||
- [x] Neue API-Endpunkte für Logging, Benutzer- und Gruppenverwaltung sowie Superuser-Registrierung
|
||||
- [x] Authentifizierung und Log-In
|
||||
- [x] Erweiterte Protokollierung und Datenbank-Migration
|
||||
- [x] Präzisere Steuerung der globalen Benutzerrechte
|
||||
|
||||
### 💻 Frontend
|
||||
- [x] Neue Log-In-Seite
|
||||
- [x] Benutzer- und Gruppenverwaltung mit granularen Rechteeinstellungen
|
||||
- [x] Erweiterte Log-Ausgabe mit Filtern und Suchoptionen
|
||||
- [x] Konsolidierte und vereinheitlichte Ansichten im Dashboard
|
||||
|
||||
### ✨ Features
|
||||
- [x] Vollständiges Authentifizierungs- und Berechtigungssystem
|
||||
- [x] Erweiterte Logs in allen Bereichen
|
||||
- [x] Speicherung von Server- und Superuser-Einstellungen in der Datenbank
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
|
||||
<summary>🟡 v0.6 – In Entwicklung</summary>
|
||||
|
||||
### Backend
|
||||
|
||||
### Frontend
|
||||
|
||||
### Features
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>🔮 Geplante Features (v0.6+)</summary>
|
||||
|
||||
- Notifications (z. B. via Webhooks oder Mail)
|
||||
- Monitoring (Status, CPU/RAM)
|
||||
- Verbesserte UI/UX
|
||||
- Erweiterte Filterungen udn Sortierungen
|
||||
- Multi-Server Verwaltung
|
||||
- Integration Community Edition
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -117,41 +164,29 @@ stackpulse/
|
||||
|
||||
### Mit Compose starten
|
||||
```bash
|
||||
version: "2.4"
|
||||
version: '3.8'
|
||||
services:
|
||||
stackpulse:
|
||||
container_name: stackpulse
|
||||
image: ghcr.io/mboehmlaender/stackpulse
|
||||
ports:
|
||||
- '4001:4001'
|
||||
volumes:
|
||||
- stackpulse_data:/app/backend/data
|
||||
environment:
|
||||
PORTAINER_URL: "Your_Portainer_Server_Address"
|
||||
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
||||
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
|
||||
SELF_STACK_ID: "Stackpulse ID"
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "4001:4001"
|
||||
volumes:
|
||||
- stackpulse_data:/app/backend/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
|
||||
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
|
||||
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
||||
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
||||
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
|
||||
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
|
||||
volumes:
|
||||
stackpulse_data:
|
||||
|
||||
|
||||
```
|
||||
|
||||
Die PORTAINER_ENDPOINT_ID erhältst du, wenn du die die URL im Browser ansiehst, wenn du das Dashboard in Portainer öffnest:
|
||||
|
||||

|
||||
|
||||
Die 3 wäre in diesem Fall Endpoint-ID.
|
||||
|
||||
Die STACK_SELF_ID findest du, wenn du das Frontend von StackPulse öffnest:
|
||||
|
||||

|
||||
|
||||
Diese ID kann erst nach dem Deploy von Stackpulse ausgelesen werden. Vergiss daher nicht, nach dem Hinterlegen der ID in den Variablen das Stack noch einmal zu redeployen!
|
||||
|
||||
---
|
||||
|
||||
## 📋 Voraussetzungen
|
||||
|
||||
- Node.js >= 20
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,3 +1,6 @@
|
||||
PORTAINER_URL=Your_Portainer_Server_Adress
|
||||
PORTAINER_API_KEY=Your_Portainer_API_Key
|
||||
PORTAINER_ENDPOINT_ID=Your_Portainer_Endpoint_ID
|
||||
PORTAINER_URL=Your_Portainer_Server_Adress (optional)
|
||||
PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
|
||||
SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
||||
SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
||||
SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
|
||||
SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
|
||||
@@ -0,0 +1,300 @@
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
export const SUPERUSER_GROUP_NAME = 'superuser';
|
||||
|
||||
export const AVATAR_COLORS = [
|
||||
'bg-arcticBlue-600',
|
||||
'bg-copperRust-500',
|
||||
'bg-sunsetCoral-600',
|
||||
'bg-mintTea-400',
|
||||
'bg-lavenderSmoke-500',
|
||||
'bg-emeraldMist-500',
|
||||
'bg-roseQuartz-500',
|
||||
'bg-auroraTeal-500',
|
||||
'bg-citrusPunch-500',
|
||||
'bg-mossGreen-400'
|
||||
];
|
||||
|
||||
export const DEFAULT_AVATAR_COLOR = 'bg-mossGreen-500';
|
||||
const AVATAR_COLOR_SET = new Set([...AVATAR_COLORS, DEFAULT_AVATAR_COLOR]);
|
||||
|
||||
export const pickRandomAvatarColor = () => {
|
||||
if (!Array.isArray(AVATAR_COLORS) || AVATAR_COLORS.length === 0) {
|
||||
return DEFAULT_AVATAR_COLOR;
|
||||
}
|
||||
const index = Math.floor(Math.random() * AVATAR_COLORS.length);
|
||||
return AVATAR_COLORS[index] ?? DEFAULT_AVATAR_COLOR;
|
||||
};
|
||||
|
||||
export const normalizeAvatarColor = (value) => {
|
||||
if (!value) return null;
|
||||
const candidate = String(value).trim();
|
||||
return AVATAR_COLOR_SET.has(candidate) ? candidate : null;
|
||||
};
|
||||
|
||||
const selectGroupByName = db.prepare('SELECT * FROM user_groups WHERE name = ?');
|
||||
const insertGroup = db.prepare('INSERT INTO user_groups (name, description) VALUES (?, ?)');
|
||||
|
||||
const selectSuperuser = db.prepare(`
|
||||
SELECT u.*
|
||||
FROM users u
|
||||
INNER JOIN user_group_memberships m ON m.user_id = u.id
|
||||
INNER JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE g.name = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const selectUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||
const selectUserByEmail = db.prepare('SELECT * FROM users WHERE email = ?');
|
||||
const selectUserById = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||
const updateLastLogin = db.prepare(`
|
||||
UPDATE users
|
||||
SET last_login = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const updateUser = db.prepare(`
|
||||
UPDATE users
|
||||
SET username = ?, email = ?, password_hash = ?, password_salt = ?, is_active = 1, avatar_color = COALESCE(?, avatar_color), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const insertMembership = db.prepare(`
|
||||
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
const removeMembershipsForGroup = db.prepare(`
|
||||
DELETE FROM user_group_memberships
|
||||
WHERE group_id = ?
|
||||
`);
|
||||
|
||||
const selectUserIdsForGroup = db.prepare(`
|
||||
SELECT u.id
|
||||
FROM users u
|
||||
INNER JOIN user_group_memberships m ON m.user_id = u.id
|
||||
WHERE m.group_id = ?
|
||||
`);
|
||||
|
||||
const deleteUserById = db.prepare('DELETE FROM users WHERE id = ?');
|
||||
const deleteGroupById = db.prepare('DELETE FROM user_groups WHERE id = ?');
|
||||
|
||||
const countSuperusers = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM user_group_memberships m
|
||||
INNER JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE g.name = ?
|
||||
`);
|
||||
|
||||
const creationTransaction = db.transaction(({ username, email, passwordHash, passwordSalt, groupId, avatarColor }) => {
|
||||
const existingUser = selectUserByUsername.get(username) || selectUserByEmail.get(email);
|
||||
|
||||
let userId;
|
||||
if (existingUser) {
|
||||
const existingColor = normalizeAvatarColor(existingUser.avatar_color);
|
||||
const normalizedNewColor = normalizeAvatarColor(avatarColor);
|
||||
const colorToPersist = existingColor || normalizedNewColor || DEFAULT_AVATAR_COLOR;
|
||||
updateUser.run(username, email, passwordHash, passwordSalt, colorToPersist, existingUser.id);
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
const colorToPersist = normalizeAvatarColor(avatarColor) || DEFAULT_AVATAR_COLOR;
|
||||
const result = insertUser.run(username, email, passwordHash, passwordSalt, colorToPersist);
|
||||
userId = result.lastInsertRowid;
|
||||
}
|
||||
|
||||
removeMembershipsForGroup.run(groupId);
|
||||
insertMembership.run(userId, groupId);
|
||||
|
||||
return selectUserById.get(userId);
|
||||
});
|
||||
|
||||
export function hashPassword(password) {
|
||||
if (!password || typeof password !== 'string') {
|
||||
const err = new Error('INVALID_PASSWORD');
|
||||
err.code = 'INVALID_PASSWORD';
|
||||
throw err;
|
||||
}
|
||||
const trimmed = password.trim();
|
||||
if (!trimmed) {
|
||||
const err = new Error('INVALID_PASSWORD');
|
||||
err.code = 'INVALID_PASSWORD';
|
||||
throw err;
|
||||
}
|
||||
if (trimmed.length < 8) {
|
||||
const err = new Error('PASSWORD_TOO_SHORT');
|
||||
err.code = 'PASSWORD_TOO_SHORT';
|
||||
throw err;
|
||||
}
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
|
||||
return { salt, hash };
|
||||
}
|
||||
|
||||
export function verifyPassword(password, hash, salt) {
|
||||
if (!hash || !salt) return false;
|
||||
if (!password || typeof password !== 'string') return false;
|
||||
const trimmed = password.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
const derived = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
|
||||
const storedBuffer = Buffer.from(hash, 'hex');
|
||||
const derivedBuffer = Buffer.from(derived, 'hex');
|
||||
if (storedBuffer.length !== derivedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(storedBuffer, derivedBuffer);
|
||||
}
|
||||
|
||||
function ensureGroup(name, description = null) {
|
||||
let group = selectGroupByName.get(name);
|
||||
if (group) return group;
|
||||
insertGroup.run(name, description);
|
||||
group = selectGroupByName.get(name);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function hasSuperuser() {
|
||||
const { count } = countSuperusers.get(SUPERUSER_GROUP_NAME);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
export function findUserByIdentifier(identifier) {
|
||||
const value = String(identifier || '').trim();
|
||||
if (!value) return null;
|
||||
if (value.includes('@')) {
|
||||
return selectUserByEmail.get(value.toLowerCase());
|
||||
}
|
||||
return selectUserByUsername.get(value);
|
||||
}
|
||||
|
||||
export function findUserById(id) {
|
||||
if (!id) return null;
|
||||
return selectUserById.get(id);
|
||||
}
|
||||
|
||||
export function markUserLogin(userId) {
|
||||
if (!userId) return;
|
||||
updateLastLogin.run(userId);
|
||||
}
|
||||
|
||||
function createOrUpdateSuperuser({ username, email, password }) {
|
||||
const normalizedUsername = String(username || '').trim();
|
||||
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||
|
||||
if (!normalizedUsername) {
|
||||
throw new Error('USERNAME_REQUIRED');
|
||||
}
|
||||
|
||||
if (!normalizedEmail || !normalizedEmail.includes('@')) {
|
||||
throw new Error('EMAIL_INVALID');
|
||||
}
|
||||
|
||||
const { hash, salt } = hashPassword(password);
|
||||
const group = ensureGroup(SUPERUSER_GROUP_NAME, 'System Superuser');
|
||||
const avatarColor = pickRandomAvatarColor();
|
||||
|
||||
const user = creationTransaction({
|
||||
username: normalizedUsername,
|
||||
email: normalizedEmail,
|
||||
passwordHash: hash,
|
||||
passwordSalt: salt,
|
||||
groupId: group.id,
|
||||
avatarColor
|
||||
});
|
||||
|
||||
const persistedColor = normalizeAvatarColor(user.avatar_color) || avatarColor || DEFAULT_AVATAR_COLOR;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatarColor: persistedColor
|
||||
};
|
||||
}
|
||||
|
||||
const deletionTransaction = db.transaction((groupId) => {
|
||||
const members = selectUserIdsForGroup.all(groupId);
|
||||
|
||||
members.forEach(({ id }) => {
|
||||
deleteUserById.run(id);
|
||||
});
|
||||
|
||||
const { changes: groupChanges } = deleteGroupById.run(groupId);
|
||||
|
||||
return {
|
||||
usersRemoved: members.length,
|
||||
groupRemoved: groupChanges > 0
|
||||
};
|
||||
});
|
||||
|
||||
export function ensureSuperuserFromEnv() {
|
||||
const username = process.env.SUPERUSER_USERNAME;
|
||||
const email = process.env.SUPERUSER_EMAIL;
|
||||
const password = process.env.SUPERUSER_PASSWORD;
|
||||
|
||||
if (hasSuperuser()) {
|
||||
if (username || email || password) {
|
||||
console.log('ℹ️ Superuser existiert bereits - Umgebungsvariablen werden ignoriert.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
createOrUpdateSuperuser({ username, email, password });
|
||||
console.log('✅ Superuser aus Umgebungsvariablen angelegt.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('⚠️ Superuser konnte nicht aus Umgebungsvariablen erzeugt werden:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSuperuser({ username, email, password }) {
|
||||
if (hasSuperuser()) {
|
||||
const err = new Error('SUPERUSER_ALREADY_EXISTS');
|
||||
err.code = 'SUPERUSER_ALREADY_EXISTS';
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
return createOrUpdateSuperuser({ username, email, password });
|
||||
} catch (error) {
|
||||
if (error.message === 'USERNAME_REQUIRED' || error.message === 'EMAIL_INVALID' || error.message === 'PASSWORD_TOO_SHORT') {
|
||||
error.code = error.message;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSuperuser() {
|
||||
const group = selectGroupByName.get(SUPERUSER_GROUP_NAME);
|
||||
|
||||
if (!group) {
|
||||
const error = new Error('SUPERUSER_NOT_FOUND');
|
||||
error.code = 'SUPERUSER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return deletionTransaction(group.id);
|
||||
}
|
||||
|
||||
export function getSuperuserSummary() {
|
||||
const user = selectSuperuser.get(SUPERUSER_GROUP_NAME);
|
||||
if (!user) return null;
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
-- Stackpulse database schema blueprint
|
||||
-- This document captures the expected structure of all tables, indexes and core seed requirements.
|
||||
|
||||
CREATE TABLE event_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
category TEXT NOT NULL,
|
||||
event_type TEXT,
|
||||
action TEXT,
|
||||
status TEXT,
|
||||
severity TEXT,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
entity_name TEXT,
|
||||
actor_type TEXT,
|
||||
actor_id TEXT,
|
||||
actor_name TEXT,
|
||||
source TEXT,
|
||||
context_type TEXT,
|
||||
context_id TEXT,
|
||||
context_label TEXT,
|
||||
message TEXT,
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE INDEX idx_event_logs_timestamp ON event_logs (timestamp DESC);
|
||||
CREATE INDEX idx_event_logs_category ON event_logs (category);
|
||||
CREATE INDEX idx_event_logs_event_type ON event_logs (event_type);
|
||||
CREATE INDEX idx_event_logs_entity ON event_logs (entity_type, entity_id);
|
||||
CREATE INDEX idx_event_logs_context ON event_logs (context_type, context_id);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_salt TEXT,
|
||||
avatar_color TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login DATETIME,
|
||||
security_phrase_content TEXT,
|
||||
security_phrase_iv TEXT,
|
||||
security_phrase_tag TEXT,
|
||||
security_phrase_downloaded_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
avatar_color TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_group_memberships (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_group_permissions (
|
||||
group_id INTEGER NOT NULL,
|
||||
permission_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (group_id, permission_id),
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE permission_sections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
has_navigation_flag INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE permission_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
section_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(section_id, key),
|
||||
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE permission_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
section_id INTEGER NOT NULL,
|
||||
group_id INTEGER,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
default_level TEXT NOT NULL,
|
||||
available_levels TEXT NOT NULL,
|
||||
is_required INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE permission_dependencies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
permission_id INTEGER NOT NULL,
|
||||
depends_on_permission_id INTEGER NOT NULL,
|
||||
required_level TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (depends_on_permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||
UNIQUE(permission_id, depends_on_permission_id)
|
||||
);
|
||||
CREATE INDEX idx_permission_dependencies_required
|
||||
ON permission_dependencies (permission_id, depends_on_permission_id);
|
||||
|
||||
CREATE TABLE group_permission_values (
|
||||
group_id INTEGER NOT NULL,
|
||||
permission_id INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
effective_level TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (group_id, permission_id),
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_server_permission_overrides (
|
||||
user_id INTEGER NOT NULL,
|
||||
server_id INTEGER NOT NULL,
|
||||
permission_id INTEGER NOT NULL,
|
||||
change_type TEXT NOT NULL CHECK (change_type IN ('ADD', 'REMOVE')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, server_id, permission_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_user_server_permission_overrides_user_server
|
||||
ON user_server_permission_overrides (user_id, server_id);
|
||||
|
||||
CREATE TABLE server_api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE,
|
||||
key_cipher TEXT NOT NULL,
|
||||
key_iv TEXT NOT NULL,
|
||||
key_tag TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE user_settings (
|
||||
user_id INTEGER NOT NULL,
|
||||
setting_key TEXT NOT NULL,
|
||||
setting_value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, setting_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Core seed expectations (keys only – maintained via schemaEnsure)
|
||||
-- permission_sections: stacks, logs, users, user-groups, maintenance
|
||||
-- permission_items: includes users-security-phrase (available_levels ["full","none"])
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ensureDatabaseSchema } from './schemaEnsure.js';
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
ensureDatabaseSchema();
|
||||
console.log('✅ Datenbankschema abgeglichen.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
@@ -1,31 +0,0 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
const createRedeployLogsTable = `
|
||||
CREATE TABLE IF NOT EXISTS redeploy_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
stack_id TEXT NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
endpoint INTEGER,
|
||||
redeploy_type TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
db.exec(createRedeployLogsTable);
|
||||
|
||||
try {
|
||||
const columns = db.prepare('PRAGMA table_info(redeploy_logs)').all();
|
||||
const hasRedeployType = columns.some((column) => column.name === 'redeploy_type');
|
||||
if (!hasRedeployType) {
|
||||
db.exec('ALTER TABLE redeploy_logs ADD COLUMN redeploy_type TEXT');
|
||||
console.log('ℹ️ redeploy_type column hinzugefügt');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('⚠️ Konnte redeploy_type Spalte nicht prüfen/erstellen:', err.message);
|
||||
}
|
||||
|
||||
console.log('✅ redeploy_logs table ready');
|
||||
|
||||
db.close();
|
||||
@@ -1,181 +0,0 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
const valueToArray = (value) => {
|
||||
if (!value && value !== 0) return [];
|
||||
const base = Array.isArray(value) ? value : [value];
|
||||
return base
|
||||
.flatMap((entry) => String(entry).split(','))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const singleValue = (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
};
|
||||
|
||||
export function buildLogFilter(queryParams = {}) {
|
||||
const filters = [];
|
||||
const params = {};
|
||||
|
||||
const ids = valueToArray(queryParams.ids ?? queryParams.id)
|
||||
.map((entry) => Number(entry))
|
||||
.filter((value) => !Number.isNaN(value));
|
||||
if (ids.length) {
|
||||
const placeholders = ids.map((_, idx) => `@id${idx}`);
|
||||
filters.push(`id IN (${placeholders.join(', ')})`);
|
||||
ids.forEach((value, idx) => {
|
||||
params[`id${idx}`] = value;
|
||||
});
|
||||
}
|
||||
|
||||
const stackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
|
||||
if (stackIds.length) {
|
||||
const placeholders = stackIds.map((_, idx) => `@stackId${idx}`);
|
||||
filters.push(`stack_id IN (${placeholders.join(', ')})`);
|
||||
stackIds.forEach((stack, idx) => {
|
||||
params[`stackId${idx}`] = stack;
|
||||
});
|
||||
}
|
||||
|
||||
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
|
||||
if (statuses.length) {
|
||||
const placeholders = statuses.map((_, idx) => `@status${idx}`);
|
||||
filters.push(`status IN (${placeholders.join(', ')})`);
|
||||
statuses.forEach((entry, idx) => {
|
||||
params[`status${idx}`] = entry;
|
||||
});
|
||||
}
|
||||
|
||||
const endpoints = valueToArray(queryParams.endpoints ?? queryParams.endpoint);
|
||||
if (endpoints.length) {
|
||||
const placeholders = endpoints.map((_, idx) => `@endpoint${idx}`);
|
||||
filters.push(`endpoint IN (${placeholders.join(', ')})`);
|
||||
endpoints.forEach((entry, idx) => {
|
||||
const numeric = Number(entry);
|
||||
params[`endpoint${idx}`] = Number.isNaN(numeric) ? entry : numeric;
|
||||
});
|
||||
}
|
||||
|
||||
const redeployTypes = valueToArray(queryParams.redeployTypes ?? queryParams.redeployType);
|
||||
if (redeployTypes.length) {
|
||||
const placeholders = redeployTypes.map((_, idx) => `@redeployType${idx}`);
|
||||
filters.push(`redeploy_type IN (${placeholders.join(', ')})`);
|
||||
redeployTypes.forEach((entry, idx) => {
|
||||
params[`redeployType${idx}`] = entry;
|
||||
});
|
||||
}
|
||||
|
||||
const messageQuery = singleValue(queryParams.message);
|
||||
if (messageQuery && String(messageQuery).trim()) {
|
||||
filters.push('message LIKE @message');
|
||||
params.message = `%${String(messageQuery).trim()}%`;
|
||||
}
|
||||
|
||||
const from = singleValue(queryParams.from);
|
||||
if (from) {
|
||||
filters.push('timestamp >= @from');
|
||||
params.from = from;
|
||||
}
|
||||
|
||||
const to = singleValue(queryParams.to);
|
||||
if (to) {
|
||||
filters.push('timestamp <= @to');
|
||||
params.to = to;
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
const insertRedeployLogStmt = db.prepare(`
|
||||
INSERT INTO redeploy_logs (stack_id, stack_name, status, message, endpoint, redeploy_type)
|
||||
VALUES (@stackId, @stackName, @status, @message, @endpoint, @redeployType)
|
||||
`);
|
||||
|
||||
export function logRedeployEvent({ stackId, stackName, status, message = null, endpoint = null, redeployType = null }) {
|
||||
try {
|
||||
insertRedeployLogStmt.run({
|
||||
stackId: String(stackId),
|
||||
stackName: stackName ?? 'Unknown',
|
||||
status,
|
||||
message,
|
||||
endpoint,
|
||||
redeployType: redeployType ?? null
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Speichern des Redeploy-Logs:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteLogById(id) {
|
||||
const stmt = db.prepare('DELETE FROM redeploy_logs WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes || 0;
|
||||
}
|
||||
|
||||
export function deleteLogsByFilters(queryParams = {}) {
|
||||
const { whereClause, params } = buildLogFilter(queryParams);
|
||||
const stmt = db.prepare(`DELETE FROM redeploy_logs ${whereClause}`);
|
||||
const info = stmt.run(params);
|
||||
return info.changes || 0;
|
||||
}
|
||||
|
||||
export function exportLogsByFilters(queryParams = {}, format = 'txt') {
|
||||
const { whereClause, params } = buildLogFilter(queryParams);
|
||||
const rows = db.prepare(`
|
||||
SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint, redeploy_type AS redeployType
|
||||
FROM redeploy_logs
|
||||
${whereClause}
|
||||
ORDER BY datetime(timestamp) DESC
|
||||
`).all(params);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
if (format === 'sql') {
|
||||
const statements = rows.map((row) => {
|
||||
const columns = ['id', 'timestamp', 'stack_id', 'stack_name', 'status', 'message', 'endpoint', 'redeploy_type'];
|
||||
const values = [
|
||||
row.id,
|
||||
row.timestamp,
|
||||
row.stackId,
|
||||
row.stackName,
|
||||
row.status,
|
||||
row.message,
|
||||
row.endpoint,
|
||||
row.redeployType
|
||||
].map((value) => {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
});
|
||||
return `INSERT INTO redeploy_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `redeploy-logs-${timestamp}.sql`,
|
||||
contentType: 'application/sql; charset=utf-8',
|
||||
content: statements.join('\n')
|
||||
};
|
||||
}
|
||||
|
||||
const lines = rows.map((row) => {
|
||||
const parts = [
|
||||
`[${row.id}]`,
|
||||
row.timestamp,
|
||||
`Stack: ${row.stackName ?? 'Unbekannt'} (ID: ${row.stackId})`,
|
||||
`Status: ${row.status}`,
|
||||
`Endpoint: ${row.endpoint ?? '-'}`,
|
||||
`Nachricht: ${row.message ?? '-'}`,
|
||||
`Redeploy: ${row.redeployType ?? '-'}`
|
||||
];
|
||||
return parts.join(' | ');
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `redeploy-logs-${timestamp}.txt`,
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
content: lines.join('\n')
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { db } from './index.js';
|
||||
|
||||
const BLUEPRINT_FILENAME = 'dbs';
|
||||
const PERMISSION_BLUEPRINT = [
|
||||
{
|
||||
key: 'stacks',
|
||||
title: 'Stacks',
|
||||
sortOrder: 0,
|
||||
hasNavigation: 0,
|
||||
items: [
|
||||
{
|
||||
key: 'stacks-redeploy-single',
|
||||
label: 'Redeploy einzeln',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'stacks-redeploy-selection',
|
||||
label: 'Redeploy Auswahl',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'stacks-redeploy-all',
|
||||
label: 'Redeploy Alle',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
title: 'Logs',
|
||||
sortOrder: 1,
|
||||
hasNavigation: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'logs-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'logs-export',
|
||||
label: 'Logs Exportieren',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'logs-delete',
|
||||
label: 'Logs löschen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
title: 'Benutzer',
|
||||
sortOrder: 2,
|
||||
hasNavigation: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'users-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'users-edit',
|
||||
label: 'Benutzer bearbeiten',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'read',
|
||||
levels: ['full', 'read'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users-delete',
|
||||
label: 'Benutzer löschen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users-security-phrase',
|
||||
label: 'Sicherheitsschlüssel',
|
||||
sortOrder: 3,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'user-groups',
|
||||
title: 'Benutzergruppen',
|
||||
sortOrder: 3,
|
||||
hasNavigation: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'user-groups-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'user-groups-edit',
|
||||
label: 'Benutzergruppen bearbeiten',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'read',
|
||||
levels: ['full', 'read'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'user-groups-delete',
|
||||
label: 'Benutzergruppen löschen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance',
|
||||
title: 'Wartung',
|
||||
sortOrder: 4,
|
||||
hasNavigation: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
}
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
key: 'maintenance-server-group',
|
||||
title: 'Server',
|
||||
sortOrder: 0,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-server-manage',
|
||||
label: 'Server-Sektion',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-server-delete',
|
||||
label: 'Server löschen',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-superuser-group',
|
||||
title: 'Superuser',
|
||||
sortOrder: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-superuser-delete',
|
||||
label: 'Superuser löschen',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-portainer-group',
|
||||
title: 'Portainer',
|
||||
sortOrder: 2,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-portainer',
|
||||
label: 'Portainer-Sektion',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-ssh-update',
|
||||
label: 'SSH/Update-Skript',
|
||||
sortOrder: 1,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-update',
|
||||
label: 'Update durchführen',
|
||||
sortOrder: 2,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-duplicates-group',
|
||||
title: 'Doppelte Stacks',
|
||||
sortOrder: 3,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-duplicates',
|
||||
label: 'Doppelte Stacks',
|
||||
sortOrder: 0,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const TABLE_REGEX = /^CREATE TABLE\s+([^\s(]+)\s*\(/i;
|
||||
const INDEX_REGEX = /^CREATE INDEX\s+([^\s(]+)\s+ON\s+([^\s(]+)\s*\(/i;
|
||||
const COLUMN_SKIP_PREFIXES = ['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK', 'CONSTRAINT'];
|
||||
|
||||
const normalizeIdentifier = (value) => value.replace(/`|"/g, '').trim();
|
||||
|
||||
const loadBlueprintStatements = () => {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const filePath = path.join(__dirname, BLUEPRINT_FILENAME);
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const cleanContent = content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('--'))
|
||||
.join('\n');
|
||||
|
||||
return cleanContent
|
||||
.split(';')
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const parseTableDefinitions = (statements) => {
|
||||
const tables = new Map();
|
||||
|
||||
statements.forEach((statement) => {
|
||||
const match = statement.match(TABLE_REGEX);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableName = normalizeIdentifier(match[1]);
|
||||
const body = statement.slice(statement.indexOf('(') + 1, statement.lastIndexOf(')'));
|
||||
const lines = body
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const columns = [];
|
||||
lines.forEach((line) => {
|
||||
const cleaned = line.replace(/,$/, '');
|
||||
const upper = cleaned.toUpperCase();
|
||||
if (COLUMN_SKIP_PREFIXES.some((prefix) => upper.startsWith(prefix))) {
|
||||
return;
|
||||
}
|
||||
const [rawName, ...restParts] = cleaned.split(/\s+/);
|
||||
if (!rawName || restParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
const columnName = normalizeIdentifier(rawName);
|
||||
const definition = restParts.join(' ');
|
||||
columns.push({ name: columnName, definition });
|
||||
});
|
||||
|
||||
tables.set(tableName, {
|
||||
createStatement: statement,
|
||||
columns
|
||||
});
|
||||
});
|
||||
|
||||
return tables;
|
||||
};
|
||||
|
||||
const parseIndexStatements = (statements) => {
|
||||
const indexes = [];
|
||||
statements.forEach((statement) => {
|
||||
const match = statement.match(INDEX_REGEX);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
const indexName = normalizeIdentifier(match[1]);
|
||||
indexes.push({
|
||||
name: indexName,
|
||||
statement
|
||||
});
|
||||
});
|
||||
return indexes;
|
||||
};
|
||||
|
||||
const tableExists = (tableName) => {
|
||||
const result = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`)
|
||||
.get(tableName);
|
||||
return Boolean(result);
|
||||
};
|
||||
|
||||
const dropDeprecatedArtifacts = () => {
|
||||
const deprecatedTables = ['user_endpoint_permission_overrides', 'endpoints'];
|
||||
deprecatedTables.forEach((table) => {
|
||||
if (tableExists(table)) {
|
||||
db.exec(`DROP TABLE IF EXISTS ${table}`);
|
||||
console.log(`ℹ️ Veraltete Tabelle ${table} entfernt`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getExistingColumns = (tableName) => {
|
||||
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
return new Set(pragma.map((column) => column.name));
|
||||
};
|
||||
|
||||
const indexExists = (indexName) => {
|
||||
const result = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1`)
|
||||
.get(indexName);
|
||||
return Boolean(result);
|
||||
};
|
||||
|
||||
const ensureTablesAndColumns = (tables) => {
|
||||
tables.forEach(({ createStatement, columns }, tableName) => {
|
||||
if (!tableExists(tableName)) {
|
||||
const statementWithGuard = createStatement.replace(/^CREATE TABLE/i, 'CREATE TABLE IF NOT EXISTS');
|
||||
db.exec(`${statementWithGuard};`);
|
||||
console.log(`ℹ️ Tabelle ${tableName} angelegt`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingColumns = getExistingColumns(tableName);
|
||||
const missingColumns = columns.filter((column) => !existingColumns.has(column.name));
|
||||
missingColumns.forEach((column) => {
|
||||
try {
|
||||
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.definition};`);
|
||||
console.log(`ℹ️ Column ${column.name} added to ${tableName}`);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Column ${column.name} could not be added to ${tableName}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const ensureIndexes = (indexes) => {
|
||||
indexes.forEach(({ name, statement }) => {
|
||||
if (indexExists(name)) {
|
||||
return;
|
||||
}
|
||||
const statementWithGuard = statement.replace(/^CREATE INDEX/i, 'CREATE INDEX IF NOT EXISTS');
|
||||
db.exec(`${statementWithGuard};`);
|
||||
console.log(`ℹ️ Index ${name} angelegt`);
|
||||
});
|
||||
};
|
||||
|
||||
const ensurePermissionSeeds = () => {
|
||||
const selectSection = db.prepare(`
|
||||
SELECT id, title, sort_order, has_navigation_flag
|
||||
FROM permission_sections
|
||||
WHERE key = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const insertSection = db.prepare(`
|
||||
INSERT INTO permission_sections (key, title, sort_order, has_navigation_flag, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
const updateSection = db.prepare(`
|
||||
UPDATE permission_sections
|
||||
SET title = ?, sort_order = ?, has_navigation_flag = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectGroup = db.prepare(`
|
||||
SELECT id, title, sort_order
|
||||
FROM permission_groups
|
||||
WHERE section_id = ? AND key = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const insertGroup = db.prepare(`
|
||||
INSERT INTO permission_groups (section_id, key, title, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
const updateGroup = db.prepare(`
|
||||
UPDATE permission_groups
|
||||
SET title = ?, sort_order = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectItem = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
section_id,
|
||||
group_id,
|
||||
label,
|
||||
sort_order,
|
||||
default_level,
|
||||
available_levels,
|
||||
is_required
|
||||
FROM permission_items
|
||||
WHERE key = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const insertItem = db.prepare(`
|
||||
INSERT INTO permission_items (
|
||||
section_id,
|
||||
group_id,
|
||||
key,
|
||||
label,
|
||||
sort_order,
|
||||
default_level,
|
||||
available_levels,
|
||||
is_required,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
const updateItem = db.prepare(`
|
||||
UPDATE permission_items
|
||||
SET
|
||||
section_id = ?,
|
||||
group_id = ?,
|
||||
label = ?,
|
||||
sort_order = ?,
|
||||
default_level = ?,
|
||||
available_levels = ?,
|
||||
is_required = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectDependency = db.prepare(`
|
||||
SELECT id, required_level
|
||||
FROM permission_dependencies
|
||||
WHERE permission_id = ? AND depends_on_permission_id = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const insertDependency = db.prepare(`
|
||||
INSERT INTO permission_dependencies (
|
||||
permission_id,
|
||||
depends_on_permission_id,
|
||||
required_level,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
const updateDependency = db.prepare(`
|
||||
UPDATE permission_dependencies
|
||||
SET required_level = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const itemKeyToId = new Map();
|
||||
|
||||
PERMISSION_BLUEPRINT.forEach((section, sectionIndex) => {
|
||||
const existingSection = selectSection.get(section.key);
|
||||
let sectionId;
|
||||
if (!existingSection) {
|
||||
const result = insertSection.run(
|
||||
section.key,
|
||||
section.title,
|
||||
section.sortOrder ?? sectionIndex,
|
||||
section.hasNavigation ? 1 : 0
|
||||
);
|
||||
sectionId = Number(result.lastInsertRowid);
|
||||
console.log(`ℹ️ Berechtigungs-Sektion ${section.key} angelegt`);
|
||||
} else {
|
||||
updateSection.run(
|
||||
section.title,
|
||||
section.sortOrder ?? sectionIndex,
|
||||
section.hasNavigation ? 1 : 0,
|
||||
existingSection.id
|
||||
);
|
||||
sectionId = existingSection.id;
|
||||
if (
|
||||
existingSection.title !== section.title ||
|
||||
(existingSection.sort_order ?? sectionIndex) !== (section.sortOrder ?? sectionIndex) ||
|
||||
Number(existingSection.has_navigation_flag) !== (section.hasNavigation ? 1 : 0)
|
||||
) {
|
||||
console.log(`ℹ️ Berechtigungs-Sektion ${section.key} aktualisiert`);
|
||||
}
|
||||
}
|
||||
|
||||
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||
sectionItems.forEach((item, itemIdx) => {
|
||||
const existingItem = selectItem.get(item.key);
|
||||
const sortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
|
||||
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
||||
? item.levels
|
||||
: ['full', 'read', 'none'];
|
||||
const availableLevels = JSON.stringify(levelOptions);
|
||||
const isRequired = item.isRequired ? 1 : 0;
|
||||
if (!existingItem) {
|
||||
const result = insertItem.run(
|
||||
sectionId,
|
||||
null,
|
||||
item.key,
|
||||
item.label,
|
||||
sortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isRequired
|
||||
);
|
||||
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
||||
console.log(`ℹ️ Berechtigung ${item.key} angelegt`);
|
||||
} else {
|
||||
const hasChanges =
|
||||
existingItem.section_id !== sectionId ||
|
||||
existingItem.group_id !== null ||
|
||||
existingItem.label !== item.label ||
|
||||
(existingItem.sort_order ?? sortOrder) !== sortOrder ||
|
||||
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
||||
existingItem.available_levels !== availableLevels ||
|
||||
Number(existingItem.is_required) !== isRequired;
|
||||
updateItem.run(
|
||||
sectionId,
|
||||
null,
|
||||
item.label,
|
||||
sortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isRequired,
|
||||
existingItem.id
|
||||
);
|
||||
itemKeyToId.set(item.key, existingItem.id);
|
||||
if (hasChanges) {
|
||||
console.log(`ℹ️ Berechtigung ${item.key} aktualisiert`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||
sectionGroups.forEach((group, groupIdx) => {
|
||||
const existingGroup = selectGroup.get(sectionId, group.key);
|
||||
const sortOrder = typeof group.sortOrder === 'number' ? group.sortOrder : groupIdx;
|
||||
let groupId;
|
||||
if (!existingGroup) {
|
||||
const result = insertGroup.run(sectionId, group.key, group.title, sortOrder);
|
||||
groupId = Number(result.lastInsertRowid);
|
||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} angelegt`);
|
||||
} else {
|
||||
const hasGroupChanges =
|
||||
existingGroup.title !== group.title ||
|
||||
(existingGroup.sort_order ?? sortOrder) !== sortOrder;
|
||||
updateGroup.run(group.title, sortOrder, existingGroup.id);
|
||||
groupId = existingGroup.id;
|
||||
if (hasGroupChanges) {
|
||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} aktualisiert`);
|
||||
}
|
||||
}
|
||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||
groupItems.forEach((item, itemIdx) => {
|
||||
const existingItem = selectItem.get(item.key);
|
||||
const itemSortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
|
||||
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
||||
? item.levels
|
||||
: ['full', 'read', 'none'];
|
||||
const availableLevels = JSON.stringify(levelOptions);
|
||||
const isRequired = item.isRequired ? 1 : 0;
|
||||
if (!existingItem) {
|
||||
const result = insertItem.run(
|
||||
sectionId,
|
||||
groupId,
|
||||
item.key,
|
||||
item.label,
|
||||
itemSortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isRequired
|
||||
);
|
||||
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
||||
console.log(`ℹ️ Berechtigung ${item.key} in Gruppe ${group.key} angelegt`);
|
||||
} else {
|
||||
const hasChanges =
|
||||
existingItem.section_id !== sectionId ||
|
||||
existingItem.group_id !== groupId ||
|
||||
existingItem.label !== item.label ||
|
||||
(existingItem.sort_order ?? itemSortOrder) !== itemSortOrder ||
|
||||
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
||||
existingItem.available_levels !== availableLevels ||
|
||||
Number(existingItem.is_required) !== isRequired;
|
||||
updateItem.run(
|
||||
sectionId,
|
||||
groupId,
|
||||
item.label,
|
||||
itemSortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isRequired,
|
||||
existingItem.id
|
||||
);
|
||||
itemKeyToId.set(item.key, existingItem.id);
|
||||
if (hasChanges) {
|
||||
console.log(`ℹ️ Berechtigung ${item.key} in Gruppe ${group.key} aktualisiert`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
PERMISSION_BLUEPRINT.forEach((section) => {
|
||||
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||
sectionItems.forEach((item) => {
|
||||
const permissionId = itemKeyToId.get(item.key);
|
||||
if (!permissionId) return;
|
||||
(item.dependencies || []).forEach((dependency) => {
|
||||
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
||||
if (!dependsId) return;
|
||||
const existing = selectDependency.get(permissionId, dependsId);
|
||||
const requiredLevel = dependency.requiredLevel ?? null;
|
||||
if (!existing) {
|
||||
insertDependency.run(permissionId, dependsId, requiredLevel);
|
||||
console.log(
|
||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
|
||||
);
|
||||
} else if (existing.required_level !== requiredLevel) {
|
||||
updateDependency.run(requiredLevel, existing.id);
|
||||
console.log(
|
||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||
sectionGroups.forEach((group) => {
|
||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||
groupItems.forEach((item) => {
|
||||
const permissionId = itemKeyToId.get(item.key);
|
||||
if (!permissionId) return;
|
||||
(item.dependencies || []).forEach((dependency) => {
|
||||
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
||||
if (!dependsId) return;
|
||||
const requiredLevel = dependency.requiredLevel ?? null;
|
||||
const existing = selectDependency.get(permissionId, dependsId);
|
||||
if (!existing) {
|
||||
insertDependency.run(permissionId, dependsId, requiredLevel);
|
||||
console.log(
|
||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
|
||||
);
|
||||
} else if (existing.required_level !== requiredLevel) {
|
||||
updateDependency.run(requiredLevel, existing.id);
|
||||
console.log(
|
||||
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const ensureDatabaseSchema = () => {
|
||||
try {
|
||||
const statements = loadBlueprintStatements();
|
||||
const tableDefinitions = parseTableDefinitions(statements);
|
||||
const indexDefinitions = parseIndexStatements(statements);
|
||||
|
||||
dropDeprecatedArtifacts();
|
||||
ensureTablesAndColumns(tableDefinitions);
|
||||
ensureIndexes(indexDefinitions);
|
||||
ensurePermissionSeeds();
|
||||
} catch (error) {
|
||||
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,5 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
db.prepare(CREATE_TABLE_SQL).run();
|
||||
|
||||
const getSettingStmt = db.prepare('SELECT key, value, updated_at FROM settings WHERE key = ?');
|
||||
const setSettingStmt = db.prepare(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔧 Starte Migration (idempotent)..."
|
||||
node db/migrate.js
|
||||
echo "🔧 Überprüfe Datenbankschema..."
|
||||
node db/ensure.js
|
||||
|
||||
echo "✅ Migration abgeschlossen. Starte Anwendung..."
|
||||
echo "✅ Schema-Abgleich abgeschlossen. Starte Anwendung..."
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { db } from '../db/index.js';
|
||||
import {
|
||||
normalizeAvatarColor,
|
||||
DEFAULT_AVATAR_COLOR,
|
||||
SUPERUSER_GROUP_NAME,
|
||||
pickRandomAvatarColor
|
||||
} from '../auth/superuser.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
|
||||
const selectGroupsWithMembers = db.prepare(`
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g.description,
|
||||
g.avatar_color,
|
||||
g.created_at,
|
||||
g.updated_at,
|
||||
COUNT(DISTINCT u.id) AS member_count,
|
||||
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
|
||||
FROM user_groups g
|
||||
LEFT JOIN user_group_memberships m ON m.group_id = g.id
|
||||
LEFT JOIN users u ON u.id = m.user_id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.name COLLATE NOCASE
|
||||
`);
|
||||
|
||||
const selectGroupWithMembersById = db.prepare(`
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g.description,
|
||||
g.avatar_color,
|
||||
g.created_at,
|
||||
g.updated_at,
|
||||
COUNT(DISTINCT u.id) AS member_count,
|
||||
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
|
||||
FROM user_groups g
|
||||
LEFT JOIN user_group_memberships m ON m.group_id = g.id
|
||||
LEFT JOIN users u ON u.id = m.user_id
|
||||
WHERE g.id = ?
|
||||
GROUP BY g.id
|
||||
`);
|
||||
|
||||
const selectGroupByIdBasic = db.prepare(`
|
||||
SELECT id, name, description, avatar_color
|
||||
FROM user_groups
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectGroupIdByName = db.prepare(`
|
||||
SELECT id
|
||||
FROM user_groups
|
||||
WHERE lower(name) = lower(?)
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const insertGroupStatement = db.prepare(`
|
||||
INSERT INTO user_groups (name, description, avatar_color, created_at, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const updateGroupStatement = db.prepare(`
|
||||
UPDATE user_groups
|
||||
SET name = ?,
|
||||
description = ?,
|
||||
avatar_color = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const countGroupMembersStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS member_count
|
||||
FROM user_group_memberships
|
||||
WHERE group_id = ?
|
||||
`);
|
||||
|
||||
const deleteGroupStatement = db.prepare(`
|
||||
DELETE FROM user_groups
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const parseMembers = (rawPairs) => {
|
||||
if (!rawPairs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawPairs
|
||||
.split('|||')
|
||||
.map((entry) => {
|
||||
const [idPart, usernamePart] = entry.split('::');
|
||||
const numericId = Number(idPart);
|
||||
return {
|
||||
id: Number.isFinite(numericId) ? numericId : idPart,
|
||||
username: (usernamePart || '').trim()
|
||||
};
|
||||
})
|
||||
.filter((member) => member.username);
|
||||
};
|
||||
|
||||
const sanitizeGroup = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
avatarColor: row.avatar_color || null,
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null,
|
||||
memberCount: Number(row.member_count) || 0,
|
||||
members: parseMembers(row.member_pairs)
|
||||
});
|
||||
|
||||
export function listGroups() {
|
||||
const rows = selectGroupsWithMembers.all();
|
||||
return rows.map(sanitizeGroup);
|
||||
}
|
||||
|
||||
export function getGroupById(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return null;
|
||||
}
|
||||
const row = selectGroupWithMembersById.get(numericId);
|
||||
return row ? sanitizeGroup(row) : null;
|
||||
}
|
||||
|
||||
export function createGroup({ name, description }) {
|
||||
const normalizedName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!normalizedName) {
|
||||
const error = new Error('GROUP_NAME_REQUIRED');
|
||||
error.code = 'GROUP_NAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existing = selectGroupIdByName.get(normalizedName);
|
||||
if (existing) {
|
||||
const error = new Error('GROUP_NAME_TAKEN');
|
||||
error.code = 'GROUP_NAME_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedDescription = typeof description === 'string' ? description.trim() : null;
|
||||
const avatarColor = pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
||||
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null, avatarColor);
|
||||
const groupId = Number(insertResult.lastInsertRowid);
|
||||
const row = selectGroupWithMembersById.get(groupId);
|
||||
const group = row ? sanitizeGroup(row) : null;
|
||||
|
||||
logEvent({
|
||||
category: 'benutzergruppe',
|
||||
eventType: 'gruppe-angelegt',
|
||||
action: 'anlegen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'gruppe',
|
||||
entityId: String(groupId),
|
||||
entityName: group?.name ?? normalizedName,
|
||||
message: `Benutzergruppe "${normalizedName}" angelegt`,
|
||||
metadata: {
|
||||
description: group?.description ?? normalizedDescription ?? null,
|
||||
avatarColor
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
export function updateGroupDetails(groupId, { name, description, avatarColor } = {}) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingRow = selectGroupByIdBasic.get(numericId);
|
||||
if (!existingRow) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedName = typeof name === 'string' ? name.trim() : existingRow.name || '';
|
||||
if (!normalizedName) {
|
||||
const error = new Error('GROUP_NAME_REQUIRED');
|
||||
error.code = 'GROUP_NAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nameRow = selectGroupIdByName.get(normalizedName);
|
||||
if (nameRow && Number(nameRow.id) !== numericId) {
|
||||
const error = new Error('GROUP_NAME_TAKEN');
|
||||
error.code = 'GROUP_NAME_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let normalizedDescription;
|
||||
if (description === undefined) {
|
||||
normalizedDescription = existingRow.description ?? null;
|
||||
} else if (description === null) {
|
||||
normalizedDescription = null;
|
||||
} else {
|
||||
const trimmedDescription = typeof description === 'string' ? description.trim() : '';
|
||||
normalizedDescription = trimmedDescription ? trimmedDescription : null;
|
||||
}
|
||||
|
||||
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||
|
||||
if (isSuperuserGroup) {
|
||||
if (name !== undefined) {
|
||||
const incomingName = typeof name === 'string' ? name.trim() : '';
|
||||
if (incomingName && incomingName !== existingRow.name) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
const existingDescriptionComparable = existingRow.description === null || existingRow.description === undefined
|
||||
? ''
|
||||
: String(existingRow.description).trim();
|
||||
const incomingDescriptionComparable = description === null
|
||||
? ''
|
||||
: typeof description === 'string'
|
||||
? description.trim()
|
||||
: '';
|
||||
|
||||
if (incomingDescriptionComparable !== existingDescriptionComparable) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce original values for protected fields
|
||||
normalizedDescription = existingRow.description ?? null;
|
||||
}
|
||||
|
||||
let colorToPersist = existingRow.avatar_color || null;
|
||||
if (avatarColor !== undefined) {
|
||||
const candidate = String(avatarColor || '').trim();
|
||||
if (!candidate) {
|
||||
colorToPersist = DEFAULT_AVATAR_COLOR;
|
||||
} else {
|
||||
const normalized = normalizeAvatarColor(candidate);
|
||||
if (!normalized) {
|
||||
const error = new Error('INVALID_AVATAR_COLOR');
|
||||
error.code = 'INVALID_AVATAR_COLOR';
|
||||
throw error;
|
||||
}
|
||||
colorToPersist = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
updateGroupStatement.run(
|
||||
isSuperuserGroup ? existingRow.name : normalizedName,
|
||||
normalizedDescription,
|
||||
colorToPersist,
|
||||
numericId
|
||||
);
|
||||
|
||||
const updatedGroup = getGroupById(numericId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzergruppe',
|
||||
eventType: 'gruppe-aktualisiert',
|
||||
action: 'aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'gruppe',
|
||||
entityId: String(numericId),
|
||||
entityName: updatedGroup?.name ?? normalizedName,
|
||||
message: `Benutzergruppe "${updatedGroup?.name ?? normalizedName}" aktualisiert`,
|
||||
metadata: {
|
||||
previous: {
|
||||
name: existingRow.name,
|
||||
description: existingRow.description ?? null,
|
||||
avatarColor: existingRow.avatar_color ?? null
|
||||
},
|
||||
current: updatedGroup
|
||||
? {
|
||||
name: updatedGroup.name,
|
||||
description: updatedGroup.description ?? null,
|
||||
avatarColor: updatedGroup.avatarColor ?? null,
|
||||
memberCount: updatedGroup.memberCount ?? 0
|
||||
}
|
||||
: {
|
||||
name: normalizedName,
|
||||
description: normalizedDescription ?? null,
|
||||
avatarColor: colorToPersist
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return updatedGroup;
|
||||
}
|
||||
|
||||
export function deleteGroup(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingRow = selectGroupByIdBasic.get(numericId);
|
||||
if (!existingRow) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||
if (isSuperuserGroup) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { member_count: memberCount } = countGroupMembersStatement.get(numericId);
|
||||
if (Number(memberCount) > 0) {
|
||||
const error = new Error('GROUP_HAS_MEMBERS');
|
||||
error.code = 'GROUP_HAS_MEMBERS';
|
||||
error.memberCount = Number(memberCount);
|
||||
throw error;
|
||||
}
|
||||
|
||||
deleteGroupStatement.run(numericId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzergruppe',
|
||||
eventType: 'gruppe-gelöscht',
|
||||
action: 'löschen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'gruppe',
|
||||
entityId: String(numericId),
|
||||
entityName: existingRow.name ?? `ID ${numericId}`,
|
||||
message: `Benutzergruppe "${existingRow.name ?? numericId}" gelöscht`,
|
||||
metadata: {
|
||||
description: existingRow.description ?? null,
|
||||
avatarColor: existingRow.avatar_color ?? null,
|
||||
memberCountBeforeDelete: Number(memberCount)
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
const valueToArray = (value) => {
|
||||
if (value === undefined || value === null) return [];
|
||||
const base = Array.isArray(value) ? value : [value];
|
||||
return base
|
||||
.flatMap((entry) => String(entry).split(','))
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
};
|
||||
|
||||
const singleValue = (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
};
|
||||
|
||||
const serializeJson = (value) => {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (err) {
|
||||
console.error('⚠️ Konnte JSON nicht serialisieren:', err.message);
|
||||
return JSON.stringify({ serializationError: true });
|
||||
}
|
||||
};
|
||||
|
||||
const appendInFilter = (filters, params, values, column, prefix) => {
|
||||
if (!values.length) return;
|
||||
const placeholders = values.map((_, idx) => {
|
||||
const key = `${prefix}${idx}`;
|
||||
params[key] = values[idx];
|
||||
return `@${key}`;
|
||||
});
|
||||
filters.push(`${column} IN (${placeholders.join(', ')})`);
|
||||
};
|
||||
|
||||
export function buildEventLogFilter(queryParams = {}) {
|
||||
const filters = [];
|
||||
const params = {};
|
||||
|
||||
const ids = valueToArray(queryParams.ids ?? queryParams.id)
|
||||
.map((entry) => Number(entry))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (ids.length) {
|
||||
appendInFilter(filters, params, ids, 'id', 'id');
|
||||
}
|
||||
|
||||
const categories = valueToArray(queryParams.categories ?? queryParams.category);
|
||||
appendInFilter(filters, params, categories, 'category', 'category');
|
||||
|
||||
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
|
||||
appendInFilter(filters, params, statuses, 'status', 'status');
|
||||
|
||||
const actions = valueToArray(queryParams.actions ?? queryParams.action);
|
||||
appendInFilter(filters, params, actions, 'action', 'action');
|
||||
|
||||
const eventTypes = [
|
||||
...valueToArray(queryParams.eventTypes ?? queryParams.eventType),
|
||||
...valueToArray(queryParams.redeployTypes ?? queryParams.redeployType)
|
||||
];
|
||||
if (eventTypes.length) {
|
||||
const unique = Array.from(new Set(eventTypes));
|
||||
appendInFilter(filters, params, unique, 'event_type', 'eventType');
|
||||
}
|
||||
|
||||
const severities = valueToArray(queryParams.severities ?? queryParams.severity);
|
||||
appendInFilter(filters, params, severities, 'severity', 'severity');
|
||||
|
||||
const entityTypes = valueToArray(queryParams.entityTypes ?? queryParams.entityType);
|
||||
appendInFilter(filters, params, entityTypes, 'entity_type', 'entityType');
|
||||
|
||||
const entityIds = valueToArray(queryParams.entityIds ?? queryParams.entityId);
|
||||
appendInFilter(filters, params, entityIds, 'entity_id', 'entityId');
|
||||
|
||||
const actorTypes = valueToArray(queryParams.actorTypes ?? queryParams.actorType);
|
||||
appendInFilter(filters, params, actorTypes, 'actor_type', 'actorType');
|
||||
|
||||
const actorIds = valueToArray(queryParams.actorIds ?? queryParams.actorId);
|
||||
appendInFilter(filters, params, actorIds, 'actor_id', 'actorId');
|
||||
|
||||
const sources = valueToArray(queryParams.sources ?? queryParams.source);
|
||||
appendInFilter(filters, params, sources, 'source', 'source');
|
||||
|
||||
const contextTypes = valueToArray(queryParams.contextTypes ?? queryParams.contextType);
|
||||
appendInFilter(filters, params, contextTypes, 'context_type', 'contextType');
|
||||
|
||||
const contextIds = valueToArray(queryParams.contextIds ?? queryParams.contextId);
|
||||
appendInFilter(filters, params, contextIds, 'context_id', 'contextId');
|
||||
|
||||
const legacyStackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
|
||||
if (legacyStackIds.length) {
|
||||
const placeholders = legacyStackIds.map((_, idx) => {
|
||||
const key = `legacyStackId${idx}`;
|
||||
params[key] = legacyStackIds[idx];
|
||||
return `@${key}`;
|
||||
});
|
||||
filters.push(`(entity_type = 'stack' AND entity_id IN (${placeholders.join(', ')}))`);
|
||||
}
|
||||
|
||||
const serverContexts = valueToArray(queryParams.servers ?? queryParams.server);
|
||||
if (serverContexts.length) {
|
||||
const placeholders = serverContexts.map((_, idx) => {
|
||||
const key = `serverContext${idx}`;
|
||||
params[key] = serverContexts[idx];
|
||||
return `@${key}`;
|
||||
});
|
||||
filters.push(`(context_type = 'server' AND context_id IN (${placeholders.join(', ')}))`);
|
||||
}
|
||||
|
||||
const messageQuery = singleValue(queryParams.message ?? queryParams.text);
|
||||
if (messageQuery && String(messageQuery).trim()) {
|
||||
filters.push('message LIKE @message');
|
||||
params.message = `%${String(messageQuery).trim()}%`;
|
||||
}
|
||||
|
||||
const search = singleValue(queryParams.search ?? queryParams.q);
|
||||
if (search && String(search).trim()) {
|
||||
filters.push('(message LIKE @search OR entity_name LIKE @search OR metadata LIKE @search)');
|
||||
params.search = `%${String(search).trim()}%`;
|
||||
}
|
||||
|
||||
const from = singleValue(queryParams.from ?? queryParams.start);
|
||||
if (from) {
|
||||
filters.push('timestamp >= @from');
|
||||
params.from = from;
|
||||
}
|
||||
|
||||
const to = singleValue(queryParams.to ?? queryParams.end);
|
||||
if (to) {
|
||||
filters.push('timestamp <= @to');
|
||||
params.to = to;
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
const insertEventLogStmt = db.prepare(`
|
||||
INSERT INTO event_logs (
|
||||
timestamp,
|
||||
category,
|
||||
event_type,
|
||||
action,
|
||||
status,
|
||||
severity,
|
||||
entity_type,
|
||||
entity_id,
|
||||
entity_name,
|
||||
actor_type,
|
||||
actor_id,
|
||||
actor_name,
|
||||
source,
|
||||
context_type,
|
||||
context_id,
|
||||
context_label,
|
||||
message,
|
||||
metadata
|
||||
)
|
||||
VALUES (
|
||||
COALESCE(@timestamp, CURRENT_TIMESTAMP),
|
||||
@category,
|
||||
@eventType,
|
||||
@action,
|
||||
@status,
|
||||
@severity,
|
||||
@entityType,
|
||||
@entityId,
|
||||
@entityName,
|
||||
@actorType,
|
||||
@actorId,
|
||||
@actorName,
|
||||
@source,
|
||||
@contextType,
|
||||
@contextId,
|
||||
@contextLabel,
|
||||
@message,
|
||||
@metadata
|
||||
)
|
||||
`);
|
||||
|
||||
export function logEvent(event = {}) {
|
||||
if (!event.category) {
|
||||
console.error('⚠️ logEvent wurde ohne Kategorie aufgerufen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
insertEventLogStmt.run({
|
||||
timestamp: event.timestamp ?? null,
|
||||
category: event.category,
|
||||
eventType: event.eventType ?? null,
|
||||
action: event.action ?? null,
|
||||
status: event.status ?? null,
|
||||
severity: event.severity ?? null,
|
||||
entityType: event.entityType ?? null,
|
||||
entityId: event.entityId !== undefined && event.entityId !== null ? String(event.entityId) : null,
|
||||
entityName: event.entityName ?? null,
|
||||
actorType: event.actorType ?? null,
|
||||
actorId: event.actorId !== undefined && event.actorId !== null ? String(event.actorId) : null,
|
||||
actorName: event.actorName ?? null,
|
||||
source: event.source ?? null,
|
||||
contextType: event.contextType ?? null,
|
||||
contextId: event.contextId !== undefined && event.contextId !== null ? String(event.contextId) : null,
|
||||
contextLabel: event.contextLabel ?? null,
|
||||
message: event.message ?? null,
|
||||
metadata: serializeJson(event.metadata)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Speichern des Event-Logs:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteEventLogById(id) {
|
||||
const stmt = db.prepare('DELETE FROM event_logs WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes || 0;
|
||||
}
|
||||
|
||||
export function deleteEventLogsByFilters(queryParams = {}) {
|
||||
const { whereClause, params } = buildEventLogFilter(queryParams);
|
||||
const stmt = db.prepare(`DELETE FROM event_logs ${whereClause}`);
|
||||
const info = stmt.run(params);
|
||||
return info.changes || 0;
|
||||
}
|
||||
|
||||
export function exportEventLogsByFilters(queryParams = {}, format = 'txt') {
|
||||
const { whereClause, params } = buildEventLogFilter(queryParams);
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
timestamp,
|
||||
category,
|
||||
event_type AS eventType,
|
||||
action,
|
||||
status,
|
||||
severity,
|
||||
entity_type AS entityType,
|
||||
entity_id AS entityId,
|
||||
entity_name AS entityName,
|
||||
actor_type AS actorType,
|
||||
actor_id AS actorId,
|
||||
actor_name AS actorName,
|
||||
source,
|
||||
context_type AS contextType,
|
||||
context_id AS contextId,
|
||||
context_label AS contextLabel,
|
||||
message,
|
||||
metadata
|
||||
FROM event_logs
|
||||
${whereClause}
|
||||
ORDER BY datetime(timestamp) DESC
|
||||
`).all(params);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
if (format === 'sql') {
|
||||
const columns = [
|
||||
'id',
|
||||
'timestamp',
|
||||
'category',
|
||||
'event_type',
|
||||
'action',
|
||||
'status',
|
||||
'severity',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'entity_name',
|
||||
'actor_type',
|
||||
'actor_id',
|
||||
'actor_name',
|
||||
'source',
|
||||
'context_type',
|
||||
'context_id',
|
||||
'context_label',
|
||||
'message',
|
||||
'metadata'
|
||||
];
|
||||
|
||||
const statements = rows.map((row) => {
|
||||
const values = columns.map((column) => {
|
||||
const key = {
|
||||
event_type: 'eventType',
|
||||
entity_type: 'entityType',
|
||||
entity_id: 'entityId',
|
||||
entity_name: 'entityName',
|
||||
actor_type: 'actorType',
|
||||
actor_id: 'actorId',
|
||||
actor_name: 'actorName',
|
||||
context_type: 'contextType',
|
||||
context_id: 'contextId',
|
||||
context_label: 'contextLabel'
|
||||
}[column] ?? column;
|
||||
const value = row[key];
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
});
|
||||
return `INSERT INTO event_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `event-logs-${timestamp}.sql`,
|
||||
contentType: 'application/sql; charset=utf-8',
|
||||
content: statements.join('\n')
|
||||
};
|
||||
}
|
||||
|
||||
const lines = rows.map((row) => {
|
||||
const parts = [
|
||||
`[${row.id}]`,
|
||||
row.timestamp,
|
||||
`Kategorie: ${row.category}`,
|
||||
row.eventType ? `Typ: ${row.eventType}` : null,
|
||||
row.action ? `Aktion: ${row.action}` : null,
|
||||
row.status ? `Status: ${row.status}` : null,
|
||||
row.entityName ? `Entität: ${row.entityName}${row.entityId ? ` (#${row.entityId})` : ''}` : row.entityId ? `Entität-ID: ${row.entityId}` : null,
|
||||
row.contextType ? `Kontext: ${row.contextType}${row.contextId ? ` (#${row.contextId}${row.contextLabel ? ` – ${row.contextLabel}` : ''})` : ''}` : null,
|
||||
row.message ? `Nachricht: ${row.message}` : null,
|
||||
row.metadata ? `Metadaten: ${row.metadata}` : null
|
||||
].filter(Boolean);
|
||||
return parts.join(' | ');
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `event-logs-${timestamp}.txt`,
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
content: lines.join('\n')
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
|
||||
const MAINTENANCE_KEY = 'maintenance_mode';
|
||||
|
||||
@@ -44,6 +45,19 @@ export function isMaintenanceModeActive() {
|
||||
}
|
||||
|
||||
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'Wartungsmodus',
|
||||
action: 'aktivieren',
|
||||
status: 'gestartet',
|
||||
entityType: 'system',
|
||||
entityId: 'wartung',
|
||||
entityName: 'StackPulse Wartung',
|
||||
contextType: 'System',
|
||||
contextId: 'System',
|
||||
message: 'Wartungsmodus aktiviert',
|
||||
source: 'system'
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
return persistState({
|
||||
active: true,
|
||||
@@ -54,6 +68,19 @@ export function activateMaintenanceMode({ message = null, extra = null } = {}) {
|
||||
}
|
||||
|
||||
export function deactivateMaintenanceMode({ message = null } = {}) {
|
||||
logEvent({
|
||||
category: 'wartung',
|
||||
eventType: 'wartungsmodus',
|
||||
action: 'deaktivieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'system',
|
||||
entityId: 'wartung',
|
||||
entityName: 'StackPulse Wartung',
|
||||
contextType: 'System',
|
||||
contextId: 'System',
|
||||
message: 'Wartungsmodus deaktiviert',
|
||||
source: 'system'
|
||||
});
|
||||
return persistState({
|
||||
active: false,
|
||||
message,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"dockerode": "^4.0.7",
|
||||
@@ -59,6 +60,17 @@
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
@@ -113,6 +125,26 @@
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"migrate": "node db/migrate.js"
|
||||
"migrate": "node db/ensure.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
@@ -12,6 +12,7 @@
|
||||
"dockerode": "^4.0.7",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.0",
|
||||
"socket.io": "^4.8.1"
|
||||
"socket.io": "^4.8.1",
|
||||
"@scure/bip39": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
const LEVEL_PRIORITY = {
|
||||
none: 0,
|
||||
read: 1,
|
||||
full: 2
|
||||
};
|
||||
|
||||
const selectSections = db.prepare(`
|
||||
SELECT id, key, title, sort_order, has_navigation_flag
|
||||
FROM permission_sections
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
`);
|
||||
|
||||
const selectGroups = db.prepare(`
|
||||
SELECT id, section_id, key, title, sort_order
|
||||
FROM permission_groups
|
||||
ORDER BY section_id ASC, sort_order ASC, id ASC
|
||||
`);
|
||||
|
||||
const selectItems = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
section_id,
|
||||
group_id,
|
||||
key,
|
||||
label,
|
||||
sort_order,
|
||||
default_level,
|
||||
available_levels,
|
||||
is_required
|
||||
FROM permission_items
|
||||
ORDER BY section_id ASC, COALESCE(group_id, 0) ASC, sort_order ASC, id ASC
|
||||
`);
|
||||
|
||||
const selectDependencies = db.prepare(`
|
||||
SELECT permission_id, depends_on_permission_id, required_level
|
||||
FROM permission_dependencies
|
||||
`);
|
||||
|
||||
const selectPermissionItemsForMap = db.prepare(`
|
||||
SELECT id, key, available_levels, default_level
|
||||
FROM permission_items
|
||||
`);
|
||||
|
||||
const selectGroupPermissionValues = db.prepare(`
|
||||
SELECT gp.permission_id, gp.level, gp.effective_level, pi.key
|
||||
FROM group_permission_values gp
|
||||
JOIN permission_items pi ON pi.id = gp.permission_id
|
||||
WHERE gp.group_id = ?
|
||||
`);
|
||||
|
||||
const deleteGroupPermissionValuesStmt = db.prepare(`
|
||||
DELETE FROM group_permission_values
|
||||
WHERE group_id = ?
|
||||
`);
|
||||
|
||||
const insertGroupPermissionValueStmt = db.prepare(`
|
||||
INSERT INTO group_permission_values (group_id, permission_id, level, effective_level, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const parseAvailableLevels = (raw) => {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return ['full', 'read', 'none'];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Konnte available_levels nicht parsen:', error.message);
|
||||
}
|
||||
return ['full', 'read', 'none'];
|
||||
};
|
||||
|
||||
const getLevelPriority = (level) => {
|
||||
if (!level) return 0;
|
||||
return LEVEL_PRIORITY[level] ?? 0;
|
||||
};
|
||||
|
||||
let cachedPermissionItems = null;
|
||||
|
||||
const getPermissionItems = () => {
|
||||
if (!cachedPermissionItems) {
|
||||
cachedPermissionItems = selectPermissionItemsForMap.all();
|
||||
}
|
||||
return cachedPermissionItems;
|
||||
};
|
||||
|
||||
const resetPermissionItemsCache = () => {
|
||||
cachedPermissionItems = null;
|
||||
};
|
||||
|
||||
export function getPermissionStructure() {
|
||||
const sections = selectSections.all();
|
||||
const groups = selectGroups.all();
|
||||
const items = selectItems.all();
|
||||
const dependencies = selectDependencies.all();
|
||||
|
||||
const itemIdToKey = new Map(items.map((item) => [item.id, item.key]));
|
||||
|
||||
const dependenciesByItemId = new Map();
|
||||
dependencies.forEach((dependency) => {
|
||||
const list = dependenciesByItemId.get(dependency.permission_id) || [];
|
||||
list.push(dependency);
|
||||
dependenciesByItemId.set(dependency.permission_id, list);
|
||||
});
|
||||
|
||||
const groupsBySectionId = new Map();
|
||||
groups.forEach((group) => {
|
||||
const list = groupsBySectionId.get(group.section_id) || [];
|
||||
list.push(group);
|
||||
groupsBySectionId.set(group.section_id, list);
|
||||
});
|
||||
|
||||
const itemsBySectionId = new Map();
|
||||
const itemsByGroupId = new Map();
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.group_id) {
|
||||
const list = itemsByGroupId.get(item.group_id) || [];
|
||||
list.push(item);
|
||||
itemsByGroupId.set(item.group_id, list);
|
||||
} else {
|
||||
const list = itemsBySectionId.get(item.section_id) || [];
|
||||
list.push(item);
|
||||
itemsBySectionId.set(item.section_id, list);
|
||||
}
|
||||
});
|
||||
|
||||
const formatItem = (item) => {
|
||||
const availableLevels = parseAvailableLevels(item.available_levels);
|
||||
|
||||
const dependencyEntries = dependenciesByItemId.get(item.id) || [];
|
||||
const formattedDependencies = dependencyEntries
|
||||
.map((dependency) => {
|
||||
const dependsOnKey = itemIdToKey.get(dependency.depends_on_permission_id);
|
||||
if (!dependsOnKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: dependsOnKey,
|
||||
requiredLevel: dependency.required_level || null
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
sortOrder: item.sort_order ?? 0,
|
||||
defaultLevel: item.default_level || 'none',
|
||||
availableLevels,
|
||||
isRequired: Boolean(item.is_required),
|
||||
dependencies: formattedDependencies
|
||||
};
|
||||
};
|
||||
|
||||
const formattedSections = sections.map((section) => {
|
||||
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
|
||||
const sectionGroups = (groupsBySectionId.get(section.id) || []).map((group) => ({
|
||||
key: group.key,
|
||||
title: group.title,
|
||||
sortOrder: group.sort_order ?? 0,
|
||||
items: (itemsByGroupId.get(group.id) || []).map(formatItem)
|
||||
}));
|
||||
|
||||
return {
|
||||
key: section.key,
|
||||
title: section.title,
|
||||
sortOrder: section.sort_order ?? 0,
|
||||
hasNavigation: Boolean(section.has_navigation_flag),
|
||||
items: sectionItems,
|
||||
groups: sectionGroups
|
||||
};
|
||||
});
|
||||
|
||||
return formattedSections;
|
||||
}
|
||||
|
||||
export function getPermissionValuesByGroup(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rows = selectGroupPermissionValues.all(numericId);
|
||||
const values = {};
|
||||
rows.forEach((row) => {
|
||||
if (row?.key && typeof row.level === 'string') {
|
||||
values[row.key] = row.level;
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
export function clearGroupPermissionValues(groupId) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return;
|
||||
}
|
||||
deleteGroupPermissionValuesStmt.run(numericId);
|
||||
}
|
||||
|
||||
export function saveGroupPermissionValues(groupId, values = {}) {
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
|
||||
const error = new Error('PERMISSION_INVALID_PAYLOAD');
|
||||
error.code = 'PERMISSION_INVALID_PAYLOAD';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const items = getPermissionItems();
|
||||
const itemMap = new Map();
|
||||
items.forEach((item) => {
|
||||
itemMap.set(item.key, {
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
defaultLevel: item.default_level || 'none',
|
||||
availableLevels: parseAvailableLevels(item.available_levels)
|
||||
});
|
||||
});
|
||||
|
||||
const entries = Object.entries(values);
|
||||
const normalizedEntries = [];
|
||||
|
||||
entries.forEach(([key, rawValue]) => {
|
||||
const item = itemMap.get(key);
|
||||
if (!item) {
|
||||
const error = new Error('PERMISSION_UNKNOWN_KEY');
|
||||
error.code = 'PERMISSION_UNKNOWN_KEY';
|
||||
error.permissionKey = key;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let level = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue ?? '').trim();
|
||||
if (!level) {
|
||||
level = 'none';
|
||||
}
|
||||
|
||||
if (!item.availableLevels.includes(level)) {
|
||||
const error = new Error('PERMISSION_INVALID_LEVEL');
|
||||
error.code = 'PERMISSION_INVALID_LEVEL';
|
||||
error.permissionKey = key;
|
||||
error.level = level;
|
||||
throw error;
|
||||
}
|
||||
|
||||
normalizedEntries.push({ item, level });
|
||||
});
|
||||
|
||||
const getSubmittedLevel = (permissionKey) => {
|
||||
const entry = normalizedEntries.find((entry) => entry.item.key === permissionKey);
|
||||
if (entry) {
|
||||
return entry.level;
|
||||
}
|
||||
const fallback = itemMap.get(permissionKey);
|
||||
return fallback ? fallback.defaultLevel || 'none' : 'none';
|
||||
};
|
||||
|
||||
const serverManageLevel = getSubmittedLevel('maintenance-server-manage');
|
||||
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
|
||||
|
||||
if (getLevelPriority(serverDeleteLevel) >= getLevelPriority('full') &&
|
||||
getLevelPriority(serverManageLevel) < getLevelPriority('full')) {
|
||||
const error = new Error('PERMISSION_DEPENDENCY_LEVEL');
|
||||
error.code = 'PERMISSION_DEPENDENCY_LEVEL';
|
||||
error.permissionKey = 'maintenance-server-delete';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const runSave = db.transaction(() => {
|
||||
deleteGroupPermissionValuesStmt.run(numericId);
|
||||
normalizedEntries.forEach(({ item, level }) => {
|
||||
if (level === (item.defaultLevel || 'none')) {
|
||||
return;
|
||||
}
|
||||
insertGroupPermissionValueStmt.run(numericId, item.id, level, level);
|
||||
});
|
||||
});
|
||||
|
||||
runSave();
|
||||
resetPermissionItemsCache();
|
||||
|
||||
return getPermissionValuesByGroup(numericId);
|
||||
}
|
||||
|
||||
export function getDefaultPermissionMap() {
|
||||
const items = getPermissionItems();
|
||||
const defaults = {};
|
||||
items.forEach((item) => {
|
||||
defaults[item.key] = item.default_level || 'none';
|
||||
});
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function getSuperuserPermissionMap() {
|
||||
const items = getPermissionItems();
|
||||
const result = {};
|
||||
|
||||
items.forEach((item) => {
|
||||
const available = parseAvailableLevels(item.available_levels);
|
||||
let chosen = 'full';
|
||||
if (!available.includes('full')) {
|
||||
chosen = available.reduce(
|
||||
(best, candidate) =>
|
||||
getLevelPriority(candidate) > getLevelPriority(best) ? candidate : best,
|
||||
'none'
|
||||
);
|
||||
}
|
||||
result[item.key] = chosen;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getEffectivePermissionsForGroup(groupId) {
|
||||
const defaults = getDefaultPermissionMap();
|
||||
const numericId = Number(groupId);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const overrides = getPermissionValuesByGroup(numericId);
|
||||
const map = { ...defaults };
|
||||
|
||||
Object.keys(overrides).forEach((key) => {
|
||||
const value = overrides[key];
|
||||
if (typeof value === 'string') {
|
||||
map[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getEffectivePermissionsForGroups(groupIds = []) {
|
||||
const defaults = getDefaultPermissionMap();
|
||||
const finalMap = { ...defaults };
|
||||
|
||||
const iterableIds = Array.isArray(groupIds) ? groupIds : [];
|
||||
iterableIds
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.forEach((groupId) => {
|
||||
const map = getEffectivePermissionsForGroup(groupId);
|
||||
Object.entries(map).forEach(([key, level]) => {
|
||||
if (getLevelPriority(level) > getLevelPriority(finalMap[key] ?? 'none')) {
|
||||
finalMap[key] = level;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return finalMap;
|
||||
}
|
||||
|
||||
export function hasRequiredPermission(permissions = {}, permissionKey, requiredLevel = 'full') {
|
||||
if (!permissionKey) {
|
||||
return true;
|
||||
}
|
||||
if (requiredLevel === 'none' || requiredLevel === null || requiredLevel === undefined) {
|
||||
return true;
|
||||
}
|
||||
const current = typeof permissions[permissionKey] === 'string' ? permissions[permissionKey] : 'none';
|
||||
const required = typeof requiredLevel === 'string' ? requiredLevel : 'full';
|
||||
return getLevelPriority(current) >= getLevelPriority(required);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 156 KiB |
@@ -1,12 +1,41 @@
|
||||
<!--
|
||||
=========================================================
|
||||
* Material Tailwind Dashboard React - v2.1.0
|
||||
=========================================================
|
||||
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
|
||||
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
|
||||
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
|
||||
* Coded by Creative Tim
|
||||
=========================================================
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/img/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StackPulse</title>
|
||||
<title>Material Tailwind Dashboard React | By Creative Tim</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css"
|
||||
integrity="sha512-xh6O/CkQoPOWDdYTDqeRdPCVd1SpvCA9XXcUnZS2FmJNp1coAFzvtCN9BmamE+4aHK8yyUHUSCcJHgXloTyT2A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-a1f09468.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-0842cd3a.css">
|
||||
<!-- Nepcha Analytics (nepcha.com) -->
|
||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-71db1823.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-dad5f6fa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { hasSuperuser } from '../auth/superuser.js';
|
||||
|
||||
const selectAllServers = db.prepare('SELECT * FROM servers ORDER BY id ASC');
|
||||
const selectServerById = db.prepare('SELECT * FROM servers WHERE id = ?');
|
||||
const selectServerByUrl = db.prepare('SELECT * FROM servers WHERE url = ?');
|
||||
const insertServer = db.prepare(`
|
||||
INSERT INTO servers (name, url)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
const updateServer = db.prepare(`
|
||||
UPDATE servers
|
||||
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
|
||||
|
||||
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
|
||||
const countApiKeysStmt = db.prepare('SELECT COUNT(*) as count FROM server_api_keys');
|
||||
const selectAllApiKeys = db.prepare('SELECT server_id, created_at, updated_at FROM server_api_keys');
|
||||
const upsertApiKey = db.prepare(`
|
||||
INSERT INTO server_api_keys (server_id, key_cipher, key_iv, key_tag, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(server_id) DO UPDATE SET
|
||||
key_cipher = excluded.key_cipher,
|
||||
key_iv = excluded.key_iv,
|
||||
key_tag = excluded.key_tag,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`);
|
||||
const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE server_id = ?');
|
||||
|
||||
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
|
||||
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
|
||||
|
||||
const API_KEY_SECRET = crypto.createHash('sha256')
|
||||
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
|
||||
.digest();
|
||||
|
||||
function encryptApiKey(apiKey) {
|
||||
if (!apiKey || typeof apiKey !== 'string') return null;
|
||||
const trimmed = apiKey.trim();
|
||||
if (!trimmed) return null;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(trimmed, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
iv: iv.toString('base64'),
|
||||
content: encrypted.toString('base64'),
|
||||
tag: tag.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
function decryptApiKey(row) {
|
||||
if (!row) return '';
|
||||
try {
|
||||
const iv = Buffer.from(row.key_iv, 'base64');
|
||||
const content = Buffer.from(row.key_cipher, 'base64');
|
||||
const tag = Buffer.from(row.key_tag, 'base64');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [Setup] API-Key konnte nicht entschlüsselt werden:', error.message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
||||
const candidate = hasProtocol ? trimmed : `https://${trimmed}`;
|
||||
const normalized = new URL(candidate);
|
||||
const pathname = normalized.pathname.replace(/\/+$/, '');
|
||||
normalized.pathname = pathname || '/';
|
||||
normalized.hash = '';
|
||||
return normalized.toString().replace(/\/$/, '');
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function deriveServerName(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname || url;
|
||||
} catch (err) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServer({ name, url }) {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
const error = new Error('SERVER_URL_REQUIRED');
|
||||
error.code = 'SERVER_URL_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const normalizedName = (name || deriveServerName(normalizedUrl)).trim();
|
||||
if (!normalizedName) {
|
||||
const error = new Error('SERVER_NAME_REQUIRED');
|
||||
error.code = 'SERVER_NAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existing = selectServerByUrl.get(normalizedUrl);
|
||||
if (existing) {
|
||||
if (existing.name !== normalizedName) {
|
||||
updateServer.run(normalizedName, normalizedUrl, existing.id);
|
||||
const updated = selectServerById.get(existing.id);
|
||||
console.log(`ℹ️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
|
||||
return updated;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const result = insertServer.run(normalizedName, normalizedUrl);
|
||||
const created = selectServerById.get(result.lastInsertRowid);
|
||||
console.log(`✅ [Setup] Server angelegt: ${created.name} (${created.id})`);
|
||||
return created;
|
||||
}
|
||||
|
||||
function setServerApiKey({ serverId, apiKey }) {
|
||||
const id = Number(serverId);
|
||||
if (!Number.isFinite(id)) {
|
||||
const error = new Error('SERVER_ID_INVALID');
|
||||
error.code = 'SERVER_ID_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const server = selectServerById.get(id);
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedKey = typeof apiKey === 'string' ? apiKey.trim() : '';
|
||||
if (!normalizedKey) {
|
||||
const error = new Error('API_KEY_REQUIRED');
|
||||
error.code = 'API_KEY_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const encrypted = encryptApiKey(normalizedKey);
|
||||
if (!encrypted) {
|
||||
const error = new Error('API_KEY_ENCRYPT_FAILED');
|
||||
error.code = 'API_KEY_ENCRYPT_FAILED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existing = selectApiKeyByServerId.get(id);
|
||||
upsertApiKey.run(id, encrypted.content, encrypted.iv, encrypted.tag);
|
||||
console.log(`${existing ? 'ℹ️' : '🔐'} [Setup] API-Key ${existing ? 'aktualisiert' : 'angelegt'}: Server ${server.name} (${server.id})`);
|
||||
|
||||
return {
|
||||
serverId: server.id,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveApiKey() {
|
||||
const firstServer = selectFirstServerStmt.get();
|
||||
if (firstServer) {
|
||||
const row = selectApiKeyByServerId.get(firstServer.id);
|
||||
const key = decryptApiKey(row);
|
||||
if (key) return key;
|
||||
}
|
||||
|
||||
const envKey = process.env.PORTAINER_API_KEY ? process.env.PORTAINER_API_KEY.trim() : '';
|
||||
return envKey || '';
|
||||
}
|
||||
|
||||
function getActiveServerUrl() {
|
||||
const firstServer = selectFirstServerStmt.get();
|
||||
if (firstServer?.url) {
|
||||
const normalizedUrl = normalizeUrl(firstServer.url);
|
||||
if (normalizedUrl) {
|
||||
return normalizedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const envUrl = process.env.PORTAINER_URL ? process.env.PORTAINER_URL.trim() : '';
|
||||
const normalizedEnvUrl = envUrl ? normalizeUrl(envUrl) : '';
|
||||
return normalizedEnvUrl || '';
|
||||
}
|
||||
|
||||
function hasServer() {
|
||||
const { count } = countServersStmt.get();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function removeServer(serverId) {
|
||||
const id = Number(serverId);
|
||||
if (!Number.isFinite(id)) {
|
||||
const error = new Error('SERVER_ID_INVALID');
|
||||
error.code = 'SERVER_ID_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const server = selectServerById.get(id);
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingApiKey = selectApiKeyByServerId.get(id);
|
||||
|
||||
deleteServerStmt.run(id);
|
||||
|
||||
if (existingApiKey) {
|
||||
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
|
||||
}
|
||||
|
||||
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id})`);
|
||||
|
||||
return {
|
||||
server,
|
||||
removed: true
|
||||
};
|
||||
}
|
||||
|
||||
function hasApiKey() {
|
||||
const { count } = countApiKeysStmt.get();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function hasCompleteSetup() {
|
||||
return hasSuperuser() && hasServer() && hasApiKey();
|
||||
}
|
||||
|
||||
function ensureDefaultsFromEnv() {
|
||||
const envServerUrlRaw = process.env.PORTAINER_URL;
|
||||
const envServerName = process.env.PORTAINER_SERVER_NAME;
|
||||
let server = null;
|
||||
if (envServerUrlRaw) {
|
||||
try {
|
||||
server = ensureServer({ name: envServerName, url: envServerUrlRaw });
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Konnte Server aus Umgebungsvariablen nicht anlegen:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const envApiKeyRaw = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||
if (envApiKeyRaw.trim()) {
|
||||
const targetServer = server || selectFirstServerStmt.get();
|
||||
if (targetServer) {
|
||||
try {
|
||||
setServerApiKey({ serverId: targetServer.id, apiKey: envApiKeyRaw.trim() });
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Konnte API-Key aus Umgebungsvariablen nicht speichern:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSetupStatus() {
|
||||
const servers = selectAllServers.all();
|
||||
const apiKeyRecords = selectAllApiKeys.all();
|
||||
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
|
||||
|
||||
const rawEnvServerName = typeof process.env.PORTAINER_SERVER_NAME === 'string' ? process.env.PORTAINER_SERVER_NAME : '';
|
||||
const envServerName = rawEnvServerName.trim();
|
||||
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
|
||||
const envServerUrl = rawEnvServerUrl.trim();
|
||||
const rawEnvApiKey = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||
const envApiKeyTrimmed = rawEnvApiKey.trim();
|
||||
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
|
||||
const envSuperuserUsernameRaw = typeof process.env.SUPERUSER_USERNAME === 'string' ? process.env.SUPERUSER_USERNAME : '';
|
||||
const envSuperuserEmailRaw = typeof process.env.SUPERUSER_EMAIL === 'string' ? process.env.SUPERUSER_EMAIL : '';
|
||||
const envSuperuserPasswordRaw = typeof process.env.SUPERUSER_PASSWORD === 'string' ? process.env.SUPERUSER_PASSWORD : '';
|
||||
const envSuperuserUsername = envSuperuserUsernameRaw.trim();
|
||||
const envSuperuserEmail = envSuperuserEmailRaw.trim();
|
||||
|
||||
const serverRequired = servers.length === 0;
|
||||
|
||||
const apiKeyItems = servers.map((server) => {
|
||||
const keyMeta = apiKeyMap.get(server.id) || null;
|
||||
return {
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
hasKey: Boolean(keyMeta),
|
||||
updatedAt: keyMeta?.updated_at ?? null
|
||||
};
|
||||
});
|
||||
const apiKeyCount = apiKeyItems.filter((item) => item.hasKey).length;
|
||||
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
|
||||
|
||||
const superuserExists = hasSuperuser();
|
||||
const setupComplete = superuserExists && !serverRequired && !apiKeyRequired;
|
||||
|
||||
return {
|
||||
superuser: {
|
||||
exists: superuserExists,
|
||||
envProvided: Boolean(envSuperuserUsername || envSuperuserEmail || envSuperuserPasswordRaw),
|
||||
env: {
|
||||
username: envSuperuserUsernameRaw,
|
||||
email: envSuperuserEmailRaw,
|
||||
password: envSuperuserPasswordRaw
|
||||
}
|
||||
},
|
||||
servers: {
|
||||
count: servers.length,
|
||||
items: servers,
|
||||
requireInput: serverRequired,
|
||||
envProvided: Boolean(envServerUrl)
|
||||
},
|
||||
apiKeys: {
|
||||
count: apiKeyCount,
|
||||
items: apiKeyItems,
|
||||
requireInput: apiKeyRequired,
|
||||
envProvided: envApiKeyProvided,
|
||||
envValue: rawEnvApiKey
|
||||
},
|
||||
requirements: {
|
||||
superuser: !superuserExists,
|
||||
server: serverRequired,
|
||||
apiKey: apiKeyRequired
|
||||
},
|
||||
setupComplete,
|
||||
envDefaults: {
|
||||
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
|
||||
serverNameFromEnv: rawEnvServerName,
|
||||
serverUrl: envServerUrl,
|
||||
apiKeyProvided: envApiKeyProvided,
|
||||
apiKeyValue: rawEnvApiKey,
|
||||
superuserUsername: envSuperuserUsernameRaw,
|
||||
superuserEmail: envSuperuserEmailRaw,
|
||||
superuserPassword: envSuperuserPasswordRaw
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function completeSetup({ server: serverInput }) {
|
||||
let serverRecord = null;
|
||||
|
||||
if (serverInput && typeof serverInput.url === 'string' && serverInput.url.trim()) {
|
||||
serverRecord = ensureServer(serverInput);
|
||||
} else {
|
||||
serverRecord = selectFirstServerStmt.get();
|
||||
}
|
||||
|
||||
if (!serverRecord) {
|
||||
const error = new Error('SERVER_DETAILS_REQUIRED');
|
||||
error.code = 'SERVER_DETAILS_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
server: serverRecord
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
ensureDefaultsFromEnv,
|
||||
ensureServer,
|
||||
setServerApiKey,
|
||||
getActiveApiKey,
|
||||
getActiveServerUrl,
|
||||
hasServer,
|
||||
hasApiKey,
|
||||
hasCompleteSetup,
|
||||
getSetupStatus,
|
||||
completeSetup,
|
||||
removeServer
|
||||
};
|
||||
@@ -0,0 +1,987 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
import {
|
||||
generateSecurityPhraseWords,
|
||||
encryptSecurityPhrase,
|
||||
decryptSecurityPhrase,
|
||||
canonicalizeWords,
|
||||
canonicalizePhraseInput
|
||||
} from './securityPhrase.js';
|
||||
import {
|
||||
hashPassword,
|
||||
normalizeAvatarColor,
|
||||
pickRandomAvatarColor,
|
||||
DEFAULT_AVATAR_COLOR,
|
||||
SUPERUSER_GROUP_NAME
|
||||
} from '../auth/superuser.js';
|
||||
|
||||
const selectUsersWithGroups = db.prepare(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.email,
|
||||
u.is_active,
|
||||
u.avatar_color,
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.security_phrase_downloaded_at,
|
||||
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||
FROM users u
|
||||
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||
LEFT JOIN user_groups g ON g.id = m.group_id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.username COLLATE NOCASE
|
||||
`);
|
||||
|
||||
const selectUserWithGroupsById = db.prepare(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.email,
|
||||
u.is_active,
|
||||
u.avatar_color,
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.security_phrase_content,
|
||||
u.security_phrase_iv,
|
||||
u.security_phrase_tag,
|
||||
u.security_phrase_downloaded_at,
|
||||
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||
FROM users u
|
||||
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||
LEFT JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE u.id = ?
|
||||
GROUP BY u.id
|
||||
`);
|
||||
|
||||
const selectUserCredentialsById = db.prepare(`
|
||||
SELECT id, username, email, password_hash, password_salt, avatar_color
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectUserByUsername = db.prepare('SELECT id FROM users WHERE username = ?');
|
||||
const selectUserByEmail = db.prepare('SELECT id FROM users WHERE email = ?');
|
||||
|
||||
const deleteMembershipsByUser = db.prepare(`
|
||||
DELETE FROM user_group_memberships
|
||||
WHERE user_id = ?
|
||||
`);
|
||||
|
||||
const insertMembershipForUser = db.prepare(`
|
||||
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
const selectSecurityPhraseByUsername = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
is_active,
|
||||
security_phrase_content AS content,
|
||||
security_phrase_iv AS iv,
|
||||
security_phrase_tag AS tag,
|
||||
security_phrase_downloaded_at AS downloaded_at
|
||||
FROM users
|
||||
WHERE lower(username) = lower(?)
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
|
||||
const selectGroupIdByName = db.prepare('SELECT id FROM user_groups WHERE lower(name) = lower(?) LIMIT 1');
|
||||
|
||||
const isUserInGroupStatement = db.prepare(`
|
||||
SELECT 1 AS has_membership
|
||||
FROM user_group_memberships m
|
||||
INNER JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE m.user_id = ? AND lower(g.name) = lower(?)
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const insertUserStatement = db.prepare(`
|
||||
INSERT INTO users (
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
password_salt,
|
||||
avatar_color,
|
||||
is_active,
|
||||
security_phrase_content,
|
||||
security_phrase_iv,
|
||||
security_phrase_tag,
|
||||
security_phrase_downloaded_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const updateUserCoreStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET username = ?,
|
||||
email = ?,
|
||||
password_hash = ?,
|
||||
password_salt = ?,
|
||||
avatar_color = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateUserActiveStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET is_active = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateUserPasswordStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET password_hash = ?,
|
||||
password_salt = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const parseGroupPairs = (rawPairs) => {
|
||||
if (!rawPairs) {
|
||||
return [];
|
||||
}
|
||||
return rawPairs
|
||||
.split('|||')
|
||||
.map((entry) => {
|
||||
const [idPart, namePart] = entry.split('::');
|
||||
const groupName = String(namePart || '').trim();
|
||||
if (!groupName) {
|
||||
return null;
|
||||
}
|
||||
const numericId = Number(idPart);
|
||||
return {
|
||||
id: Number.isFinite(numericId) ? numericId : null,
|
||||
name: groupName
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const sanitizeUserRecord = (row) => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email || null,
|
||||
isActive: Boolean(row.is_active),
|
||||
avatarColor: row.avatar_color || null,
|
||||
lastLogin: row.last_login || null,
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null,
|
||||
groups: parseGroupPairs(row.group_pairs),
|
||||
securityPhraseDownloadedAt: row.security_phrase_downloaded_at || null
|
||||
});
|
||||
|
||||
export function listUsers() {
|
||||
const rows = selectUsersWithGroups.all();
|
||||
return rows.map(sanitizeUserRecord);
|
||||
}
|
||||
|
||||
export function getUserById(userId) {
|
||||
const row = selectUserWithGroupsById.get(userId);
|
||||
return row ? sanitizeUserRecord(row) : null;
|
||||
}
|
||||
|
||||
const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
|
||||
deleteMembershipsByUser.run(userId);
|
||||
groupIds.forEach((groupId) => {
|
||||
insertMembershipForUser.run(userId, groupId);
|
||||
});
|
||||
});
|
||||
|
||||
const insertUserWithGroups = db.transaction(({
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
avatarColor,
|
||||
groupId,
|
||||
securityPhrase
|
||||
}) => {
|
||||
const phraseToPersist = securityPhrase && typeof securityPhrase === 'object'
|
||||
? {
|
||||
content: securityPhrase.content ?? null,
|
||||
iv: securityPhrase.iv ?? null,
|
||||
tag: securityPhrase.tag ?? null
|
||||
}
|
||||
: { content: null, iv: null, tag: null };
|
||||
|
||||
const result = insertUserStatement.run(
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
avatarColor,
|
||||
phraseToPersist.content,
|
||||
phraseToPersist.iv,
|
||||
phraseToPersist.tag
|
||||
);
|
||||
const userId = Number(result.lastInsertRowid);
|
||||
applyUserGroupAssignments(userId, [groupId]);
|
||||
return userId;
|
||||
});
|
||||
|
||||
const selectSecurityPhraseByUserId = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
security_phrase_content AS content,
|
||||
security_phrase_iv AS iv,
|
||||
security_phrase_tag AS tag,
|
||||
security_phrase_downloaded_at AS downloaded_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateSecurityPhraseStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET
|
||||
security_phrase_content = ?,
|
||||
security_phrase_iv = ?,
|
||||
security_phrase_tag = ?,
|
||||
security_phrase_downloaded_at = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const markSecurityPhraseDownloadedStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET security_phrase_downloaded_at = COALESCE(security_phrase_downloaded_at, CURRENT_TIMESTAMP),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const selectUsersMissingSecurityPhraseStatement = db.prepare(`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE security_phrase_content IS NULL
|
||||
OR security_phrase_iv IS NULL
|
||||
OR security_phrase_tag IS NULL
|
||||
`);
|
||||
|
||||
const resolveActorFields = (actor) => {
|
||||
if (!actor || actor.id === undefined || actor.id === null) {
|
||||
return {};
|
||||
}
|
||||
const name = actor.username || actor.email || `User ${actor.id}`;
|
||||
return {
|
||||
actorType: 'user',
|
||||
actorId: String(actor.id),
|
||||
actorName: name
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeEmail = (value) => {
|
||||
if (!value) return null;
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
};
|
||||
|
||||
export function getUserSecurityPhrase(userId, options = {}) {
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { actor = null, allowAutoGenerate = true } = options;
|
||||
|
||||
const record = selectSecurityPhraseByUserId.get(numericUserId);
|
||||
if (!record) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const words = decryptSecurityPhrase(record);
|
||||
|
||||
if (allowAutoGenerate && (!Array.isArray(words) || words.length === 0)) {
|
||||
return renewUserSecurityPhrase(numericUserId, { actor, suppressLog: true });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: numericUserId,
|
||||
username: record.username,
|
||||
words,
|
||||
downloadedAt: record.downloaded_at || null
|
||||
};
|
||||
}
|
||||
|
||||
export function renewUserSecurityPhrase(userId, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const suppressLog = Boolean(options.suppressLog);
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previousPhraseRecord = selectSecurityPhraseByUserId.get(numericUserId);
|
||||
|
||||
const words = generateSecurityPhraseWords(8);
|
||||
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
||||
|
||||
updateSecurityPhraseStatement.run(
|
||||
encryptedPhrase.content,
|
||||
encryptedPhrase.iv,
|
||||
encryptedPhrase.tag,
|
||||
numericUserId
|
||||
);
|
||||
|
||||
if (!suppressLog) {
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-sicherheitsschluessel-erneuert',
|
||||
action: 'sicherheitsschluessel-erneuern',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: `Sicherheitsschlüssel für Benutzer "${existingUser.username ?? numericUserId}" erneuert`,
|
||||
metadata: {
|
||||
previousDownloadedAt: previousPhraseRecord?.downloaded_at ?? null
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userId: numericUserId,
|
||||
username: existingUser.username,
|
||||
words,
|
||||
downloadedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
export function markSecurityPhraseDownloaded(userId, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const record = selectSecurityPhraseByUserId.get(numericUserId);
|
||||
if (!record) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
markSecurityPhraseDownloadedStatement.run(numericUserId);
|
||||
|
||||
const updatedRecord = selectSecurityPhraseByUserId.get(numericUserId) || record;
|
||||
const downloadedAt = updatedRecord?.downloaded_at || null;
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-sicherheitsschluessel-heruntergeladen',
|
||||
action: 'sicherheitsschluessel-herunterladen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: record.username ?? `ID ${numericUserId}`,
|
||||
message: `Sicherheitsschlüssel für Benutzer "${record.username ?? numericUserId}" heruntergeladen`,
|
||||
metadata: {
|
||||
downloadedAt
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return {
|
||||
userId: numericUserId,
|
||||
username: record.username,
|
||||
downloadedAt
|
||||
};
|
||||
}
|
||||
|
||||
export function verifySecurityPhraseForUsername(username, phraseInput) {
|
||||
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||
if (!normalizedUsername) {
|
||||
const error = new Error('USERNAME_REQUIRED');
|
||||
error.code = 'USERNAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const record = selectSecurityPhraseByUsername.get(normalizedUsername);
|
||||
if (!record) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const candidateCanonical = canonicalizePhraseInput(phraseInput);
|
||||
if (!candidateCanonical) {
|
||||
const error = new Error('PHRASE_REQUIRED');
|
||||
error.code = 'PHRASE_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const words = decryptSecurityPhrase(record);
|
||||
if (!Array.isArray(words) || words.length === 0) {
|
||||
const error = new Error('PHRASE_NOT_INITIALIZED');
|
||||
error.code = 'PHRASE_NOT_INITIALIZED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const storedCanonical = canonicalizeWords(words);
|
||||
if (!storedCanonical) {
|
||||
const error = new Error('PHRASE_NOT_INITIALIZED');
|
||||
error.code = 'PHRASE_NOT_INITIALIZED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (storedCanonical !== candidateCanonical) {
|
||||
const error = new Error('PHRASE_MISMATCH');
|
||||
error.code = 'PHRASE_MISMATCH';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: record.id,
|
||||
username: record.username,
|
||||
isActive: Boolean(record.is_active),
|
||||
words
|
||||
};
|
||||
}
|
||||
|
||||
export function setUserPassword(userId, newPassword, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const resetMethod = options.resetMethod || 'security-phrase';
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let passwordHash;
|
||||
let passwordSalt;
|
||||
try {
|
||||
const hashed = hashPassword(newPassword);
|
||||
passwordHash = hashed.hash;
|
||||
passwordSalt = hashed.salt;
|
||||
} catch (err) {
|
||||
if (err && err.code) {
|
||||
throw err;
|
||||
}
|
||||
const error = new Error('INVALID_PASSWORD');
|
||||
error.code = 'INVALID_PASSWORD';
|
||||
throw error;
|
||||
}
|
||||
|
||||
updateUserPasswordStatement.run(passwordHash, passwordSalt, numericUserId);
|
||||
const updated = getUserById(numericUserId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-passwort-zurueckgesetzt',
|
||||
action: 'passwort-zuruecksetzen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: `Passwort für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" zurückgesetzt`,
|
||||
metadata: {
|
||||
resetMethod
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function ensureSecurityPhrasesForExistingUsers() {
|
||||
const rows = selectUsersMissingSecurityPhraseStatement.all();
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
const initialize = db.transaction((users) => {
|
||||
users.forEach((entry) => {
|
||||
if (!entry || entry.id === undefined || entry.id === null) {
|
||||
return;
|
||||
}
|
||||
const words = generateSecurityPhraseWords(8);
|
||||
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
||||
updateSecurityPhraseStatement.run(
|
||||
encryptedPhrase.content,
|
||||
encryptedPhrase.iv,
|
||||
encryptedPhrase.tag,
|
||||
entry.id
|
||||
);
|
||||
processed += 1;
|
||||
});
|
||||
});
|
||||
|
||||
initialize(rows);
|
||||
return processed;
|
||||
}
|
||||
|
||||
export function createUser({ username, email, password, groupId, avatarColor }, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||
if (!normalizedUsername) {
|
||||
const error = new Error('USERNAME_REQUIRED');
|
||||
error.code = 'USERNAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const numericGroupId = Number(groupId);
|
||||
if (!Number.isFinite(numericGroupId) || numericGroupId <= 0) {
|
||||
const error = new Error('INVALID_GROUP_ID');
|
||||
error.code = 'INVALID_GROUP_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const groupRow = selectGroupById.get(numericGroupId);
|
||||
if (!groupRow) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if ((groupRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME) {
|
||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
if (normalizedEmail && !normalizedEmail.includes('@')) {
|
||||
const error = new Error('INVALID_EMAIL');
|
||||
error.code = 'INVALID_EMAIL';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUsername = selectUserByUsername.get(normalizedUsername);
|
||||
if (existingUsername) {
|
||||
const error = new Error('USERNAME_TAKEN');
|
||||
error.code = 'USERNAME_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (normalizedEmail) {
|
||||
const existingEmail = selectUserByEmail.get(normalizedEmail);
|
||||
if (existingEmail) {
|
||||
const error = new Error('EMAIL_TAKEN');
|
||||
error.code = 'EMAIL_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let passwordHash;
|
||||
let passwordSalt;
|
||||
try {
|
||||
const hashed = hashPassword(password);
|
||||
passwordHash = hashed.hash;
|
||||
passwordSalt = hashed.salt;
|
||||
} catch (err) {
|
||||
if (err && err.code) {
|
||||
throw err;
|
||||
}
|
||||
const error = new Error('INVALID_PASSWORD');
|
||||
error.code = 'INVALID_PASSWORD';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
|
||||
const avatarColorToPersist = normalizedAvatarColor || pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
||||
|
||||
const securityPhraseWords = generateSecurityPhraseWords(8);
|
||||
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(securityPhraseWords);
|
||||
const securityPhrasePayload = {
|
||||
content: encryptedPhrase.content,
|
||||
iv: encryptedPhrase.iv,
|
||||
tag: encryptedPhrase.tag
|
||||
};
|
||||
|
||||
const userId = insertUserWithGroups({
|
||||
username: normalizedUsername,
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
avatarColor: avatarColorToPersist,
|
||||
groupId: numericGroupId,
|
||||
securityPhrase: securityPhrasePayload
|
||||
});
|
||||
|
||||
const userRecord = getUserById(userId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-angelegt',
|
||||
action: 'anlegen',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(userRecord?.id ?? userId),
|
||||
entityName: userRecord?.username ?? normalizedUsername,
|
||||
message: `Benutzer "${normalizedUsername}" angelegt`,
|
||||
metadata: {
|
||||
email: userRecord?.email ?? normalizedEmail ?? null,
|
||||
primaryGroupId: numericGroupId,
|
||||
primaryGroupName: groupRow.name
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return userRecord;
|
||||
}
|
||||
|
||||
export function updateUserGroups(userId, groupIds, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = getUserById(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedGroupIds = Array.isArray(groupIds)
|
||||
? Array.from(
|
||||
new Set(
|
||||
groupIds
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
const missingGroupIds = [];
|
||||
const groupDetails = [];
|
||||
normalizedGroupIds.forEach((groupId) => {
|
||||
const groupRow = selectGroupById.get(groupId);
|
||||
if (!groupRow) {
|
||||
missingGroupIds.push(groupId);
|
||||
} else {
|
||||
groupDetails.push({
|
||||
id: groupId,
|
||||
name: groupRow.name
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (missingGroupIds.length > 0) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
error.missingGroupIds = missingGroupIds;
|
||||
throw error;
|
||||
}
|
||||
|
||||
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
|
||||
const updated = getUserById(numericUserId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-gruppen-aktualisiert',
|
||||
action: 'gruppe-aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: `Gruppenzuordnung für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktualisiert`,
|
||||
metadata: {
|
||||
previousGroups: (existingUser.groups || []).map((group) => ({ id: group.id, name: group.name })),
|
||||
groups: updated?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? groupDetails
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateUserDetails(userId, { username, email, password, avatarColor, groupId, groupIds } = {}, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previousUserRecord = getUserById(numericUserId);
|
||||
|
||||
const normalizedUsername = typeof username === 'string' ? username.trim() : existingUser.username;
|
||||
if (!normalizedUsername) {
|
||||
const error = new Error('USERNAME_REQUIRED');
|
||||
error.code = 'USERNAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const usernameRow = selectUserByUsername.get(normalizedUsername);
|
||||
if (usernameRow && Number(usernameRow.id) !== numericUserId) {
|
||||
const error = new Error('USERNAME_TAKEN');
|
||||
error.code = 'USERNAME_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedEmail = email === undefined ? existingUser.email : normalizeEmail(email);
|
||||
if (normalizedEmail && !normalizedEmail.includes('@')) {
|
||||
const error = new Error('INVALID_EMAIL');
|
||||
error.code = 'INVALID_EMAIL';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (normalizedEmail) {
|
||||
const emailRow = selectUserByEmail.get(normalizedEmail);
|
||||
if (emailRow && Number(emailRow.id) !== numericUserId) {
|
||||
const error = new Error('EMAIL_TAKEN');
|
||||
error.code = 'EMAIL_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let passwordHash = existingUser.password_hash;
|
||||
let passwordSalt = existingUser.password_salt;
|
||||
if (typeof password === 'string') {
|
||||
const trimmedPassword = password.trim();
|
||||
if (trimmedPassword.length > 0) {
|
||||
const hashed = hashPassword(trimmedPassword);
|
||||
passwordHash = hashed.hash;
|
||||
passwordSalt = hashed.salt;
|
||||
}
|
||||
} else if (password !== undefined && password !== null) {
|
||||
const error = new Error('INVALID_PASSWORD');
|
||||
error.code = 'INVALID_PASSWORD';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let colorToPersist = existingUser.avatar_color || DEFAULT_AVATAR_COLOR;
|
||||
if (avatarColor !== undefined) {
|
||||
const candidate = String(avatarColor || '').trim();
|
||||
if (!candidate) {
|
||||
colorToPersist = DEFAULT_AVATAR_COLOR;
|
||||
} else {
|
||||
const normalizedColor = normalizeAvatarColor(candidate);
|
||||
if (!normalizedColor) {
|
||||
const error = new Error('INVALID_AVATAR_COLOR');
|
||||
error.code = 'INVALID_AVATAR_COLOR';
|
||||
throw error;
|
||||
}
|
||||
colorToPersist = normalizedColor;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldUpdateGroups = groupId !== undefined || groupIds !== undefined;
|
||||
let normalizedGroupIds = null;
|
||||
let nextGroups = null;
|
||||
if (shouldUpdateGroups) {
|
||||
const incomingGroupIds = Array.isArray(groupIds)
|
||||
? groupIds
|
||||
: groupId !== undefined && groupId !== null
|
||||
? [groupId]
|
||||
: [];
|
||||
|
||||
normalizedGroupIds = Array.from(
|
||||
new Set(
|
||||
incomingGroupIds
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const missingGroupIds = [];
|
||||
nextGroups = [];
|
||||
normalizedGroupIds.forEach((value) => {
|
||||
const groupRow = selectGroupById.get(value);
|
||||
if (!groupRow) {
|
||||
missingGroupIds.push(value);
|
||||
} else {
|
||||
nextGroups.push({ id: value, name: groupRow.name });
|
||||
}
|
||||
});
|
||||
|
||||
if (missingGroupIds.length > 0) {
|
||||
const error = new Error('GROUP_NOT_FOUND');
|
||||
error.code = 'GROUP_NOT_FOUND';
|
||||
error.missingGroupIds = missingGroupIds;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const performUpdate = db.transaction(() => {
|
||||
updateUserCoreStatement.run(
|
||||
normalizedUsername,
|
||||
normalizedEmail,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
colorToPersist,
|
||||
numericUserId
|
||||
);
|
||||
|
||||
if (normalizedGroupIds !== null) {
|
||||
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
|
||||
}
|
||||
});
|
||||
|
||||
performUpdate();
|
||||
const updatedUser = getUserById(numericUserId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-aktualisiert',
|
||||
action: 'aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: updatedUser?.username ?? normalizedUsername,
|
||||
message: `Benutzer "${updatedUser?.username ?? normalizedUsername}" aktualisiert`,
|
||||
metadata: {
|
||||
email: updatedUser?.email ?? normalizedEmail ?? null,
|
||||
groupsUpdated: normalizedGroupIds !== null
|
||||
? (updatedUser?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? nextGroups)
|
||||
: undefined,
|
||||
previousGroups: normalizedGroupIds !== null
|
||||
? (previousUserRecord?.groups || []).map((group) => ({ id: group.id, name: group.name }))
|
||||
: undefined,
|
||||
avatarColor: updatedUser?.avatarColor ?? colorToPersist
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
const deleteMembershipsStatement = db.prepare(`
|
||||
DELETE FROM user_group_memberships
|
||||
WHERE user_id = ?
|
||||
`);
|
||||
|
||||
const deleteUserStatement = db.prepare(`
|
||||
DELETE FROM users
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
export function deleteUser(userId, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
|
||||
if (superuserMembership && superuserMembership.has_membership) {
|
||||
const error = new Error('USER_SUPERUSER_PROTECTED');
|
||||
error.code = 'USER_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const performDelete = db.transaction(() => {
|
||||
deleteMembershipsStatement.run(numericUserId);
|
||||
deleteUserStatement.run(numericUserId);
|
||||
});
|
||||
|
||||
performDelete();
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: 'benutzer-gelöscht',
|
||||
action: 'gelöscht',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: `Benutzer "${existingUser.username ?? numericUserId}" gelöscht`,
|
||||
metadata: {
|
||||
email: existingUser.email ?? null
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUserActiveStatus(userId, isActive, options = {}) {
|
||||
const actor = options.actor || null;
|
||||
const numericUserId = Number(userId);
|
||||
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||
const error = new Error('INVALID_USER_ID');
|
||||
error.code = 'INVALID_USER_ID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||
if (!existingUser) {
|
||||
const error = new Error('USER_NOT_FOUND');
|
||||
error.code = 'USER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
|
||||
if (superuserMembership && superuserMembership.has_membership && !isActive) {
|
||||
const error = new Error('USER_SUPERUSER_PROTECTED');
|
||||
error.code = 'USER_SUPERUSER_PROTECTED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedIsActive = isActive ? 1 : 0;
|
||||
updateUserActiveStatement.run(normalizedIsActive, numericUserId);
|
||||
const updated = getUserById(numericUserId);
|
||||
|
||||
logEvent({
|
||||
category: 'benutzer',
|
||||
eventType: normalizedIsActive ? 'benutzer-aktiviert' : 'benutzer-deaktiviert',
|
||||
action: 'status-aktualisieren',
|
||||
status: 'erfolgreich',
|
||||
entityType: 'benutzer',
|
||||
entityId: String(numericUserId),
|
||||
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||
message: normalizedIsActive
|
||||
? `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktiviert`
|
||||
: `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" deaktiviert`,
|
||||
metadata: {
|
||||
isActive: Boolean(normalizedIsActive)
|
||||
},
|
||||
...resolveActorFields(actor)
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import crypto from 'crypto';
|
||||
import { wordlist as englishWordlist } from '@scure/bip39/wordlists/english.js';
|
||||
|
||||
const FALLBACK_SECRET = 'stackpulse-security-phrase-secret';
|
||||
|
||||
const WORD_LIST = englishWordlist;
|
||||
|
||||
|
||||
const SECURITY_PHRASE_KEY = crypto
|
||||
.createHash('sha256')
|
||||
.update(String(process.env.SECURITY_PHRASE_SECRET || FALLBACK_SECRET))
|
||||
.digest();
|
||||
|
||||
const encryptValue = (value) => {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
iv: iv.toString('base64'),
|
||||
content: encrypted.toString('base64'),
|
||||
tag: tag.toString('base64')
|
||||
};
|
||||
};
|
||||
|
||||
const decryptValue = ({ content, iv, tag }) => {
|
||||
if (!content || !iv || !tag) {
|
||||
return '';
|
||||
}
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(content, 'base64')),
|
||||
decipher.final()
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
};
|
||||
|
||||
export const generateSecurityPhraseWords = (count = 8) => {
|
||||
const words = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = crypto.randomInt(0, WORD_LIST.length);
|
||||
words.push(WORD_LIST[index]);
|
||||
}
|
||||
return words;
|
||||
};
|
||||
|
||||
export const encodeWords = (words = []) => {
|
||||
return words
|
||||
.map((word) => {
|
||||
const index = WORD_LIST.indexOf(word);
|
||||
if (index === -1) {
|
||||
throw new Error(`SECURITY_PHRASE_WORD_UNKNOWN:${word}`);
|
||||
}
|
||||
return String(index);
|
||||
})
|
||||
.join('-');
|
||||
};
|
||||
|
||||
export const decodeWords = (encoded) => {
|
||||
if (!encoded) {
|
||||
return [];
|
||||
}
|
||||
return encoded.split('-').map((entry) => {
|
||||
const index = Number(entry);
|
||||
if (!Number.isFinite(index) || index < 0 || index >= WORD_LIST.length) {
|
||||
throw new Error('SECURITY_PHRASE_DECODE_FAILED');
|
||||
}
|
||||
return WORD_LIST[index];
|
||||
});
|
||||
};
|
||||
|
||||
export const encryptSecurityPhrase = (words) => {
|
||||
const encoded = encodeWords(words);
|
||||
const encrypted = encryptValue(encoded);
|
||||
return {
|
||||
encrypted,
|
||||
encoded
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptSecurityPhrase = (record) => {
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
const encoded = decryptValue(record);
|
||||
if (!encoded) {
|
||||
return [];
|
||||
}
|
||||
return decodeWords(encoded);
|
||||
};
|
||||
|
||||
export const formatPhraseForDisplay = (words) => {
|
||||
const rows = [];
|
||||
for (let i = 0; i < words.length; i += 4) {
|
||||
rows.push(words.slice(i, i + 4));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const normalizeWord = (value) => {
|
||||
if (!value && value !== 0) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim().toLowerCase();
|
||||
};
|
||||
|
||||
export const canonicalizeWords = (words = []) => {
|
||||
return words
|
||||
.map(normalizeWord)
|
||||
.filter((word) => word.length > 0)
|
||||
.join('');
|
||||
};
|
||||
|
||||
export const extractWordsFromString = (input) => {
|
||||
if (!input && input !== 0) {
|
||||
return [];
|
||||
}
|
||||
const normalized = String(input)
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
return normalized.split(/\s+/).filter(Boolean);
|
||||
};
|
||||
|
||||
export const canonicalizePhraseInput = (input) => {
|
||||
if (Array.isArray(input)) {
|
||||
return canonicalizeWords(input);
|
||||
}
|
||||
return canonicalizeWords(extractWordsFromString(input));
|
||||
};
|
||||
|
||||
export default WORD_LIST;
|
||||
@@ -8,12 +8,13 @@ services:
|
||||
- "4001:4001"
|
||||
volumes:
|
||||
- stackpulse_data:/app/backend/data
|
||||
environment:
|
||||
PORTAINER_URL: "Your_Portainer_Server_Address"
|
||||
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
||||
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
|
||||
SELF_STACK_ID: "Stackpulse ID"
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
|
||||
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
|
||||
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
||||
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
||||
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
|
||||
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
|
||||
volumes:
|
||||
stackpulse_data:
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
<!--
|
||||
=========================================================
|
||||
* Material Tailwind Dashboard React - v2.1.0
|
||||
=========================================================
|
||||
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
|
||||
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
|
||||
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
|
||||
* Coded by Creative Tim
|
||||
=========================================================
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/img/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StackPulse</title>
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<title>Material Tailwind Dashboard React | By Creative Tim</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css"
|
||||
integrity="sha512-xh6O/CkQoPOWDdYTDqeRdPCVd1SpvCA9XXcUnZS2FmJNp1coAFzvtCN9BmamE+4aHK8yyUHUSCcJHgXloTyT2A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
|
||||
<!-- Nepcha Analytics (nepcha.com) -->
|
||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||
<link rel="stylesheet" href="/src/tailwind.css" />
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,23 +1,34 @@
|
||||
{
|
||||
"name": "stackpulse-frontend",
|
||||
"version": "0.1.0",
|
||||
"name": "material-tailwind-dashboard-react",
|
||||
"private": true,
|
||||
"version": "2.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"axios": "1.7.5",
|
||||
"socket.io-client": "4.8.1",
|
||||
"@heroicons/react": "2.0.18",
|
||||
"@material-tailwind/react": "2.1.4",
|
||||
"apexcharts": "3.44.0",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.2.0",
|
||||
"react-apexcharts": "1.4.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^4.4.9"
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"@vitejs/plugin-react": "4.1.0",
|
||||
"autoprefixer": "10.4.16",
|
||||
"postcss": "8.4.31",
|
||||
"prettier": "3.0.3",
|
||||
"prettier-plugin-tailwindcss": "0.5.6",
|
||||
"tailwindcss": "3.3.4",
|
||||
"vite": "4.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
tailwindConfig: "./tailwind.config.cjs",
|
||||
plugins: [require("prettier-plugin-tailwindcss")],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import pkg from '@material-tailwind/react';
|
||||
const { Select, Option } = pkg;
|
||||
|
||||
const element = React.createElement(Select, {
|
||||
label: 'Test',
|
||||
value: 'b',
|
||||
selected: (el) => el?.props.children,
|
||||
onChange: () => {},
|
||||
children: [
|
||||
React.createElement(Option, { key: 'a', value: 'a' }, 'Alpha'),
|
||||
React.createElement(Option, { key: 'b', value: 'b' }, 'Beta')
|
||||
]
|
||||
});
|
||||
|
||||
console.log(renderToString(element));
|
||||
@@ -1,65 +1,16 @@
|
||||
import React from "react";
|
||||
import { NavLink, Route, Routes } from "react-router-dom";
|
||||
import Stacks from "./Stacks.jsx";
|
||||
import Logs from "./Logs.jsx";
|
||||
import Maintenance from "./Maintenance.jsx";
|
||||
import logo from "./assets/images/stackpulse.png";
|
||||
import { useMaintenance } from "./context/MaintenanceContext.jsx";
|
||||
|
||||
const navLinkBase =
|
||||
"px-4 py-2 rounded-md font-medium transition-colors duration-150";
|
||||
|
||||
const getNavClass = ({ isActive }) =>
|
||||
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
|
||||
|
||||
export default function App() {
|
||||
const { maintenance, update } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const maintenanceLabel = maintenance?.message || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv");
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Dashboard, Auth } from "@/layouts";
|
||||
import Setup from "@/pages/setup/setup";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<header className="bg-gray-800 shadow-md">
|
||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-xs text-gray-500">v0.3</span>
|
||||
{maintenanceActive && (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-300">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||
{maintenanceLabel}
|
||||
</span>
|
||||
)}
|
||||
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
|
||||
</div>
|
||||
<nav className="flex gap-2 items-end">
|
||||
<NavLink to="/" end className={getNavClass}>
|
||||
Stacks
|
||||
</NavLink>
|
||||
<NavLink to="/maintenance" className={getNavClass}>
|
||||
<span className="flex items-center gap-2">
|
||||
Wartung
|
||||
{maintenanceActive && (
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
|
||||
)}
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink to="/logs" className={getNavClass}>
|
||||
Logs
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-6xl mx-auto px-6 py-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Stacks />} />
|
||||
<Route path="/maintenance" element={<Maintenance />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="*" element={<Stacks />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||
<Route path="/auth/*" element={<Auth />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,935 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
const STATUS_COLORS = {
|
||||
success: "text-green-400",
|
||||
warning: "text-yellow-400",
|
||||
error: "text-red-400",
|
||||
started: "text-blue-300"
|
||||
};
|
||||
|
||||
const formatTimestamp = (value) => {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeDateParam = (value) => {
|
||||
if (!value) return undefined;
|
||||
return value.replace("T", " ");
|
||||
};
|
||||
|
||||
const FILTER_STORAGE_KEY = "redeployLogFilters";
|
||||
const ALL_OPTION_VALUE = "__all__";
|
||||
const ALL_OPTION_LABEL = "- Alle -";
|
||||
const PER_PAGE_DEFAULT = "50";
|
||||
const PER_PAGE_OPTIONS = [
|
||||
{ value: "10", label: "10" },
|
||||
{ value: "25", label: "25" },
|
||||
{ value: "50", label: "50" },
|
||||
{ value: "100", label: "100" },
|
||||
{ value: "all", label: "Alle" }
|
||||
];
|
||||
const VALID_PER_PAGE_VALUES = new Set(PER_PAGE_OPTIONS.map((option) => option.value));
|
||||
|
||||
const REDEPLOY_TYPE_LABELS = {
|
||||
Einzeln: "Einzeln",
|
||||
Alle: "Alle",
|
||||
Auswahl: "Auswahl",
|
||||
single: "Einzeln",
|
||||
all: "Alle",
|
||||
selection: "Auswahl"
|
||||
};
|
||||
|
||||
const hasActiveFilters = (filters) => Boolean(
|
||||
(filters.stacks && filters.stacks.length) ||
|
||||
(filters.statuses && filters.statuses.length) ||
|
||||
(filters.endpoints && filters.endpoints.length) ||
|
||||
(filters.redeployTypes && filters.redeployTypes.length) ||
|
||||
(filters.message && filters.message.trim()) ||
|
||||
(filters.from && filters.from.trim()) ||
|
||||
(filters.to && filters.to.trim())
|
||||
);
|
||||
|
||||
export default function Logs() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [totalLogs, setTotalLogs] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const [stackOptions, setStackOptions] = useState([]);
|
||||
const [statusOptions, setStatusOptions] = useState([]);
|
||||
const [endpointOptions, setEndpointOptions] = useState([]);
|
||||
const [redeployTypeOptions, setRedeployTypeOptions] = useState([]);
|
||||
|
||||
const [selectedStacks, setSelectedStacks] = useState([]);
|
||||
const [selectedStatuses, setSelectedStatuses] = useState([]);
|
||||
const [selectedEndpoints, setSelectedEndpoints] = useState([]);
|
||||
const [selectedRedeployTypes, setSelectedRedeployTypes] = useState([]);
|
||||
const [messageQuery, setMessageQuery] = useState("");
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [toDate, setToDate] = useState("");
|
||||
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filtersReady, setFiltersReady] = useState(false);
|
||||
const [optionsInitialized, setOptionsInitialized] = useState(false);
|
||||
const [refreshSignal, setRefreshSignal] = useState(0);
|
||||
|
||||
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const updateFilterOptions = useCallback((payload) => {
|
||||
const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? [];
|
||||
|
||||
const stackMap = new Map();
|
||||
const statusSet = new Set();
|
||||
const endpointSet = new Set();
|
||||
const redeployTypeSet = new Set();
|
||||
|
||||
logsPayload.forEach((log) => {
|
||||
if (log.stackId) {
|
||||
const value = String(log.stackId);
|
||||
const label = log.stackName || `Stack ${value}`;
|
||||
stackMap.set(value, label);
|
||||
}
|
||||
|
||||
if (log.status) {
|
||||
statusSet.add(log.status);
|
||||
}
|
||||
|
||||
if (log.endpoint !== null && log.endpoint !== undefined && log.endpoint !== "") {
|
||||
endpointSet.add(String(log.endpoint));
|
||||
}
|
||||
|
||||
if (log.redeployType) {
|
||||
redeployTypeSet.add(log.redeployType);
|
||||
}
|
||||
});
|
||||
|
||||
setStackOptions(Array.from(stackMap.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)));
|
||||
|
||||
setStatusOptions(Array.from(statusSet).sort());
|
||||
|
||||
setEndpointOptions(Array.from(endpointSet)
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));
|
||||
|
||||
setRedeployTypeOptions(Array.from(redeployTypeSet).sort());
|
||||
setOptionsInitialized(true);
|
||||
}, []);
|
||||
|
||||
const stackLabelMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
stackOptions.forEach((option) => {
|
||||
map.set(option.value, option.label);
|
||||
});
|
||||
return map;
|
||||
}, [stackOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!optionsInitialized) return;
|
||||
setSelectedStacks((prev) => {
|
||||
const valid = prev.filter((value) => stackOptions.some((option) => option.value === value));
|
||||
return valid.length === prev.length ? prev : valid;
|
||||
});
|
||||
}, [optionsInitialized, stackOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!optionsInitialized) return;
|
||||
setSelectedStatuses((prev) => {
|
||||
const valid = prev.filter((value) => statusOptions.includes(value));
|
||||
return valid.length === prev.length ? prev : valid;
|
||||
});
|
||||
}, [optionsInitialized, statusOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!optionsInitialized) return;
|
||||
setSelectedEndpoints((prev) => {
|
||||
const valid = prev.filter((value) => endpointOptions.includes(value));
|
||||
return valid.length === prev.length ? prev : valid;
|
||||
});
|
||||
}, [optionsInitialized, endpointOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!optionsInitialized) return;
|
||||
setSelectedRedeployTypes((prev) => {
|
||||
const valid = prev.filter((value) => redeployTypeOptions.includes(value));
|
||||
return valid.length === prev.length ? prev : valid;
|
||||
});
|
||||
}, [optionsInitialized, redeployTypeOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
setFiltersReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storedValue = window.localStorage.getItem(FILTER_STORAGE_KEY);
|
||||
if (storedValue) {
|
||||
const parsed = JSON.parse(storedValue);
|
||||
const storedFilters = parsed?.filters ?? parsed ?? {};
|
||||
const storedPagination = parsed?.pagination ?? {};
|
||||
|
||||
setSelectedStacks(storedFilters.stacks || []);
|
||||
setSelectedStatuses(storedFilters.statuses || []);
|
||||
setSelectedEndpoints(storedFilters.endpoints || []);
|
||||
setSelectedRedeployTypes(storedFilters.redeployTypes || []);
|
||||
setMessageQuery(storedFilters.message || "");
|
||||
setFromDate(storedFilters.from || "");
|
||||
setToDate(storedFilters.to || "");
|
||||
setFiltersOpen(hasActiveFilters(storedFilters));
|
||||
|
||||
const rawPerPage = storedPagination.perPage;
|
||||
if (rawPerPage !== undefined) {
|
||||
const parsedPerPage = String(rawPerPage);
|
||||
if (VALID_PER_PAGE_VALUES.has(parsedPerPage)) {
|
||||
setPerPage(parsedPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
const rawPage = storedPagination.page;
|
||||
if (typeof rawPage === 'number' && rawPage > 0) {
|
||||
setPage(rawPage);
|
||||
}
|
||||
}
|
||||
} catch (storageError) {
|
||||
console.error("❌ Fehler beim Laden der gespeicherten Log-Filter:", storageError);
|
||||
} finally {
|
||||
setFiltersReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const buildFilterParams = useCallback(() => {
|
||||
const params = {};
|
||||
|
||||
if (selectedStacks.length) {
|
||||
params.stackIds = selectedStacks.join(",");
|
||||
}
|
||||
|
||||
if (selectedStatuses.length) {
|
||||
params.statuses = selectedStatuses.join(",");
|
||||
}
|
||||
|
||||
if (selectedEndpoints.length) {
|
||||
params.endpoints = selectedEndpoints.join(",");
|
||||
}
|
||||
|
||||
if (selectedRedeployTypes.length) {
|
||||
params.redeployTypes = selectedRedeployTypes.join(",");
|
||||
}
|
||||
|
||||
if (messageQuery.trim()) {
|
||||
params.message = messageQuery.trim();
|
||||
}
|
||||
|
||||
const fromParam = normalizeDateParam(fromDate);
|
||||
if (fromParam) {
|
||||
params.from = fromParam;
|
||||
}
|
||||
|
||||
const toParam = normalizeDateParam(toDate);
|
||||
if (toParam) {
|
||||
params.to = toParam;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filtersReady) return;
|
||||
|
||||
let cancelled = false;
|
||||
axios.get("/api/logs", { params: { perPage: 'all', page: 1 } })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
updateFilterOptions(response.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.error("⚠️ Konnte Filteroptionen nicht aktualisieren:", err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filtersReady, updateFilterOptions, refreshSignal]);
|
||||
|
||||
const currentFilters = useMemo(() => ({
|
||||
stacks: selectedStacks,
|
||||
statuses: selectedStatuses,
|
||||
endpoints: selectedEndpoints,
|
||||
redeployTypes: selectedRedeployTypes,
|
||||
message: messageQuery,
|
||||
from: fromDate,
|
||||
to: toDate
|
||||
}), [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filtersReady) return;
|
||||
|
||||
const params = { ...buildFilterParams() };
|
||||
if (perPage === 'all') {
|
||||
params.perPage = 'all';
|
||||
params.page = 1;
|
||||
} else {
|
||||
params.perPage = perPage;
|
||||
params.page = page;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
axios.get("/api/logs", { params })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const data = response.data || {};
|
||||
const items = Array.isArray(data) ? data : data.items ?? [];
|
||||
const total = Array.isArray(data) ? items.length : data.total ?? items.length;
|
||||
|
||||
setLogs(items);
|
||||
setTotalLogs(total);
|
||||
|
||||
if (perPage === 'all') {
|
||||
if (page !== 1) setPage(1);
|
||||
} else {
|
||||
const totalPages = Math.max(1, Math.ceil((total || 0) / Number(perPage)));
|
||||
const nextPage = Math.min(Math.max(page, 1), totalPages);
|
||||
if (nextPage !== page) {
|
||||
setPage(nextPage);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.error("❌ Fehler beim Laden der Logs:", err);
|
||||
setError("Fehler beim Laden der Redeploy-Logs");
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal, perPage, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filtersReady) return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
FILTER_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
filters: currentFilters,
|
||||
pagination: {
|
||||
perPage,
|
||||
page
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (storageError) {
|
||||
console.error("⚠️ Konnte Filter nicht speichern:", storageError);
|
||||
}
|
||||
}, [filtersReady, currentFilters, perPage, page]);
|
||||
|
||||
const handleMultiSelectChange = (setter) => (event) => {
|
||||
const values = Array.from(event.target.selectedOptions).map((option) => option.value);
|
||||
if (values.includes(ALL_OPTION_VALUE)) {
|
||||
setter([]);
|
||||
} else {
|
||||
setter(values);
|
||||
}
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleOptionMouseDown = (event, currentValues, setter) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { value } = event.target;
|
||||
if (value === ALL_OPTION_VALUE) {
|
||||
if (currentValues.length) {
|
||||
setter([]);
|
||||
setPage(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValues = currentValues.includes(value)
|
||||
? currentValues.filter((entry) => entry !== value)
|
||||
: [...currentValues, value];
|
||||
|
||||
setter(nextValues);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setSelectedStacks([]);
|
||||
setSelectedStatuses([]);
|
||||
setSelectedEndpoints([]);
|
||||
setSelectedRedeployTypes([]);
|
||||
setMessageQuery("");
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
setFiltersOpen(false);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleToggleFilters = () => {
|
||||
setFiltersOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshSignal((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (event) => {
|
||||
const value = event.target.value;
|
||||
if (!VALID_PER_PAGE_VALUES.has(value)) return;
|
||||
setPerPage(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (nextPage) => {
|
||||
if (perPage === 'all') return;
|
||||
if (nextPage < 1) return;
|
||||
const totalPages = Math.max(1, Math.ceil((totalLogs || 0) / Number(perPage)));
|
||||
if (nextPage > totalPages) return;
|
||||
setPage(nextPage);
|
||||
};
|
||||
|
||||
const stackSelectOptions = useMemo(() => {
|
||||
const entries = stackOptions.filter((option) => option.value !== ALL_OPTION_VALUE);
|
||||
return [
|
||||
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
|
||||
...entries
|
||||
];
|
||||
}, [stackOptions]);
|
||||
|
||||
const statusSelectOptions = useMemo(() => {
|
||||
const entries = statusOptions
|
||||
.filter((status) => status !== ALL_OPTION_VALUE)
|
||||
.map((status) => ({ value: status, label: status }));
|
||||
return [
|
||||
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
|
||||
...entries
|
||||
];
|
||||
}, [statusOptions]);
|
||||
|
||||
const endpointSelectOptions = useMemo(() => {
|
||||
const entries = endpointOptions
|
||||
.filter((endpoint) => endpoint !== ALL_OPTION_VALUE)
|
||||
.map((endpoint) => ({ value: endpoint, label: endpoint }));
|
||||
return [
|
||||
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
|
||||
...entries
|
||||
];
|
||||
}, [endpointOptions]);
|
||||
|
||||
const redeployTypeSelectOptions = useMemo(() => {
|
||||
const entries = redeployTypeOptions
|
||||
.filter((type) => type !== ALL_OPTION_VALUE)
|
||||
.map((type) => ({ value: type, label: REDEPLOY_TYPE_LABELS[type] ?? type }));
|
||||
return [
|
||||
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
|
||||
...entries
|
||||
];
|
||||
}, [redeployTypeOptions]);
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (selectedStacks.length) count += selectedStacks.length;
|
||||
if (selectedStatuses.length) count += selectedStatuses.length;
|
||||
if (selectedEndpoints.length) count += selectedEndpoints.length;
|
||||
if (selectedRedeployTypes.length) count += selectedRedeployTypes.length;
|
||||
if (messageQuery.trim()) count += 1;
|
||||
if (fromDate) count += 1;
|
||||
if (toDate) count += 1;
|
||||
return count;
|
||||
}, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (perPage === 'all') return 1;
|
||||
const numeric = Number(perPage) || Number(PER_PAGE_DEFAULT);
|
||||
return Math.max(1, Math.ceil((totalLogs || 0) / numeric));
|
||||
}, [perPage, totalLogs]);
|
||||
|
||||
const rangeStart = useMemo(() => {
|
||||
if (totalLogs === 0) return 0;
|
||||
if (perPage === 'all') return 1;
|
||||
return (page - 1) * Number(perPage) + 1;
|
||||
}, [totalLogs, perPage, page]);
|
||||
|
||||
const rangeEnd = useMemo(() => {
|
||||
if (totalLogs === 0) return 0;
|
||||
if (perPage === 'all') return totalLogs;
|
||||
return Math.min(totalLogs, (page - 1) * Number(perPage) + logs.length);
|
||||
}, [totalLogs, perPage, page, logs.length]);
|
||||
|
||||
const handleDeleteLog = async (id) => {
|
||||
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
|
||||
setActionLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await axios.delete(`/api/logs/${id}`);
|
||||
setRefreshSignal((prev) => prev + 1);
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Löschen des Logs:", err);
|
||||
setError("Fehler beim Löschen des Redeploy-Logs");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFiltered = async () => {
|
||||
if (!logs.length) return;
|
||||
if (!window.confirm("Alle angezeigten Logs (entsprechend Filter) löschen?")) return;
|
||||
setActionLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = {
|
||||
...buildFilterParams(),
|
||||
ids: logs.map((log) => log.id).join(',')
|
||||
};
|
||||
await axios.delete("/api/logs", { params });
|
||||
setRefreshSignal((prev) => prev + 1);
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Löschen der gefilterten Logs:", err);
|
||||
setError("Fehler beim Löschen der gefilterten Logs");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (format) => {
|
||||
setActionLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await axios.get("/api/logs/export", {
|
||||
params: { ...buildFilterParams(), format },
|
||||
responseType: "blob"
|
||||
});
|
||||
|
||||
const contentType = response.headers["content-type"] || (format === "sql" ? "application/sql" : "text/plain");
|
||||
const disposition = response.headers["content-disposition"] || "";
|
||||
const match = disposition.match(/filename="?([^";]+)"?/i);
|
||||
const filename = match?.[1] || `redeploy-logs.${format}`;
|
||||
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Export der Logs:", err);
|
||||
setError("Fehler beim Export der Redeploy-Logs");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-4 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold text-gray-100">Redeploy-Logs</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleExport('txt')}
|
||||
disabled={actionLoading || loading}
|
||||
className="rounded-md border border-gray-600 px-3 py-1.5 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
|
||||
>
|
||||
Export TXT
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('sql')}
|
||||
disabled={actionLoading || loading}
|
||||
className="rounded-md border border-gray-600 px-3 py-1.5 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
|
||||
>
|
||||
Export SQL
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteFiltered}
|
||||
disabled={actionLoading || loading || logs.length === 0}
|
||||
className="rounded-md border border-red-600 px-3 py-1.5 text-sm text-red-300 transition hover:bg-red-600/20 disabled:opacity-60"
|
||||
>
|
||||
Angezeigte löschen
|
||||
</button>
|
||||
{loading && <span className="text-sm text-gray-400">Aktualisiere…</span>}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={actionLoading}
|
||||
className="px-4 py-2 rounded-md font-medium transition bg-purple-500 hover:bg-purple-600 disabled:opacity-60"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500/40 text-red-300 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-gray-700 bg-gray-800/60">
|
||||
<button
|
||||
onClick={handleToggleFilters}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-gray-200 hover:bg-gray-700/40"
|
||||
>
|
||||
<span>
|
||||
Filter {activeFilterCount > 0 && (
|
||||
<span className="ml-2 rounded-full bg-purple-500/20 px-2 py-0.5 text-xs text-purple-300">
|
||||
{activeFilterCount} aktiv
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{filtersOpen ? "Ausblenden" : "Anzeigen"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="space-y-4 border-t border-gray-700 px-4 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Stack</label>
|
||||
<select
|
||||
multiple
|
||||
value={selectedStacks}
|
||||
onChange={handleMultiSelectChange(setSelectedStacks)}
|
||||
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{stackSelectOptions.map(({ value, label }) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
onMouseDown={(event) => handleOptionMouseDown(event, selectedStacks, setSelectedStacks)}
|
||||
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
|
||||
{selectedStacks.length === 0 ? (
|
||||
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
|
||||
Alle Stacks
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedStacks.map((stackId) => (
|
||||
<span
|
||||
key={stackId}
|
||||
className="rounded-full bg-purple-500/20 px-2 py-0.5 text-purple-200"
|
||||
>
|
||||
{stackLabelMap.get(stackId) ?? `Stack ${stackId}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Status</label>
|
||||
<select
|
||||
multiple
|
||||
value={selectedStatuses}
|
||||
onChange={handleMultiSelectChange(setSelectedStatuses)}
|
||||
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{statusSelectOptions.map(({ value, label }) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
onMouseDown={(event) => handleOptionMouseDown(event, selectedStatuses, setSelectedStatuses)}
|
||||
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
|
||||
{selectedStatuses.length === 0 ? (
|
||||
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
|
||||
Alle Status
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedStatuses.map((status) => (
|
||||
<span
|
||||
key={status}
|
||||
className="rounded-full bg-amber-500/20 px-2 py-0.5 text-amber-200"
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Endpoint</label>
|
||||
<select
|
||||
multiple
|
||||
value={selectedEndpoints}
|
||||
onChange={handleMultiSelectChange(setSelectedEndpoints)}
|
||||
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{endpointSelectOptions.map(({ value, label }) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
onMouseDown={(event) => handleOptionMouseDown(event, selectedEndpoints, setSelectedEndpoints)}
|
||||
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
|
||||
{selectedEndpoints.length === 0 ? (
|
||||
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
|
||||
Alle Endpoints
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedEndpoints.map((endpoint) => (
|
||||
<span
|
||||
key={endpoint}
|
||||
className="rounded-full bg-blue-500/20 px-2 py-0.5 text-blue-200"
|
||||
>
|
||||
{endpoint}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Redeploy-Typ</label>
|
||||
<select
|
||||
multiple
|
||||
value={selectedRedeployTypes}
|
||||
onChange={handleMultiSelectChange(setSelectedRedeployTypes)}
|
||||
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{redeployTypeSelectOptions.map(({ value, label }) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
onMouseDown={(event) => handleOptionMouseDown(event, selectedRedeployTypes, setSelectedRedeployTypes)}
|
||||
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
|
||||
{selectedRedeployTypes.length === 0 ? (
|
||||
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
|
||||
Alle Typen
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedRedeployTypes.map((type) => (
|
||||
<span
|
||||
key={type}
|
||||
className="rounded-full bg-teal-500/20 px-2 py-0.5 text-teal-200"
|
||||
>
|
||||
{REDEPLOY_TYPE_LABELS[type] ?? type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 lg:col-span-4">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Nachricht (Freitext)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={messageQuery}
|
||||
onChange={(event) => {
|
||||
setMessageQuery(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Textsuche in Log-Nachrichten..."
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Von</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={fromDate}
|
||||
onChange={(event) => {
|
||||
setFromDate(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">Bis</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toDate}
|
||||
onChange={(event) => {
|
||||
setToDate(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={handleResetFilters}
|
||||
className="rounded-md border border-gray-600 px-4 py-2 text-gray-200 transition hover:bg-gray-700"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 text-sm text-gray-300">
|
||||
<span>Einträge pro Seite</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={handlePerPageChange}
|
||||
className="rounded-md border border-gray-700 bg-gray-900 px-2 py-1 text-sm text-gray-100 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto bg-gray-800/60 rounded-xl border border-gray-700">
|
||||
<table className="min-w-full divide-y divide-gray-700">
|
||||
<thead className="bg-gray-800">
|
||||
<tr className="text-left text-sm uppercase tracking-wide text-gray-400">
|
||||
<th className="px-4 py-3">Zeitpunkt</th>
|
||||
<th className="px-4 py-3">Stack</th>
|
||||
<th className="px-4 py-3">Art</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Nachricht</th>
|
||||
<th className="px-4 py-3">Endpoint</th>
|
||||
<th className="px-4 py-3 text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700 text-sm">
|
||||
{logs.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan="7" className="px-4 py-6 text-center text-gray-400">
|
||||
Keine Logs vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{logs.map((log) => {
|
||||
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
|
||||
const stackDisplayName = log.stackName || "Unbekannt";
|
||||
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
|
||||
const redeployTypeLabel = log.redeployType
|
||||
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
|
||||
: '---';
|
||||
return (
|
||||
<tr key={log.id} className="hover:bg-gray-700/40">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-gray-300">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-200">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{stackDisplayName}</span>
|
||||
{showStackId && (
|
||||
<span className="text-xs text-gray-400">ID: {log.stackId}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-300">
|
||||
{redeployTypeLabel}
|
||||
</td>
|
||||
<td className={`px-4 py-3 font-semibold ${statusClass}`}>
|
||||
{log.status}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-300">
|
||||
{log.message || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400">
|
||||
{log.endpoint ?? "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
disabled={actionLoading}
|
||||
className="rounded-md border border-red-600 px-3 py-1 text-xs text-red-300 transition hover:bg-red-600/20 disabled:opacity-60"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-300">
|
||||
<span>
|
||||
{totalLogs === 0
|
||||
? "Keine Einträge"
|
||||
: perPage === 'all'
|
||||
? `Zeige alle ${totalLogs} Einträge`
|
||||
: `Zeige ${rangeStart.toLocaleString()} – ${rangeEnd.toLocaleString()} von ${totalLogs.toLocaleString()} Einträgen`}
|
||||
</span>
|
||||
{perPage !== 'all' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1 || actionLoading}
|
||||
className="rounded-md border border-gray-600 px-3 py-1 text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span className="text-gray-400">
|
||||
Seite {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= totalPages || actionLoading}
|
||||
className="rounded-md border border-gray-600 px-3 py-1 text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1,172 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
|
||||
const LEVEL_PRIORITY = {
|
||||
none: 0,
|
||||
read: 1,
|
||||
full: 2,
|
||||
};
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
AuthContext.displayName = "AuthContext";
|
||||
|
||||
const normalizePermissions = (raw) => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
return Object.entries(raw).reduce((acc, [key, value]) => {
|
||||
if (typeof key !== "string" || !key) {
|
||||
return acc;
|
||||
}
|
||||
const normalizedValue = typeof value === "string" ? value : String(value ?? "").trim();
|
||||
acc[key] = normalizedValue || "none";
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [state, setState] = useState({
|
||||
user: null,
|
||||
permissions: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
lastErrorCode: null,
|
||||
initialized: false,
|
||||
});
|
||||
|
||||
const setSession = useCallback((payload) => {
|
||||
const user = payload?.user ?? null;
|
||||
const permissions = normalizePermissions(payload?.permissions);
|
||||
setState({
|
||||
user,
|
||||
permissions,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastErrorCode: null,
|
||||
initialized: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
user: null,
|
||||
permissions: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
lastErrorCode: null,
|
||||
initialized: prev.initialized || true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
error: null,
|
||||
lastErrorCode: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
setState({
|
||||
user: null,
|
||||
permissions: {},
|
||||
loading: false,
|
||||
error: payload?.error ?? null,
|
||||
lastErrorCode: payload?.error ?? null,
|
||||
initialized: true,
|
||||
});
|
||||
return { ok: false, status: response.status, error: payload?.error ?? null };
|
||||
}
|
||||
|
||||
setSession(payload);
|
||||
return { ok: true, data: payload };
|
||||
} catch (err) {
|
||||
const message = err?.message || "NETWORK_ERROR";
|
||||
setState({
|
||||
user: null,
|
||||
permissions: {},
|
||||
loading: false,
|
||||
error: message,
|
||||
lastErrorCode: "NETWORK_ERROR",
|
||||
initialized: true,
|
||||
});
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}, [setSession]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
let capturedError = null;
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const logoutError = new Error(payload?.error || "LOGOUT_FAILED");
|
||||
logoutError.code = payload?.error || `STATUS_${response.status}`;
|
||||
throw logoutError;
|
||||
}
|
||||
} catch (err) {
|
||||
capturedError = err;
|
||||
} finally {
|
||||
clearSession();
|
||||
}
|
||||
if (capturedError) {
|
||||
throw capturedError;
|
||||
}
|
||||
}, [clearSession]);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permissionKey, requiredLevel = "full") => {
|
||||
if (!permissionKey) {
|
||||
return true;
|
||||
}
|
||||
if (state.user?.isSuperuser) {
|
||||
return true;
|
||||
}
|
||||
const current = state.permissions?.[permissionKey] ?? "none";
|
||||
const currentPriority = LEVEL_PRIORITY[current] ?? 0;
|
||||
const requiredPriority = LEVEL_PRIORITY[requiredLevel] ?? LEVEL_PRIORITY.full;
|
||||
return currentPriority >= requiredPriority;
|
||||
},
|
||||
[state.permissions, state.user]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user: state.user,
|
||||
permissions: state.permissions,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
lastErrorCode: state.lastErrorCode,
|
||||
initialized: state.initialized,
|
||||
isAuthenticated: Boolean(state.user),
|
||||
refreshSession,
|
||||
setSession,
|
||||
clearSession,
|
||||
logout,
|
||||
hasPermission,
|
||||
}),
|
||||
[state, refreshSession, setSession, clearSession, logout, hasPermission]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export const AuthContextDisplayName = "/src/components/AuthProvider.jsx";
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
const MaintenanceContext = createContext(null);
|
||||
|
||||
@@ -14,6 +15,8 @@ const INITIAL_STATE = {
|
||||
};
|
||||
|
||||
export default function MaintenanceProvider({ children }) {
|
||||
const { isAuthenticated, hasPermission, user } = useAuth();
|
||||
const canAccessMaintenance = Boolean(isAuthenticated && (user?.isSuperuser || hasPermission("maintenance-access", "read")));
|
||||
const [state, setState] = useState(INITIAL_STATE);
|
||||
const pollingRef = useRef(null);
|
||||
const wasRunningRef = useRef(false);
|
||||
@@ -26,6 +29,10 @@ export default function MaintenanceProvider({ children }) {
|
||||
}, []);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
if (!canAccessMaintenance) {
|
||||
applyState({ loading: false, error: "" });
|
||||
return null;
|
||||
}
|
||||
applyState({ loading: true, error: "" });
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/config");
|
||||
@@ -43,9 +50,12 @@ export default function MaintenanceProvider({ children }) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||
applyState({ loading: false, error: message });
|
||||
}
|
||||
}, [applyState]);
|
||||
}, [applyState, canAccessMaintenance]);
|
||||
|
||||
const refreshUpdateStatus = useCallback(async () => {
|
||||
if (!canAccessMaintenance) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/update-status");
|
||||
const data = response.data ?? {};
|
||||
@@ -59,13 +69,20 @@ export default function MaintenanceProvider({ children }) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
|
||||
setState((prev) => ({ ...prev, error: message }));
|
||||
}
|
||||
}, []);
|
||||
}, [canAccessMaintenance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAccessMaintenance) {
|
||||
applyState({ loading: false, error: "" });
|
||||
return;
|
||||
}
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
}, [applyState, fetchConfig, canAccessMaintenance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAccessMaintenance) {
|
||||
return undefined;
|
||||
}
|
||||
if (state.update?.running) {
|
||||
refreshUpdateStatus();
|
||||
const intervalId = setInterval(() => {
|
||||
@@ -79,15 +96,15 @@ export default function MaintenanceProvider({ children }) {
|
||||
pollingRef.current = null;
|
||||
}
|
||||
return undefined;
|
||||
}, [state.update?.running, refreshUpdateStatus]);
|
||||
}, [canAccessMaintenance, state.update?.running, refreshUpdateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentlyRunning = Boolean(state.update?.running);
|
||||
if (!currentlyRunning && wasRunningRef.current) {
|
||||
if (canAccessMaintenance && !currentlyRunning && wasRunningRef.current) {
|
||||
fetchConfig();
|
||||
}
|
||||
wasRunningRef.current = currentlyRunning;
|
||||
}, [state.update?.running, fetchConfig]);
|
||||
}, [canAccessMaintenance, state.update?.running, fetchConfig]);
|
||||
|
||||
const triggerUpdate = useCallback(async (payload = {}) => {
|
||||
await axios.post("/api/maintenance/portainer-update", payload);
|
||||
@@ -114,11 +131,57 @@ export default function MaintenanceProvider({ children }) {
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const setMaintenanceMode = useCallback(async ({ active, message } = {}) => {
|
||||
const payload = { active: Boolean(active) };
|
||||
if (message !== undefined) {
|
||||
payload.message = message;
|
||||
}
|
||||
|
||||
const response = await axios.post("/api/maintenance/mode", payload);
|
||||
const nextMaintenance = response.data?.maintenance ?? null;
|
||||
applyState({ maintenance: nextMaintenance, error: "" });
|
||||
return nextMaintenance;
|
||||
}, [applyState]);
|
||||
|
||||
const testSshConnection = useCallback(async (config) => {
|
||||
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
|
||||
return response.data;
|
||||
}, []);
|
||||
|
||||
const fetchSuperuserStatus = useCallback(async () => {
|
||||
const response = await axios.get("/api/auth/superuser/status");
|
||||
const data = response.data ?? {};
|
||||
return {
|
||||
exists: Boolean(data.exists),
|
||||
user: data.user ?? null
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchSetupStatus = useCallback(async () => {
|
||||
const response = await axios.get("/api/setup/status");
|
||||
return response.data ?? {};
|
||||
}, []);
|
||||
|
||||
const deleteSetupServer = useCallback(async (serverId) => {
|
||||
const response = await axios.delete(`/api/setup/servers/${serverId}`);
|
||||
return response.data ?? { success: false };
|
||||
}, []);
|
||||
|
||||
const updateSetupApiKey = useCallback(async (serverId, apiKey) => {
|
||||
const response = await axios.put(`/api/setup/servers/${serverId}/api-key`, { apiKey });
|
||||
return response.data ?? { success: false };
|
||||
}, []);
|
||||
|
||||
const updateSelfStackId = useCallback(async (selfStackId) => {
|
||||
const response = await axios.put("/api/setup/self-stack", { selfStackId });
|
||||
return response.data ?? { success: false };
|
||||
}, []);
|
||||
|
||||
const removeSuperuserAccount = useCallback(async () => {
|
||||
const response = await axios.delete("/api/auth/superuser");
|
||||
return response.data ?? { success: false };
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
maintenance: state.maintenance,
|
||||
update: state.update,
|
||||
@@ -129,13 +192,20 @@ export default function MaintenanceProvider({ children }) {
|
||||
lastUpdated: state.lastUpdated,
|
||||
refreshConfig: fetchConfig,
|
||||
refreshUpdateStatus,
|
||||
setMaintenanceMode,
|
||||
triggerUpdate,
|
||||
saveScript,
|
||||
resetScript,
|
||||
saveSshConfig,
|
||||
deleteSshConfig,
|
||||
testSshConnection
|
||||
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
|
||||
testSshConnection,
|
||||
fetchSuperuserStatus,
|
||||
fetchSetupStatus,
|
||||
deleteSetupServer,
|
||||
updateSetupApiKey,
|
||||
updateSelfStackId,
|
||||
removeSuperuserAccount
|
||||
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, fetchSetupStatus, deleteSetupServer, updateSetupApiKey, updateSelfStackId, removeSuperuserAccount]);
|
||||
|
||||
return (
|
||||
<MaintenanceContext.Provider value={value}>
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
|
||||
const DEFAULT_PER_PAGE_OPTIONS = [
|
||||
{ value: "10", label: "10" },
|
||||
{ value: "25", label: "25" },
|
||||
{ value: "50", label: "50" },
|
||||
{ value: "100", label: "100" },
|
||||
{ value: "all", label: "Alle" }
|
||||
];
|
||||
|
||||
const DEFAULT_PER_PAGE = "50";
|
||||
|
||||
const PageContext = createContext(null);
|
||||
|
||||
const normalizeNumber = (value, fallback) => {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return numeric;
|
||||
}
|
||||
const fallbackNumeric = Number(fallback);
|
||||
if (Number.isFinite(fallbackNumeric) && fallbackNumeric > 0) {
|
||||
return fallbackNumeric;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
export function PageProvider({
|
||||
children,
|
||||
initialPage = 1,
|
||||
initialPerPage = DEFAULT_PER_PAGE,
|
||||
perPageOptions = DEFAULT_PER_PAGE_OPTIONS
|
||||
}) {
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [perPage, setPerPage] = useState(initialPerPage);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [visibleItems, setVisibleItems] = useState(0);
|
||||
|
||||
const initialPageRef = useRef(initialPage);
|
||||
const initialPerPageRef = useRef(initialPerPage);
|
||||
|
||||
const validPerPageValues = useMemo(() => new Set(
|
||||
perPageOptions.map((option) => String(option.value))
|
||||
), [perPageOptions]);
|
||||
|
||||
const perPageIsAll = perPage === "all";
|
||||
|
||||
const resolvePerPageNumber = useCallback(() => {
|
||||
if (perPageIsAll) {
|
||||
return null;
|
||||
}
|
||||
return normalizeNumber(perPage, initialPerPage);
|
||||
}, [perPage, perPageIsAll, initialPerPage]);
|
||||
|
||||
const updateTotals = useCallback((total, visible) => {
|
||||
setTotalItems(Math.max(0, Number(total) || 0));
|
||||
setVisibleItems(Math.max(0, Number(visible) || 0));
|
||||
}, []);
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPage(initialPageRef.current ?? 1);
|
||||
setPerPage(initialPerPageRef.current ?? DEFAULT_PER_PAGE);
|
||||
setTotalItems(0);
|
||||
setVisibleItems(0);
|
||||
}, []);
|
||||
|
||||
const setPerPageValue = useCallback((value, { resetPage = true } = {}) => {
|
||||
if (value === undefined || value === null) return;
|
||||
const nextValue = String(value);
|
||||
if (!validPerPageValues.has(nextValue)) {
|
||||
return;
|
||||
}
|
||||
setPerPage(nextValue);
|
||||
if (resetPage) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [validPerPageValues]);
|
||||
|
||||
const handlePerPageChange = useCallback((eventOrValue) => {
|
||||
const nextValue = typeof eventOrValue === "string"
|
||||
? eventOrValue
|
||||
: eventOrValue?.target?.value;
|
||||
setPerPageValue(nextValue, { resetPage: true });
|
||||
}, [setPerPageValue]);
|
||||
|
||||
const handlePageChange = useCallback((nextPage) => {
|
||||
if (perPageIsAll) return;
|
||||
|
||||
const numericPage = Number(nextPage);
|
||||
if (!Number.isFinite(numericPage) || numericPage < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
if (!perPageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPages = Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
|
||||
if (numericPage > maxPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPage(numericPage);
|
||||
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (perPageIsAll) {
|
||||
return 1;
|
||||
}
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
if (!perPageNumber) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
|
||||
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
|
||||
|
||||
const rangeStart = useMemo(() => {
|
||||
if (!totalItems) {
|
||||
return 0;
|
||||
}
|
||||
if (perPageIsAll) {
|
||||
return 1;
|
||||
}
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
return (page - 1) * perPageNumber + 1;
|
||||
}, [totalItems, perPageIsAll, resolvePerPageNumber, page]);
|
||||
|
||||
const rangeEnd = useMemo(() => {
|
||||
if (!totalItems) {
|
||||
return 0;
|
||||
}
|
||||
if (perPageIsAll) {
|
||||
return totalItems;
|
||||
}
|
||||
const perPageNumber = resolvePerPageNumber();
|
||||
const estimatedEnd = (page - 1) * perPageNumber + (visibleItems || perPageNumber);
|
||||
return Math.min(totalItems, estimatedEnd);
|
||||
}, [totalItems, perPageIsAll, resolvePerPageNumber, page, visibleItems]);
|
||||
|
||||
const summaryLabel = useMemo(() => {
|
||||
if (!totalItems) {
|
||||
return "Keine Einträge";
|
||||
}
|
||||
if (perPageIsAll) {
|
||||
return `Zeige alle ${totalItems.toLocaleString()} Einträge`;
|
||||
}
|
||||
return `Zeige ${rangeStart.toLocaleString()} – ${rangeEnd.toLocaleString()} von ${totalItems.toLocaleString()} Einträgen`;
|
||||
}, [totalItems, perPageIsAll, rangeStart, rangeEnd]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
perPageIsAll,
|
||||
totalItems,
|
||||
visibleItems,
|
||||
totalPages,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
summaryLabel,
|
||||
setPage,
|
||||
setPerPage: setPerPageValue,
|
||||
setTotals: updateTotals,
|
||||
setTotalItems,
|
||||
setVisibleItems,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
validPerPageValues,
|
||||
resetPagination
|
||||
}), [
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
perPageIsAll,
|
||||
totalItems,
|
||||
visibleItems,
|
||||
totalPages,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
summaryLabel,
|
||||
updateTotals,
|
||||
setPerPageValue,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
validPerPageValues,
|
||||
resetPagination
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageContext.Provider value={value}>
|
||||
{children}
|
||||
</PageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePage() {
|
||||
const context = useContext(PageContext);
|
||||
if (!context) {
|
||||
throw new Error("usePage must be used within a PageProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function PaginationControls({ disabled = false }) {
|
||||
const {
|
||||
summaryLabel,
|
||||
perPageIsAll,
|
||||
page,
|
||||
totalPages,
|
||||
handlePageChange
|
||||
} = usePage();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span>{summaryLabel}</span>
|
||||
{!perPageIsAll && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={disabled || page <= 1}
|
||||
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
|
||||
type="button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
|
||||
<path d="M11.03 3.97a.75.75 0 0 1 0 1.06l-6.22 6.22H21a.75.75 0 0 1 0 1.5H4.81l6.22 6.22a.75.75 0 1 1-1.06 1.06l-7.5-7.5a.75.75 0 0 1 0-1.06l7.5-7.5a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<p className="text-slate-600">
|
||||
Seite <strong className="text-slate-800">{page}</strong> /
|
||||
<strong className="text-slate-800">{totalPages}</strong>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={disabled || page >= totalPages}
|
||||
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
|
||||
type="button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
|
||||
<path d="M12.97 3.97a.75.75 0 0 1 1.06 0l7.5 7.5a.75.75 0 0 1 0 1.06l-7.5 7.5a.75.75 0 1 1-1.06-1.06l6.22-6.22H3a.75.75 0 0 1 0-1.5h16.19l-6.22-6.22a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PAGE_DEFAULTS = {
|
||||
perPage: DEFAULT_PER_PAGE,
|
||||
options: DEFAULT_PER_PAGE_OPTIONS
|
||||
};
|
||||
|
||||
export default PageProvider;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Typography, Card, CardBody } from "@material-tailwind/react";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
export function PermissionGate({
|
||||
permission,
|
||||
requiredLevel = "full",
|
||||
loadingFallback = null,
|
||||
children,
|
||||
}) {
|
||||
const { initialized, loading, hasPermission } = useAuth();
|
||||
|
||||
if (!permission) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!initialized || loading) {
|
||||
return (
|
||||
loadingFallback ?? (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
<Typography variant="small" color="blue-gray">
|
||||
Berechtigungen werden geladen …
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(permission, requiredLevel)) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-12 flex items-center justify-center px-4">
|
||||
<Card className="max-w-xl border border-blue-gray-100 shadow-sm">
|
||||
<CardBody className="space-y-3 text-center">
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Kein Zugriff auf diesen Bereich
|
||||
</Typography>
|
||||
<Typography variant="small" className="text-blue-gray-500">
|
||||
Für diese Funktion wird die Berechtigung <code>{permission}</code> benötigt.
|
||||
</Typography>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PermissionGate.propTypes = {
|
||||
permission: PropTypes.string,
|
||||
requiredLevel: PropTypes.string,
|
||||
loadingFallback: PropTypes.node,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default PermissionGate;
|
||||
|
||||
@@ -4,20 +4,20 @@ const ToastContext = createContext(null);
|
||||
|
||||
const VARIANT_STYLES = {
|
||||
success: {
|
||||
container: "border-green-500/80 bg-green-950/90 text-green-100",
|
||||
accent: "bg-green-400"
|
||||
container: "border-mossGreen-500/80 bg-mossGreen-900/90 text-white",
|
||||
accent: "bg-mossGreen-400"
|
||||
},
|
||||
warning: {
|
||||
container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100",
|
||||
accent: "bg-yellow-400"
|
||||
container: "border-warmAmberGlow-500/80 bg-warmAmberGlow-900/90 text-white",
|
||||
accent: "bg-warmAmberGlow-400"
|
||||
},
|
||||
error: {
|
||||
container: "border-red-500/80 bg-red-950/90 text-red-100",
|
||||
accent: "bg-red-400"
|
||||
container: "border-sunsetCoral-500/80 bg-sunsetCoral-900/90 text-white",
|
||||
accent: "bg-sunsetCoral-400"
|
||||
},
|
||||
info: {
|
||||
container: "border-sky-500/60 bg-sky-950/90 text-sky-100",
|
||||
accent: "bg-sky-400"
|
||||
container: "border-arcticBlue-500 bg-arcticBlue-900/90 text-white",
|
||||
accent: "bg-arcticBlue-400"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export const MaterialTailwind = React.createContext(null);
|
||||
MaterialTailwind.displayName = "MaterialTailwindContext";
|
||||
|
||||
export function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case "OPEN_SIDENAV": {
|
||||
return { ...state, openSidenav: action.value };
|
||||
}
|
||||
case "SIDENAV_TYPE": {
|
||||
return { ...state, sidenavType: action.value };
|
||||
}
|
||||
case "SIDENAV_COLOR": {
|
||||
return { ...state, sidenavColor: action.value };
|
||||
}
|
||||
case "TRANSPARENT_NAVBAR": {
|
||||
return { ...state, transparentNavbar: action.value };
|
||||
}
|
||||
case "FIXED_NAVBAR": {
|
||||
return { ...state, fixedNavbar: action.value };
|
||||
}
|
||||
case "OPEN_CONFIGURATOR": {
|
||||
return { ...state, openConfigurator: action.value };
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unhandled action type: ${action.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function MaterialTailwindControllerProvider({ children }) {
|
||||
const initialState = {
|
||||
openSidenav: false,
|
||||
sidenavColor: "gray",
|
||||
sidenavType: "white",
|
||||
transparentNavbar: false,
|
||||
fixedNavbar: true,
|
||||
openConfigurator: false,
|
||||
};
|
||||
|
||||
const [controller, dispatch] = React.useReducer(reducer, initialState);
|
||||
const value = React.useMemo(
|
||||
() => [controller, dispatch],
|
||||
[controller, dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<MaterialTailwind.Provider value={value}>
|
||||
{children}
|
||||
</MaterialTailwind.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaterialTailwindController() {
|
||||
const context = React.useContext(MaterialTailwind);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useMaterialTailwindController should be used inside the MaterialTailwindControllerProvider."
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
MaterialTailwindControllerProvider.displayName = "/src/context/index.jsx";
|
||||
|
||||
MaterialTailwindControllerProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export const setOpenSidenav = (dispatch, value) =>
|
||||
dispatch({ type: "OPEN_SIDENAV", value });
|
||||
export const setSidenavType = (dispatch, value) =>
|
||||
dispatch({ type: "SIDENAV_TYPE", value });
|
||||
export const setSidenavColor = (dispatch, value) =>
|
||||
dispatch({ type: "SIDENAV_COLOR", value });
|
||||
export const setTransparentNavbar = (dispatch, value) =>
|
||||
dispatch({ type: "TRANSPARENT_NAVBAR", value });
|
||||
export const setFixedNavbar = (dispatch, value) =>
|
||||
dispatch({ type: "FIXED_NAVBAR", value });
|
||||
export const setOpenConfigurator = (dispatch, value) =>
|
||||
dispatch({ type: "OPEN_CONFIGURATOR", value });
|
||||
@@ -0,0 +1,61 @@
|
||||
export const chartsConfig = {
|
||||
chart: {
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
show: "",
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
labels: {
|
||||
style: {
|
||||
colors: "#37474f",
|
||||
fontSize: "13px",
|
||||
fontFamily: "inherit",
|
||||
fontWeight: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: "#37474f",
|
||||
fontSize: "13px",
|
||||
fontFamily: "inherit",
|
||||
fontWeight: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: "#dddddd",
|
||||
strokeDashArray: 5,
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
padding: {
|
||||
top: 5,
|
||||
right: 20,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
tooltip: {
|
||||
theme: "dark",
|
||||
},
|
||||
};
|
||||
|
||||
export default chartsConfig;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@/configs/charts-config";
|
||||
@@ -0,0 +1,52 @@
|
||||
export const authorsTableData = [
|
||||
{
|
||||
img: "/img/team-2.jpeg",
|
||||
name: "John Michael",
|
||||
email: "john@creative-tim.com",
|
||||
job: ["Manager", "Organization"],
|
||||
online: true,
|
||||
date: "23/04/18",
|
||||
},
|
||||
{
|
||||
img: "/img/team-1.jpeg",
|
||||
name: "Alexa Liras",
|
||||
email: "alexa@creative-tim.com",
|
||||
job: ["Programator", "Developer"],
|
||||
online: false,
|
||||
date: "11/01/19",
|
||||
},
|
||||
{
|
||||
img: "/img/team-4.jpeg",
|
||||
name: "Laurent Perrier",
|
||||
email: "laurent@creative-tim.com",
|
||||
job: ["Executive", "Projects"],
|
||||
online: true,
|
||||
date: "19/09/17",
|
||||
},
|
||||
{
|
||||
img: "/img/team-3.jpeg",
|
||||
name: "Michael Levi",
|
||||
email: "michael@creative-tim.com",
|
||||
job: ["Programator", "Developer"],
|
||||
online: true,
|
||||
date: "24/12/08",
|
||||
},
|
||||
{
|
||||
img: "/img/bruce-mars.jpeg",
|
||||
name: "Bruce Mars",
|
||||
email: "bruce@creative-tim.com",
|
||||
job: ["Manager", "Executive"],
|
||||
online: false,
|
||||
date: "04/10/21",
|
||||
},
|
||||
{
|
||||
img: "/img/team-2.jpeg",
|
||||
name: "Alexander",
|
||||
email: "alexander@creative-tim.com",
|
||||
job: ["Programator", "Developer"],
|
||||
online: false,
|
||||
date: "14/09/20",
|
||||
},
|
||||
];
|
||||
|
||||
export default authorsTableData;
|
||||
@@ -0,0 +1,14 @@
|
||||
export const AVATAR_COLORS = [
|
||||
"bg-arcticBlue-600",
|
||||
"bg-copperRust-500",
|
||||
"bg-sunsetCoral-600",
|
||||
"bg-mintTea-400",
|
||||
"bg-lavenderSmoke-500",
|
||||
"bg-emeraldMist-500",
|
||||
"bg-roseQuartz-500",
|
||||
"bg-auroraTeal-500",
|
||||
"bg-citrusPunch-500",
|
||||
"bg-mossGreen-400"
|
||||
];
|
||||
|
||||
export const DEFAULT_AVATAR_COLOR = "bg-mossGreen-500";
|
||||
@@ -0,0 +1,29 @@
|
||||
export const conversationsData = [
|
||||
{
|
||||
img: "/img/team-1.jpeg",
|
||||
name: "Sophie B.",
|
||||
message: "Hi! I need more information...",
|
||||
},
|
||||
{
|
||||
img: "/img/team-2.jpeg",
|
||||
name: "Alexander",
|
||||
message: "Awesome work, can you...",
|
||||
},
|
||||
{
|
||||
img: "/img/team-3.jpeg",
|
||||
name: "Ivanna",
|
||||
message: "About files I can...",
|
||||
},
|
||||
{
|
||||
img: "/img/team-4.jpeg",
|
||||
name: "Peterson",
|
||||
message: "Have a great afternoon...",
|
||||
},
|
||||
{
|
||||
img: "/img/bruce-mars.jpeg",
|
||||
name: "Bruce Mars",
|
||||
message: "Hi! I need more information...",
|
||||
},
|
||||
];
|
||||
|
||||
export default conversationsData;
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "@/data/statistics-cards-data";
|
||||
export * from "@/data/statistics-charts-data";
|
||||
export * from "@/data/projects-table-data";
|
||||
export * from "@/data/orders-overview-data";
|
||||
export * from "@/data/platform-settings-data";
|
||||
export * from "@/data/conversations-data";
|
||||
export * from "@/data/projects-data";
|
||||
export * from "@/data/authors-table-data";
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
BellIcon,
|
||||
PlusCircleIcon,
|
||||
ShoppingCartIcon,
|
||||
CreditCardIcon,
|
||||
LockOpenIcon,
|
||||
BanknotesIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
export const ordersOverviewData = [
|
||||
{
|
||||
icon: BellIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "$2400, Design changes",
|
||||
description: "22 DEC 7:20 PM",
|
||||
},
|
||||
{
|
||||
icon: PlusCircleIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "New order #1832412",
|
||||
description: "21 DEC 11 PM",
|
||||
},
|
||||
{
|
||||
icon: ShoppingCartIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "Server payments for April",
|
||||
description: "21 DEC 9:34 PM",
|
||||
},
|
||||
{
|
||||
icon: CreditCardIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "New card added for order #4395133",
|
||||
description: "20 DEC 2:20 AM",
|
||||
},
|
||||
{
|
||||
icon: LockOpenIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "Unlock packages for development",
|
||||
description: "18 DEC 4:54 AM",
|
||||
},
|
||||
{
|
||||
icon: BanknotesIcon,
|
||||
color: "text-blue-gray-300",
|
||||
title: "New order #9583120",
|
||||
description: "17 DEC",
|
||||
},
|
||||
];
|
||||
|
||||
export default ordersOverviewData;
|
||||
@@ -0,0 +1,38 @@
|
||||
export const platformSettingsData = [
|
||||
{
|
||||
title: "account",
|
||||
options: [
|
||||
{
|
||||
checked: true,
|
||||
label: "Email me when someone follows me",
|
||||
},
|
||||
{
|
||||
checked: false,
|
||||
label: "Email me when someone answers on my post",
|
||||
},
|
||||
{
|
||||
checked: true,
|
||||
label: "Email me when someone mentions me",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "application",
|
||||
options: [
|
||||
{
|
||||
checked: false,
|
||||
label: "New launches and projects",
|
||||
},
|
||||
{
|
||||
checked: true,
|
||||
label: "Monthly product updates",
|
||||
},
|
||||
{
|
||||
checked: false,
|
||||
label: "Subscribe to newsletter",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default platformSettingsData;
|
||||
@@ -0,0 +1,60 @@
|
||||
export const projectsData = [
|
||||
{
|
||||
img: "/img/home-decor-1.jpeg",
|
||||
title: "Modern",
|
||||
tag: "Project #1",
|
||||
description:
|
||||
"As Uber works through a huge amount of internal management turmoil.",
|
||||
route: "/dashboard/profile",
|
||||
members: [
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
},
|
||||
{
|
||||
img: "/img/home-decor-2.jpeg",
|
||||
title: "Scandinavian",
|
||||
tag: "Project #2",
|
||||
description:
|
||||
"Music is something that every person has his or her own specific opinion about.",
|
||||
route: "/dashboard/profile",
|
||||
members: [
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
],
|
||||
},
|
||||
{
|
||||
img: "/img/home-decor-3.jpeg",
|
||||
title: "Minimalist",
|
||||
tag: "Project #3",
|
||||
description:
|
||||
"Different people have different taste, and various types of music.",
|
||||
route: "/dashboard/profile",
|
||||
members: [
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
},
|
||||
{
|
||||
img: "/img/home-decor-4.jpeg",
|
||||
title: "Gothic",
|
||||
tag: "Project #4",
|
||||
description:
|
||||
"Why would anyone pick blue over pink? Pink is obviously a better color.",
|
||||
route: "/dashboard/profile",
|
||||
members: [
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default projectsData;
|
||||
@@ -0,0 +1,65 @@
|
||||
export const projectsTableData = [
|
||||
{
|
||||
img: "/img/logo-xd.svg",
|
||||
name: "Material XD Version",
|
||||
members: [
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
budget: "$14,000",
|
||||
completion: 60,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-atlassian.svg",
|
||||
name: "Add Progress Track",
|
||||
members: [
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
budget: "$3,000",
|
||||
completion: 10,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-slack.svg",
|
||||
name: "Fix Platform Errors",
|
||||
members: [
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
],
|
||||
budget: "Not set",
|
||||
completion: 100,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-spotify.svg",
|
||||
name: "Launch our Mobile App",
|
||||
members: [
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
{ img: "/img/team-3.jpeg", name: "Jessica Doe" },
|
||||
{ img: "/img/team-2.jpeg", name: "Ryan Tompson" },
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
],
|
||||
budget: "$20,500",
|
||||
completion: 100,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-jira.svg",
|
||||
name: "Add the New Pricing Page",
|
||||
members: [{ img: "/img/team-4.jpeg", name: "Alexander Smith" }],
|
||||
budget: "$500",
|
||||
completion: 25,
|
||||
},
|
||||
{
|
||||
img: "/img/logo-invision.svg",
|
||||
name: "Redesign New Online Shop",
|
||||
members: [
|
||||
{ img: "/img/team-1.jpeg", name: "Romina Hadid" },
|
||||
{ img: "/img/team-4.jpeg", name: "Alexander Smith" },
|
||||
],
|
||||
budget: "$2,000",
|
||||
completion: 40,
|
||||
},
|
||||
];
|
||||
|
||||
export default projectsTableData;
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
BanknotesIcon,
|
||||
UserPlusIcon,
|
||||
UsersIcon,
|
||||
ChartBarIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
export const statisticsCardsData = [
|
||||
{
|
||||
color: "gray",
|
||||
icon: BanknotesIcon,
|
||||
title: "Today's Money",
|
||||
value: "$53k",
|
||||
footer: {
|
||||
color: "text-green-500",
|
||||
value: "+55%",
|
||||
label: "than last week",
|
||||
},
|
||||
},
|
||||
{
|
||||
color: "gray",
|
||||
icon: UsersIcon,
|
||||
title: "Today's Users",
|
||||
value: "2,300",
|
||||
footer: {
|
||||
color: "text-green-500",
|
||||
value: "+3%",
|
||||
label: "than last month",
|
||||
},
|
||||
},
|
||||
{
|
||||
color: "gray",
|
||||
icon: UserPlusIcon,
|
||||
title: "New Clients",
|
||||
value: "3,462",
|
||||
footer: {
|
||||
color: "text-red-500",
|
||||
value: "-2%",
|
||||
label: "than yesterday",
|
||||
},
|
||||
},
|
||||
{
|
||||
color: "gray",
|
||||
icon: ChartBarIcon,
|
||||
title: "Sales",
|
||||
value: "$103,430",
|
||||
footer: {
|
||||
color: "text-green-500",
|
||||
value: "+5%",
|
||||
label: "than yesterday",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default statisticsCardsData;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { chartsConfig } from "@/configs";
|
||||
|
||||
const websiteViewsChart = {
|
||||
type: "bar",
|
||||
height: 220,
|
||||
series: [
|
||||
{
|
||||
name: "Views",
|
||||
data: [50, 20, 10, 22, 50, 10, 40],
|
||||
},
|
||||
],
|
||||
options: {
|
||||
...chartsConfig,
|
||||
colors: "#388e3c",
|
||||
plotOptions: {
|
||||
bar: {
|
||||
columnWidth: "16%",
|
||||
borderRadius: 5,
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
...chartsConfig.xaxis,
|
||||
categories: ["M", "T", "W", "T", "F", "S", "S"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dailySalesChart = {
|
||||
type: "line",
|
||||
height: 220,
|
||||
series: [
|
||||
{
|
||||
name: "Sales",
|
||||
data: [50, 40, 300, 320, 500, 350, 200, 230, 500],
|
||||
},
|
||||
],
|
||||
options: {
|
||||
...chartsConfig,
|
||||
colors: ["#0288d1"],
|
||||
stroke: {
|
||||
lineCap: "round",
|
||||
},
|
||||
markers: {
|
||||
size: 5,
|
||||
},
|
||||
xaxis: {
|
||||
...chartsConfig.xaxis,
|
||||
categories: [
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const completedTaskChart = {
|
||||
type: "line",
|
||||
height: 220,
|
||||
series: [
|
||||
{
|
||||
name: "Sales",
|
||||
data: [50, 40, 300, 320, 500, 350, 200, 230, 500],
|
||||
},
|
||||
],
|
||||
options: {
|
||||
...chartsConfig,
|
||||
colors: ["#388e3c"],
|
||||
stroke: {
|
||||
lineCap: "round",
|
||||
},
|
||||
markers: {
|
||||
size: 5,
|
||||
},
|
||||
xaxis: {
|
||||
...chartsConfig.xaxis,
|
||||
categories: [
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const completedTasksChart = {
|
||||
...completedTaskChart,
|
||||
series: [
|
||||
{
|
||||
name: "Tasks",
|
||||
data: [50, 40, 300, 220, 500, 250, 400, 230, 500],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const statisticsChartsData = [
|
||||
{
|
||||
color: "white",
|
||||
title: "Website View",
|
||||
description: "Last Campaign Performance",
|
||||
footer: "campaign sent 2 days ago",
|
||||
chart: websiteViewsChart,
|
||||
},
|
||||
{
|
||||
color: "white",
|
||||
title: "Daily Sales",
|
||||
description: "15% increase in today sales",
|
||||
footer: "updated 4 min ago",
|
||||
chart: dailySalesChart,
|
||||
},
|
||||
{
|
||||
color: "white",
|
||||
title: "Completed Tasks",
|
||||
description: "Last Campaign Performance",
|
||||
footer: "just updated",
|
||||
chart: completedTasksChart,
|
||||
},
|
||||
];
|
||||
|
||||
export default statisticsChartsData;
|
||||
@@ -1,18 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom tweaks für GitHub-Next-Style */
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button {
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import {
|
||||
ChartPieIcon,
|
||||
UserIcon,
|
||||
UserPlusIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Navbar, Footer } from "@/widgets/layout";
|
||||
import routes from "@/routes";
|
||||
export function Auth() {
|
||||
const navbarRoutes = [
|
||||
{
|
||||
name: "dashboard",
|
||||
path: "/dashboard/home",
|
||||
icon: ChartPieIcon,
|
||||
},
|
||||
{
|
||||
name: "profile",
|
||||
path: "/dashboard/home",
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
name: "sign up",
|
||||
path: "/auth/sign-up",
|
||||
icon: UserPlusIcon,
|
||||
},
|
||||
{
|
||||
name: "sign in",
|
||||
path: "/auth/sign-in",
|
||||
icon: ArrowRightOnRectangleIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full">
|
||||
<Routes>
|
||||
{routes.map(
|
||||
({ layout, pages }) =>
|
||||
layout === "auth" &&
|
||||
pages.map(({ path, element }) => (
|
||||
<Route exact path={path} element={element} />
|
||||
))
|
||||
)}
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Auth.displayName = "/src/layout/Auth.jsx";
|
||||
|
||||
export default Auth;
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Routes, Route, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Cog6ToothIcon } from "@heroicons/react/24/solid";
|
||||
import { IconButton } from "@material-tailwind/react";
|
||||
import {
|
||||
Sidenav,
|
||||
DashboardNavbar,
|
||||
Configurator,
|
||||
Footer,
|
||||
} from "@/widgets/layout";
|
||||
import routes from "@/routes";
|
||||
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
|
||||
import { UserGroupDetail } from "@/pages/dashboard/userGroupDetail.jsx";
|
||||
import { SecurityPhrase } from "@/pages/dashboard/securityPhrase.jsx";
|
||||
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
import PermissionGate from "@/components/PermissionGate.jsx";
|
||||
|
||||
export function Dashboard() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { sidenavType } = controller;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
const [setupIncomplete, setSetupIncomplete] = useState(true);
|
||||
const {
|
||||
initialized: authInitialized,
|
||||
loading: authLoading,
|
||||
isAuthenticated,
|
||||
refreshSession,
|
||||
user
|
||||
} = useAuth();
|
||||
|
||||
const checkSetupStatus = useCallback(async () => {
|
||||
setSetupChecked(false);
|
||||
try {
|
||||
const response = await fetch("/api/setup/status", { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Error("STATUS_REQUEST_FAILED");
|
||||
}
|
||||
const data = await response.json();
|
||||
setSetupIncomplete(!data.setupComplete);
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Setup] Statusprüfung fehlgeschlagen:", error);
|
||||
setSetupIncomplete(true);
|
||||
} finally {
|
||||
setSetupChecked(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkSetupStatus();
|
||||
}, [checkSetupStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupChecked || setupIncomplete) return;
|
||||
if (authInitialized || authLoading) return;
|
||||
refreshSession();
|
||||
}, [setupChecked, setupIncomplete, authInitialized, authLoading, refreshSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupChecked) return;
|
||||
|
||||
if (setupIncomplete) {
|
||||
if (location.pathname !== "/setup") {
|
||||
navigate("/setup", { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authInitialized) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (location.pathname !== "/auth/sign-in") {
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresSecurityPhrase = Boolean(user?.requiresSecurityPhraseDownload);
|
||||
const isSecurityPhrasePath = location.pathname === "/dashboard/security-phrase";
|
||||
|
||||
if (requiresSecurityPhrase && !isSecurityPhrasePath) {
|
||||
navigate("/dashboard/security-phrase", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requiresSecurityPhrase && isSecurityPhrasePath) {
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
}
|
||||
}, [
|
||||
setupChecked,
|
||||
setupIncomplete,
|
||||
authInitialized,
|
||||
isAuthenticated,
|
||||
location.pathname,
|
||||
navigate,
|
||||
user?.requiresSecurityPhraseDownload
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupChecked || setupIncomplete) return;
|
||||
if (!authInitialized || authLoading) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (location.pathname !== "/auth/sign-in") {
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isAuthPath = location.pathname.startsWith("/auth/");
|
||||
const isLogoutPath = location.pathname === "/auth/logout";
|
||||
|
||||
if (location.pathname === "/setup" || (isAuthPath && !isLogoutPath)) {
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
}
|
||||
}, [
|
||||
setupChecked,
|
||||
setupIncomplete,
|
||||
authInitialized,
|
||||
authLoading,
|
||||
isAuthenticated,
|
||||
location.pathname,
|
||||
navigate
|
||||
]);
|
||||
|
||||
if (!setupChecked) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Pruefe Systemkonfiguration ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupIncomplete) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Setup erforderlich ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authInitialized || authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Pruefe Anmeldung ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Weiterleitung zur Anmeldung ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSecurityPhraseRoute = location.pathname === "/dashboard/security-phrase";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-blue-gray-50/50">
|
||||
{!isSecurityPhraseRoute && (
|
||||
<Sidenav
|
||||
routes={routes}
|
||||
brandImg={
|
||||
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={isSecurityPhraseRoute ? "" : "p-4 xl:ml-80"}>
|
||||
{!isSecurityPhraseRoute && (
|
||||
<>
|
||||
<DashboardNavbar />
|
||||
<Configurator />
|
||||
<IconButton
|
||||
size="lg"
|
||||
color="white"
|
||||
className="fixed bottom-8 right-8 z-40 rounded-full shadow-blue-gray-900/10 xl:hidden"
|
||||
ripple={false}
|
||||
onClick={() => setOpenConfigurator(dispatch, true)}
|
||||
>
|
||||
<Cog6ToothIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<Routes>
|
||||
{routes.map(
|
||||
({ layout, pages }) =>
|
||||
layout === "dashboard" &&
|
||||
pages.map(({ path, element }) => (
|
||||
<Route exact path={path} element={element} />
|
||||
))
|
||||
)}
|
||||
<Route path="security-phrase" element={<SecurityPhrase />} />
|
||||
<Route
|
||||
path="users/:userId"
|
||||
element={
|
||||
<PermissionGate permission="users-access" requiredLevel="read">
|
||||
<UserDetails />
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="usergroups/:groupId"
|
||||
element={
|
||||
<PermissionGate permission="user-groups-access" requiredLevel="read">
|
||||
<UserGroupDetail />
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
{!isSecurityPhraseRoute && (
|
||||
<div className="text-blue-gray-600">
|
||||
<Footer />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Dashboard.displayName = "/src/layout/dashboard.jsx";
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "@/layouts/dashboard";
|
||||
export * from "@/layouts/auth";
|
||||
@@ -1,19 +1,45 @@
|
||||
/**
|
||||
=========================================================
|
||||
* Material Tailwind Dashboard React - v2.1.0
|
||||
=========================================================
|
||||
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
|
||||
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
|
||||
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
|
||||
* Coded by Creative Tim
|
||||
=========================================================
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
*/
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.jsx";
|
||||
import ToastProvider from "./components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "./context/MaintenanceContext.jsx";
|
||||
import './index.css'; // Tailwind CSS
|
||||
import { ThemeProvider } from "@material-tailwind/react";
|
||||
import { MaterialTailwindControllerProvider } from "@/components";
|
||||
import "./tailwind.css";
|
||||
import ToastProvider from "@/components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
|
||||
import PageProvider from "@/components/PageProvider.jsx";
|
||||
import { AuthProvider } from "@/components/AuthProvider.jsx";
|
||||
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<MaintenanceProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</MaintenanceProvider>
|
||||
<AuthProvider>
|
||||
<MaintenanceProvider>
|
||||
<ToastProvider>
|
||||
<PageProvider>
|
||||
<ThemeProvider>
|
||||
<MaterialTailwindControllerProvider>
|
||||
<App />
|
||||
</MaterialTailwindControllerProvider>
|
||||
</ThemeProvider>
|
||||
</PageProvider>
|
||||
</ToastProvider>
|
||||
</MaintenanceProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Typography,
|
||||
Input,
|
||||
Button,
|
||||
Spinner,
|
||||
Alert
|
||||
} from "@material-tailwind/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
const PHRASE_WORD_COUNT = 8;
|
||||
|
||||
const normalizeWord = (value) => String(value || "").trim().toLowerCase();
|
||||
|
||||
const extractWordsFromText = (text = "") => {
|
||||
const normalized = String(text || "").replace(/[\r\n\t]+/g, " ").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
return normalized.split(/\s+/).filter(Boolean);
|
||||
};
|
||||
|
||||
const buildPhrasePayload = (words = []) => {
|
||||
const normalized = words.map(normalizeWord).filter(Boolean);
|
||||
return {
|
||||
words: normalized,
|
||||
canonical: normalized.join("")
|
||||
};
|
||||
};
|
||||
|
||||
export function ForgotPassword() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [phraseInputs, setPhraseInputs] = useState(Array(PHRASE_WORD_COUNT).fill(""));
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [verificationError, setVerificationError] = useState("");
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verificationToken, setVerificationToken] = useState("");
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
const phraseWords = useMemo(() => {
|
||||
if (fileContent.trim()) {
|
||||
return extractWordsFromText(fileContent);
|
||||
}
|
||||
return phraseInputs.map(normalizeWord).filter(Boolean);
|
||||
}, [fileContent, phraseInputs]);
|
||||
|
||||
const handlePhraseInputChange = useCallback((index, value) => {
|
||||
setPhraseInputs((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = useCallback((event) => {
|
||||
const [file] = event.target.files || [];
|
||||
if (!file) {
|
||||
setFileName("");
|
||||
setFileContent("");
|
||||
return;
|
||||
}
|
||||
setFileName(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (loadEvent) => {
|
||||
const text = String(loadEvent.target?.result || "");
|
||||
setFileContent(text);
|
||||
setPhraseInputs(Array(PHRASE_WORD_COUNT).fill(""));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setFileName("");
|
||||
setFileContent("");
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Datei konnte nicht gelesen werden",
|
||||
description: "Bitte lade die Datei erneut hoch."
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, [showToast]);
|
||||
|
||||
const handleVerifyPhrase = useCallback(async () => {
|
||||
setVerificationError("");
|
||||
setVerificationToken("");
|
||||
|
||||
const trimmedUsername = username.trim();
|
||||
if (!trimmedUsername) {
|
||||
setVerificationError("Bitte gib deinen Benutzernamen ein.");
|
||||
return;
|
||||
}
|
||||
|
||||
const words = phraseWords;
|
||||
const hasFile = Boolean(fileContent.trim());
|
||||
|
||||
if (!hasFile && words.length !== PHRASE_WORD_COUNT) {
|
||||
setVerificationError("Bitte gib alle acht Wörter des Sicherheitsschlüssels ein.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFile && words.length !== PHRASE_WORD_COUNT) {
|
||||
setVerificationError("Die hochgeladene Datei enthält nicht genau acht Wörter.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPhrasePayload(words);
|
||||
if (!payload.canonical) {
|
||||
setVerificationError("Der Sicherheitsschlüssel ist ungültig oder leer.");
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifying(true);
|
||||
try {
|
||||
const response = await axios.post("/api/auth/recover/verify", {
|
||||
username: trimmedUsername,
|
||||
phrase: payload.canonical,
|
||||
words: payload.words
|
||||
});
|
||||
|
||||
const responseToken = response.data?.token;
|
||||
if (!responseToken) {
|
||||
throw new Error("TOKEN_MISSING");
|
||||
}
|
||||
setVerificationToken(responseToken);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Sicherheitsschlüssel bestätigt",
|
||||
description: "Du kannst jetzt ein neues Passwort festlegen."
|
||||
});
|
||||
} catch (error) {
|
||||
const serverError = error.response?.data?.error;
|
||||
if (serverError === "USER_NOT_FOUND") {
|
||||
setVerificationError("Der angegebene Benutzer wurde nicht gefunden.");
|
||||
} else if (serverError === "PHRASE_MISMATCH") {
|
||||
setVerificationError("Der Sicherheitsschlüssel passt nicht zum Benutzer.");
|
||||
} else if (serverError === "PHRASE_NOT_INITIALIZED") {
|
||||
setVerificationError("Für diesen Benutzer ist aktuell kein Sicherheitsschlüssel hinterlegt.");
|
||||
} else if (serverError === "PHRASE_REQUIRED") {
|
||||
setVerificationError("Bitte gib den Sicherheitsschlüssel ein.");
|
||||
} else if (serverError === "USERNAME_REQUIRED") {
|
||||
setVerificationError("Der Benutzername darf nicht leer sein.");
|
||||
} else if (serverError === "INVALID_PASSWORD") {
|
||||
setVerificationError("Der Sicherheitsschlüssel ist ungültig.");
|
||||
} else {
|
||||
setVerificationError("Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
}, [fileContent, phraseWords, showToast, username]);
|
||||
|
||||
const handleResetPassword = useCallback(async () => {
|
||||
setResetError("");
|
||||
|
||||
if (!verificationToken) {
|
||||
setResetError("Bitte bestätige zuerst deinen Sicherheitsschlüssel.");
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedPassword = password.trim();
|
||||
const trimmedConfirm = confirmPassword.trim();
|
||||
|
||||
if (!trimmedPassword || !trimmedConfirm) {
|
||||
setResetError("Bitte gib das neue Passwort zweimal ein.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedPassword !== trimmedConfirm) {
|
||||
setResetError("Die Passwörter stimmen nicht überein.");
|
||||
return;
|
||||
}
|
||||
|
||||
setResetting(true);
|
||||
try {
|
||||
await axios.post("/api/auth/recover/reset", {
|
||||
token: verificationToken,
|
||||
password: trimmedPassword,
|
||||
confirmPassword: trimmedConfirm
|
||||
});
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Passwort aktualisiert",
|
||||
description: "Du kannst dich jetzt mit deinem neuen Passwort anmelden."
|
||||
});
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
} catch (error) {
|
||||
const serverError = error.response?.data?.error;
|
||||
if (serverError === "TOKEN_INVALID_OR_EXPIRED") {
|
||||
setResetError("Die Wiederherstellungssitzung ist abgelaufen. Bitte starte den Vorgang erneut.");
|
||||
setVerificationToken("");
|
||||
} else if (serverError === "PASSWORD_TOO_SHORT") {
|
||||
setResetError("Das Passwort muss mindestens 8 Zeichen enthalten.");
|
||||
} else if (serverError === "PASSWORD_REQUIRED") {
|
||||
setResetError("Bitte gib ein neues Passwort ein.");
|
||||
} else if (serverError === "PASSWORD_MISMATCH") {
|
||||
setResetError("Die Passwörter stimmen nicht überein.");
|
||||
} else {
|
||||
setResetError("Das Passwort konnte nicht zurückgesetzt werden. Bitte versuche es erneut.");
|
||||
}
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}, [confirmPassword, navigate, password, showToast, verificationToken]);
|
||||
|
||||
const verificationCompleted = Boolean(verificationToken);
|
||||
|
||||
return (
|
||||
<section className="m-8 flex justify-center">
|
||||
<Card className="w-full max-w-3xl">
|
||||
<CardHeader floated={false} shadow={false} className="rounded-none border-b border-blue-gray-50 bg-white px-6 py-5">
|
||||
<Typography variant="h4" color="blue-gray" className="font-semibold">
|
||||
Passwort zurücksetzen
|
||||
</Typography>
|
||||
<Typography color="blue-gray" className="mt-1 text-sm">
|
||||
Gib deinen Benutzernamen und den Sicherheitsschlüssel (8 Wörter) ein oder lade die gespeicherte Schlüsseldatei.
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
Schritt 1: Sicherheitsschlüssel prüfen
|
||||
</Typography>
|
||||
<div>
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Benutzername
|
||||
</Typography>
|
||||
<Input
|
||||
size="lg"
|
||||
placeholder="Benutzername"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
disabled={verificationCompleted || verifying}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{ className: "before:content-none after:content-none" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Sicherheitsschlüssel eingeben
|
||||
</Typography>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4 min-w-[20px] [&>div]:!min-w-0">
|
||||
{phraseInputs.map((value, index) => (
|
||||
<Input
|
||||
key={`phrase-input-${index}`}
|
||||
placeholder={`Wort ${index + 1}`}
|
||||
value={value}
|
||||
onChange={(event) => handlePhraseInputChange(index, event.target.value)}
|
||||
disabled={verificationCompleted || verifying || Boolean(fileContent.trim())}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{ className: "before:content-none after:content-none" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Alternativ: Schlüsseldatei (.txt) hochladen
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
onChange={handleFileUpload}
|
||||
disabled={verificationCompleted || verifying}
|
||||
className="block w-full max-w-xs text-sm text-blue-gray-500 file:mr-4 file:rounded-md file:border-0 file:bg-blue-gray-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-blue-gray-700 hover:file:bg-blue-gray-100"
|
||||
/>
|
||||
{fileName && (
|
||||
<Typography className="text-xs text-blue-gray-500">
|
||||
Ausgewählte Datei: <span className="font-medium text-blue-gray-700">{fileName}</span>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{verificationError && (
|
||||
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
|
||||
{verificationError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
color="blue"
|
||||
className="normal-case"
|
||||
onClick={handleVerifyPhrase}
|
||||
disabled={verifying || verificationCompleted}
|
||||
>
|
||||
{verifying ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="h-4 w-4" /> Prüfe Sicherheitsschlüssel ...
|
||||
</span>
|
||||
) : (
|
||||
"Sicherheitsschlüssel prüfen"
|
||||
)}
|
||||
</Button>
|
||||
{verificationCompleted && (
|
||||
<Typography className="text-sm text-blue-gray-500">
|
||||
Die Wiederherstellung bleibt für kurze Zeit aktiv. Bitte setze dein Passwort zeitnah zurück.
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schritt 2 */}
|
||||
<div className="space-y-4">
|
||||
<Typography variant="h6" color={verificationCompleted ? "blue-gray" : "gray"}>
|
||||
Schritt 2: Neues Passwort festlegen
|
||||
</Typography>
|
||||
|
||||
{!verificationCompleted && (
|
||||
<Typography className="text-sm text-blue-gray-500">
|
||||
Nach erfolgreicher Prüfung des Sicherheitsschlüssels kannst du hier ein neues Passwort vergeben.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{verificationCompleted && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Neues Passwort
|
||||
</Typography>
|
||||
<Input
|
||||
type="password"
|
||||
size="lg"
|
||||
placeholder="Neues Passwort"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
disabled={resetting}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{ className: "before:content-none after:content-none" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Passwort bestätigen
|
||||
</Typography>
|
||||
<Input
|
||||
type="password"
|
||||
size="lg"
|
||||
placeholder="Passwort bestätigen"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
disabled={resetting}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{ className: "before:content-none after:content-none" }}
|
||||
/>
|
||||
</div>
|
||||
{resetError && (
|
||||
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
|
||||
{resetError}
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
color="green"
|
||||
className="normal-case"
|
||||
onClick={handleResetPassword}
|
||||
disabled={resetting}
|
||||
>
|
||||
{resetting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="h-4 w-4" /> Speichere Passwort ...
|
||||
</span>
|
||||
) : (
|
||||
"Neues Passwort speichern"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-blue-gray-50 pt-4">
|
||||
<Typography
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => navigate("/auth/sign-in")}
|
||||
className="text-sm font-medium text-blue-600 transition hover:text-blue-800"
|
||||
>
|
||||
Zurück zur Anmeldung
|
||||
</Typography>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "@/pages/auth/sign-in";
|
||||
export * from "@/pages/auth/sign-up";
|
||||
export * from "@/pages/auth/sign-out";
|
||||
export * from "@/pages/auth/forgot-password";
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
} from "@material-tailwind/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import pattern from "@/assets/images/pattern.png";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
export function SignIn() {
|
||||
const navigate = useNavigate();
|
||||
const { setSession, refreshSession } = useAuth();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [statusChecked, setStatusChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const controller = new AbortController();
|
||||
|
||||
const initialize = async () => {
|
||||
try {
|
||||
const setupResponse = await fetch("/api/setup/status", {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (setupResponse.ok) {
|
||||
const setupData = await setupResponse.json();
|
||||
if (!setupData.setupComplete && isActive) {
|
||||
navigate("/setup", { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionResponse = await fetch("/api/auth/session", {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (sessionResponse.ok) {
|
||||
const sessionData = await sessionResponse.json();
|
||||
if (sessionData?.user) {
|
||||
setSession(sessionData);
|
||||
}
|
||||
if (sessionData?.user && isActive) {
|
||||
const requiresSecurityPhrase = Boolean(sessionData.user.requiresSecurityPhraseDownload);
|
||||
navigate(requiresSecurityPhrase ? "/dashboard/security-phrase" : "/dashboard/stacks", { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error("⚠️ [Auth] Initiale Prüfung fehlgeschlagen:", err);
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
setStatusChecked(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [navigate, setSession]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (loading) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
const trimmedIdentifier = identifier.trim();
|
||||
const trimmedPassword = password.trim();
|
||||
|
||||
if (!trimmedIdentifier || !trimmedPassword) {
|
||||
setError("Bitte fülle beide Felder aus.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ identifier: trimmedIdentifier, password: trimmedPassword }),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || payload.error === "INVALID_CREDENTIALS") {
|
||||
setError("Ungültige Zugangsdaten.");
|
||||
} else if (payload.error === "MISSING_CREDENTIALS") {
|
||||
setError("Bitte fülle beide Felder aus.");
|
||||
} else if (payload.error === "SETUP_REQUIRED" || payload.error === "SUPERUSER_REQUIRED") {
|
||||
navigate("/setup", { replace: true });
|
||||
return;
|
||||
} else if (payload.error === "USER_INACTIVE") {
|
||||
setError("Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.");
|
||||
} else {
|
||||
setError("Anmeldung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionPayload = null;
|
||||
|
||||
if (payload?.user) {
|
||||
setSession(payload);
|
||||
sessionPayload = payload;
|
||||
} else {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed?.ok && refreshed?.data?.user) {
|
||||
sessionPayload = refreshed.data;
|
||||
}
|
||||
}
|
||||
|
||||
const requiresSecurityPhrase = Boolean(sessionPayload?.user?.requiresSecurityPhraseDownload);
|
||||
navigate(requiresSecurityPhrase ? "/dashboard/security-phrase" : "/dashboard/stacks", { replace: true });
|
||||
} catch (err) {
|
||||
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", err);
|
||||
setError("Netzwerkfehler – bitte erneut versuchen.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[identifier, password, loading, navigate, refreshSession, setSession]
|
||||
);
|
||||
|
||||
if (!statusChecked) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Pruefe Anmeldestatus ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="m-8 flex gap-4">
|
||||
<div className="w-full lg:w-3/5 mt-24">
|
||||
<div className="text-center">
|
||||
<Typography variant="h2" className="font-bold mb-4">
|
||||
Anmelden
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="paragraph"
|
||||
color="blue-gray"
|
||||
className="text-lg font-normal"
|
||||
>
|
||||
Gib deine E-Mail oder deinen Benutzernamen sowie dein Passwort ein, um dich anzumelden.
|
||||
</Typography>
|
||||
</div>
|
||||
<form
|
||||
className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="mb-1 flex flex-col gap-6">
|
||||
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
|
||||
E-Mail oder Benutzername
|
||||
</Typography>
|
||||
<Input
|
||||
size="lg"
|
||||
placeholder="E-Mail oder Benutzername"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
|
||||
Passwort
|
||||
</Typography>
|
||||
<Input
|
||||
type="password"
|
||||
size="lg"
|
||||
placeholder="********"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" className="mt-2 border border-red-200 bg-red-50 text-red-700">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="mt-6" fullWidth disabled={loading}>
|
||||
{loading ? "Anmeldung läuft ..." : "Anmelden"}
|
||||
</Button>
|
||||
<Typography
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => navigate("/auth/forgot-password")}
|
||||
className="mt-4 text-sm font-medium text-blue-600 transition hover:text-blue-800"
|
||||
>
|
||||
Passwort vergessen?
|
||||
</Typography>
|
||||
</form>
|
||||
</div>
|
||||
<div className="w-2/5 h-full hidden lg:block">
|
||||
<img src={pattern} className="h-full w-full object-cover rounded-3xl" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignIn;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Card, CardBody, Typography, Spinner } from "@material-tailwind/react";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
export function SignOut() {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
const [status, setStatus] = useState("abmelden...");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const performLogout = async () => {
|
||||
try {
|
||||
setStatus("Abmeldung läuft …");
|
||||
setError(null);
|
||||
await logout();
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("⚠️ [Auth] Logout fehlgeschlagen:", err);
|
||||
setError("Abmeldung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
performLogout();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [logout, navigate]);
|
||||
|
||||
return (
|
||||
<section className="m-8 flex min-h-[60vh] items-center justify-center">
|
||||
<Card className="w-full max-w-md border border-blue-gray-100 shadow-sm">
|
||||
<CardBody className="space-y-4 text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Spinner className="h-5 w-5 text-blue-gray-500" />
|
||||
<Typography color="blue-gray" className="text-sm font-medium">
|
||||
{status}
|
||||
</Typography>
|
||||
</div>
|
||||
{error && (
|
||||
<Typography color="red" className="text-sm">
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography className="text-xs text-blue-gray-400">
|
||||
Du wirst automatisch weitergeleitet …
|
||||
</Typography>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
SignOut.displayName = "/src/pages/auth/sign-out.jsx";
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Card,
|
||||
Input,
|
||||
Checkbox,
|
||||
Button,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
|
||||
export function SignUp() {
|
||||
return (
|
||||
<section className="m-8 flex">
|
||||
<div className="w-2/5 h-full hidden lg:block">
|
||||
<img
|
||||
src="/img/pattern.png"
|
||||
className="h-full w-full object-cover rounded-3xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-3/5 flex flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Typography variant="h2" className="font-bold mb-4">Join Us Today</Typography>
|
||||
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Enter your email and password to register.</Typography>
|
||||
</div>
|
||||
<form className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2">
|
||||
<div className="mb-1 flex flex-col gap-6">
|
||||
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
|
||||
Your email
|
||||
</Typography>
|
||||
<Input
|
||||
size="lg"
|
||||
placeholder="name@mail.com"
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
label={
|
||||
<Typography
|
||||
variant="small"
|
||||
color="gray"
|
||||
className="flex items-center justify-start font-medium"
|
||||
>
|
||||
I agree the
|
||||
<a
|
||||
href="#"
|
||||
className="font-normal text-black transition-colors hover:text-gray-900 underline"
|
||||
>
|
||||
Terms and Conditions
|
||||
</a>
|
||||
</Typography>
|
||||
}
|
||||
containerProps={{ className: "-ml-2.5" }}
|
||||
/>
|
||||
<Button className="mt-6" fullWidth>
|
||||
Register Now
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4 mt-8">
|
||||
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_1156_824)">
|
||||
<path d="M16.3442 8.18429C16.3442 7.64047 16.3001 7.09371 16.206 6.55872H8.66016V9.63937H12.9813C12.802 10.6329 12.2258 11.5119 11.3822 12.0704V14.0693H13.9602C15.4741 12.6759 16.3442 10.6182 16.3442 8.18429Z" fill="#4285F4" />
|
||||
<path d="M8.65974 16.0006C10.8174 16.0006 12.637 15.2922 13.9627 14.0693L11.3847 12.0704C10.6675 12.5584 9.7415 12.8347 8.66268 12.8347C6.5756 12.8347 4.80598 11.4266 4.17104 9.53357H1.51074V11.5942C2.86882 14.2956 5.63494 16.0006 8.65974 16.0006Z" fill="#34A853" />
|
||||
<path d="M4.16852 9.53356C3.83341 8.53999 3.83341 7.46411 4.16852 6.47054V4.40991H1.51116C0.376489 6.67043 0.376489 9.33367 1.51116 11.5942L4.16852 9.53356Z" fill="#FBBC04" />
|
||||
<path d="M8.65974 3.16644C9.80029 3.1488 10.9026 3.57798 11.7286 4.36578L14.0127 2.08174C12.5664 0.72367 10.6469 -0.0229773 8.65974 0.000539111C5.63494 0.000539111 2.86882 1.70548 1.51074 4.40987L4.1681 6.4705C4.8001 4.57449 6.57266 3.16644 8.65974 3.16644Z" fill="#EA4335" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1156_824">
|
||||
<rect width="16" height="16" fill="white" transform="translate(0.5)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>Sign in With Google</span>
|
||||
</Button>
|
||||
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
|
||||
<img src="/img/twitter-logo.svg" height={24} width={24} alt="" />
|
||||
<span>Sign in With Twitter</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Typography variant="paragraph" className="text-center text-blue-gray-500 font-medium mt-4">
|
||||
Already have an account?
|
||||
<Link to="/auth/sign-in" className="text-gray-900 ml-1">Sign in</Link>
|
||||
</Typography>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUp;
|
||||
@@ -0,0 +1,289 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuHandler,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
Progress,
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
ArrowUpIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { StatisticsCard } from "@/widgets/cards";
|
||||
import { StatisticsChart } from "@/widgets/charts";
|
||||
import {
|
||||
statisticsCardsData,
|
||||
statisticsChartsData,
|
||||
projectsTableData,
|
||||
ordersOverviewData,
|
||||
} from "@/data";
|
||||
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/24/solid";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
|
||||
const UPDATE_STAGE_LABELS = {
|
||||
initializing: "Vorbereitung",
|
||||
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||
"executing-script": "Skript wird ausgeführt",
|
||||
waiting: "Warte auf Portainer",
|
||||
completed: "Abgeschlossen",
|
||||
failed: "Fehlgeschlagen"
|
||||
};
|
||||
|
||||
export function Home() {
|
||||
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
const maintenanceMessage = maintenanceMeta?.message;
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
|
||||
return (
|
||||
<div className="mt-12">
|
||||
{(maintenanceActive || updateRunning) && (
|
||||
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100 mb-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||
</span>
|
||||
{updateRunning && (
|
||||
<span className="text-xs text-indigo-900">
|
||||
Phase: {updateStageLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-12 grid gap-y-10 gap-x-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{statisticsCardsData.map(({ icon, title, footer, ...rest }) => (
|
||||
<StatisticsCard
|
||||
key={title}
|
||||
{...rest}
|
||||
title={title}
|
||||
icon={React.createElement(icon, {
|
||||
className: "w-6 h-6 text-white",
|
||||
})}
|
||||
footer={
|
||||
<Typography className="font-normal text-blue-gray-600">
|
||||
<strong className={footer.color}>{footer.value}</strong>
|
||||
{footer.label}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-6 grid grid-cols-1 gap-y-12 gap-x-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{statisticsChartsData.map((props) => (
|
||||
<StatisticsChart
|
||||
key={props.title}
|
||||
{...props}
|
||||
footer={
|
||||
<Typography
|
||||
variant="small"
|
||||
className="flex items-center font-normal text-blue-gray-600"
|
||||
>
|
||||
<ClockIcon strokeWidth={2} className="h-4 w-4 text-blue-gray-400" />
|
||||
{props.footer}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<Card className="overflow-hidden xl:col-span-2 border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
floated={false}
|
||||
shadow={false}
|
||||
color="transparent"
|
||||
className="m-0 flex items-center justify-between p-6"
|
||||
>
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-1">
|
||||
Projects
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="flex items-center gap-1 font-normal text-blue-gray-600"
|
||||
>
|
||||
<CheckCircleIcon strokeWidth={3} className="h-4 w-4 text-blue-gray-200" />
|
||||
<strong>30 done</strong> this month
|
||||
</Typography>
|
||||
</div>
|
||||
<Menu placement="left-start">
|
||||
<MenuHandler>
|
||||
<IconButton size="sm" variant="text" color="blue-gray" disabled={maintenanceLocked}>
|
||||
<EllipsisVerticalIcon
|
||||
strokeWidth={3}
|
||||
fill="currenColor"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</IconButton>
|
||||
</MenuHandler>
|
||||
<MenuList>
|
||||
<MenuItem>Action</MenuItem>
|
||||
<MenuItem>Another Action</MenuItem>
|
||||
<MenuItem>Something else here</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</CardHeader>
|
||||
<CardBody className="overflow-x-scroll px-0 pt-0 pb-2">
|
||||
<table className="w-full min-w-[640px] table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
{["companies", "members", "budget", "completion"].map(
|
||||
(el) => (
|
||||
<th
|
||||
key={el}
|
||||
className="border-b border-blue-gray-50 py-3 px-6 text-left"
|
||||
>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="text-[11px] font-medium uppercase text-blue-gray-400"
|
||||
>
|
||||
{el}
|
||||
</Typography>
|
||||
</th>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projectsTableData.map(
|
||||
({ img, name, members, budget, completion }, key) => {
|
||||
const className = `py-3 px-5 ${
|
||||
key === projectsTableData.length - 1
|
||||
? ""
|
||||
: "border-b border-blue-gray-50"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td className={className}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar src={img} alt={name} size="sm" />
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="font-bold"
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
</div>
|
||||
</td>
|
||||
<td className={className}>
|
||||
{members.map(({ img, name }, key) => (
|
||||
<Tooltip key={name} content={name}>
|
||||
<Avatar
|
||||
src={img}
|
||||
alt={name}
|
||||
size="xs"
|
||||
variant="circular"
|
||||
className={`cursor-pointer border-2 border-white ${
|
||||
key === 0 ? "" : "-ml-2.5"
|
||||
}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</td>
|
||||
<td className={className}>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="text-xs font-medium text-blue-gray-600"
|
||||
>
|
||||
{budget}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={className}>
|
||||
<div className="w-10/12">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mb-1 block text-xs font-medium text-blue-gray-600"
|
||||
>
|
||||
{completion}%
|
||||
</Typography>
|
||||
<Progress
|
||||
value={completion}
|
||||
variant="gradient"
|
||||
color={completion === 100 ? "green" : "blue"}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
floated={false}
|
||||
shadow={false}
|
||||
color="transparent"
|
||||
className="m-0 p-6"
|
||||
>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||
Orders Overview
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="flex items-center gap-1 font-normal text-blue-gray-600"
|
||||
>
|
||||
<ArrowUpIcon
|
||||
strokeWidth={3}
|
||||
className="h-3.5 w-3.5 text-green-500"
|
||||
/>
|
||||
<strong>24%</strong> this month
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{ordersOverviewData.map(
|
||||
({ icon, color, title, description }, key) => (
|
||||
<div key={title} className="flex items-start gap-4 py-3">
|
||||
<div
|
||||
className={`relative p-1 after:absolute after:-bottom-6 after:left-2/4 after:w-0.5 after:-translate-x-2/4 after:bg-blue-gray-50 after:content-[''] ${
|
||||
key === ordersOverviewData.length - 1
|
||||
? "after:h-0"
|
||||
: "after:h-4/6"
|
||||
}`}
|
||||
>
|
||||
{React.createElement(icon, {
|
||||
className: `!w-5 !h-5 ${color}`,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="block font-medium"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
as="span"
|
||||
variant="small"
|
||||
className="text-xs font-medium text-blue-gray-500"
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "@/pages/dashboard/stacks";
|
||||
export * from "@/pages/dashboard/maintenance";
|
||||
export * from "@/pages/dashboard/logs";
|
||||
export * from "@/pages/dashboard/users";
|
||||
export * from "@/pages/dashboard/usergroups";
|
||||
export * from "@/pages/dashboard/userDetails";
|
||||
export * from "@/pages/dashboard/userGroupDetail";
|
||||
export * from "@/pages/dashboard/securityPhrase";
|
||||
@@ -0,0 +1,265 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Typography,
|
||||
Button,
|
||||
Spinner,
|
||||
Alert
|
||||
} from "@material-tailwind/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowDownTrayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
const UPDATE_STAGE_LABELS = {
|
||||
initializing: "Vorbereitung",
|
||||
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||
"executing-script": "Skript wird ausgeführt",
|
||||
waiting: "Warte auf Portainer",
|
||||
completed: "Abgeschlossen",
|
||||
failed: "Fehlgeschlagen"
|
||||
};
|
||||
|
||||
const formatWordsAsText = (words = []) => {
|
||||
if (!Array.isArray(words) || words.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const rows = [];
|
||||
for (let index = 0; index < words.length; index += 4) {
|
||||
rows.push(words.slice(index, index + 4).join(" "));
|
||||
}
|
||||
return rows.join("\n");
|
||||
};
|
||||
|
||||
const groupWords = (words = []) => {
|
||||
if (!Array.isArray(words)) {
|
||||
return [];
|
||||
}
|
||||
const rows = [];
|
||||
for (let index = 0; index < words.length; index += 4) {
|
||||
rows.push(words.slice(index, index + 4));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
export function SecurityPhrase() {
|
||||
const navigate = useNavigate();
|
||||
const { user, setSession, refreshSession } = useAuth();
|
||||
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [words, setWords] = useState([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
|
||||
const requiresDownload = Boolean(user?.requiresSecurityPhraseDownload);
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
const maintenanceMessage = maintenanceMeta?.message;
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
|
||||
const groupedWords = useMemo(() => groupWords(words), [words]);
|
||||
|
||||
const triggerDownload = useCallback((content) => {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
const baseName = user?.username ? `${user.username}-sicherheitsschluessel` : "security-phrase";
|
||||
link.download = `${baseName}.txt`;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [user?.username]);
|
||||
|
||||
const fetchPhrase = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await axios.get("/api/users/me/security-phrase");
|
||||
const phraseWords = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
|
||||
if (!phraseWords.length) {
|
||||
throw new Error("EMPTY_SECURITY_PHRASE");
|
||||
}
|
||||
setWords(phraseWords);
|
||||
setDownloaded(false);
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 409 || err?.response?.data?.error === "SECURITY_PHRASE_ALREADY_DOWNLOADED") {
|
||||
await refreshSession();
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
return;
|
||||
}
|
||||
if (err?.response?.status === 401) {
|
||||
await refreshSession();
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = err?.response?.data?.error || err?.message || "Sicherheitsschlüssel konnte nicht geladen werden.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [navigate, refreshSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requiresDownload) {
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
fetchPhrase();
|
||||
}, [user, requiresDownload, fetchPhrase, navigate]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
fetchPhrase();
|
||||
}, [fetchPhrase]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (saving || !Array.isArray(words) || words.length === 0) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const text = formatWordsAsText(words);
|
||||
triggerDownload(text);
|
||||
|
||||
const response = await axios.post("/api/users/me/security-phrase/downloaded");
|
||||
const downloadedAt = response.data?.item?.downloadedAt || null;
|
||||
const sessionPayload = response.data?.session;
|
||||
|
||||
if (sessionPayload?.user) {
|
||||
setSession(sessionPayload);
|
||||
} else {
|
||||
await refreshSession();
|
||||
}
|
||||
|
||||
setDownloaded(Boolean(downloadedAt));
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 401) {
|
||||
await refreshSession();
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
return;
|
||||
}
|
||||
const message = err?.response?.data?.error || err?.message || "Download konnte nicht bestätigt werden.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [words, saving, triggerDownload, refreshSession, navigate, setSession]);
|
||||
|
||||
if (!requiresDownload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-blue-gray-50/50 px-4 py-10">
|
||||
<div className="mb-6 text-center">
|
||||
<Typography variant="h2" className="font-semibold text-blue-gray-800">
|
||||
Sicherheitsschlüssel sichern
|
||||
</Typography>
|
||||
<Typography color="blue-gray" className="mt-2 max-w-3xl text-base">
|
||||
Bitte lade den einmaligen Sicherheitsschlüssel herunter und bewahre ihn sicher auf. Er wird benötigt, um dein Passwort zurückzusetzen.
|
||||
Ohne den Download kannst du die Anwendung nicht weiter nutzen.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Card className="w-full max-w-3xl shadow-lg">
|
||||
<CardBody className="space-y-6">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Spinner className="h-10 w-10 text-blue-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<Alert
|
||||
color="red"
|
||||
className="border border-red-200 bg-red-50 text-red-700"
|
||||
icon={<ArrowPathIcon className="h-5 w-5" />}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<Typography variant="h6" color="red">
|
||||
Fehler
|
||||
</Typography>
|
||||
<Typography color="red" className="text-sm">
|
||||
{error}
|
||||
</Typography>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="red"
|
||||
variant="text"
|
||||
onClick={handleRetry}
|
||||
className="normal-case"
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 p-6">
|
||||
<Typography variant="h5" className="mb-4 text-blue-gray-800">
|
||||
Deine Sicherheitsphrase
|
||||
</Typography>
|
||||
<div className="space-y-3">
|
||||
{groupedWords.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`phrase-row-${rowIndex}`}
|
||||
className="flex flex-wrap items-center justify-center gap-4 text-lg font-mono tracking-wide text-blue-gray-900 md:text-xl"
|
||||
>
|
||||
{row.map((word, wordIndex) => (
|
||||
<span key={`phrase-${rowIndex}-${wordIndex}`} className="uppercase">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||
<div className="text-sm text-blue-gray-600">
|
||||
Der Download startet im TXT-Format. Bewahre die Datei sicher auf – sie kann später nicht erneut angezeigt werden.
|
||||
</div>
|
||||
<Button
|
||||
color="blue"
|
||||
size="md"
|
||||
className="flex items-center gap-2 normal-case"
|
||||
onClick={handleDownload}
|
||||
disabled={maintenanceLocked || saving || downloaded || words.length === 0}
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||
Sicherheitsschlüssel herunterladen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SecurityPhrase;
|
||||
@@ -0,0 +1,907 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Typography,
|
||||
Button,
|
||||
Spinner,
|
||||
Select,
|
||||
Option,
|
||||
Input,
|
||||
Switch
|
||||
} from "@material-tailwind/react";
|
||||
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { platformSettingsData } from "@/data";
|
||||
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
const _ = AVATAR_COLORS.join(" ");
|
||||
|
||||
const UPDATE_STAGE_LABELS = {
|
||||
initializing: "Vorbereitung",
|
||||
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||
"executing-script": "Skript wird ausgeführt",
|
||||
waiting: "Warte auf Portainer",
|
||||
completed: "Abgeschlossen",
|
||||
failed: "Fehlgeschlagen"
|
||||
};
|
||||
|
||||
const normalizeUserGroups = (rawGroups) => {
|
||||
if (!Array.isArray(rawGroups)) {
|
||||
return [];
|
||||
}
|
||||
return rawGroups
|
||||
.map((group) => {
|
||||
if (group && typeof group === "object") {
|
||||
const id = Number(group.id);
|
||||
const name = typeof group.name === "string" ? group.name : "";
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: Number.isFinite(id) ? id : null,
|
||||
name
|
||||
};
|
||||
}
|
||||
if (typeof group === "string") {
|
||||
const name = group.trim();
|
||||
return name ? { id: null, name } : null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const mapUser = (item) => ({
|
||||
id: item?.id ?? null,
|
||||
username: item?.username || "",
|
||||
email: item?.email || "",
|
||||
isActive: Boolean(item?.isActive),
|
||||
avatarColor: item?.avatarColor || null,
|
||||
lastLogin: item?.lastLogin || null,
|
||||
createdAt: item?.createdAt || null,
|
||||
updatedAt: item?.updatedAt || null,
|
||||
groups: normalizeUserGroups(item?.groups),
|
||||
securityPhraseDownloadedAt: item?.securityPhraseDownloadedAt || null
|
||||
});
|
||||
|
||||
const extractPrimaryGroupId = (user) => {
|
||||
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const firstValid = user.groups
|
||||
.map((group) => Number(group.id))
|
||||
.find((id) => Number.isFinite(id) && id > 0);
|
||||
return Number.isFinite(firstValid) ? firstValid : null;
|
||||
};
|
||||
|
||||
const buildInitialFormValues = (user) => {
|
||||
if (!user) {
|
||||
return {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
groupId: null,
|
||||
avatarColor: ""
|
||||
};
|
||||
}
|
||||
|
||||
const primaryGroupId = extractPrimaryGroupId(user);
|
||||
|
||||
return {
|
||||
username: user.username || "",
|
||||
email: user.email || "",
|
||||
password: "",
|
||||
groupId: Number.isFinite(primaryGroupId) ? primaryGroupId : null,
|
||||
avatarColor: user.avatarColor || ""
|
||||
};
|
||||
};
|
||||
|
||||
export function UserDetails() {
|
||||
const { userId } = useParams();
|
||||
const { showToast } = useToast();
|
||||
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||
const { hasPermission, user: authUser } = useAuth();
|
||||
|
||||
const [user, setUser] = useState(null);
|
||||
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||
const initialFormValuesRef = useRef(buildInitialFormValues(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [availableGroups, setAvailableGroups] = useState([]);
|
||||
const [groupsLoading, setGroupsLoading] = useState(false);
|
||||
const [groupsError, setGroupsError] = useState("");
|
||||
const [savingUser, setSavingUser] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [securityPhraseWords, setSecurityPhraseWords] = useState([]);
|
||||
const [securityPhraseDownloadedAt, setSecurityPhraseDownloadedAt] = useState(null);
|
||||
const [securityPhraseLoading, setSecurityPhraseLoading] = useState(false);
|
||||
const [securityPhraseError, setSecurityPhraseError] = useState("");
|
||||
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
|
||||
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
const maintenanceMessage = maintenanceMeta?.message;
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
|
||||
const canEditUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "full"));
|
||||
const canReadUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "read"));
|
||||
const canManageSecurityPhrase = Boolean(authUser?.isSuperuser || hasPermission("users-security-phrase", "full"));
|
||||
const canRenewSecurityPhrase = canManageSecurityPhrase;
|
||||
|
||||
if (!canReadUsers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSuperuserUser = useMemo(() => {
|
||||
if (!Array.isArray(user?.groups)) {
|
||||
return false;
|
||||
}
|
||||
return user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser");
|
||||
}, [user]);
|
||||
const canCurrentUserEditSuperuser = authUser?.isSuperuser && user?.id && Number(authUser.id) === Number(user.id);
|
||||
const superuserFieldsLocked = isSuperuserUser && !canCurrentUserEditSuperuser;
|
||||
|
||||
const numericUserId = useMemo(() => {
|
||||
const asNumber = Number(userId);
|
||||
return Number.isFinite(asNumber) ? asNumber : null;
|
||||
}, [userId]);
|
||||
|
||||
const fetchUserDetails = useCallback(async () => {
|
||||
if (!numericUserId) {
|
||||
setError("Ungültige Benutzer-ID.");
|
||||
setUser(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/users/${numericUserId}`);
|
||||
const item = mapUser(response.data?.item);
|
||||
if (!item.id) {
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
setUser(item);
|
||||
setSecurityPhraseWords([]);
|
||||
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
|
||||
setSecurityPhraseError("");
|
||||
const initialValues = buildInitialFormValues(item);
|
||||
initialFormValuesRef.current = { ...initialValues };
|
||||
setFormValues(initialValues);
|
||||
setSaveError("");
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Benutzerdetails konnten nicht geladen werden.";
|
||||
|
||||
if (serverError === "USER_NOT_FOUND") {
|
||||
message = "Der angeforderte Benutzer wurde nicht gefunden.";
|
||||
} else if (serverError === "INVALID_USER_ID") {
|
||||
message = "Die angegebene Benutzer-ID ist ungültig.";
|
||||
} else if (err.response?.status === 404) {
|
||||
message = "Der angeforderte Benutzer existiert nicht.";
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
setError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Fehler beim Laden",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
}
|
||||
}, [numericUserId, showToast]);
|
||||
|
||||
const fetchAvailableGroups = useCallback(async () => {
|
||||
setGroupsLoading(true);
|
||||
setGroupsError("");
|
||||
try {
|
||||
const response = await axios.get("/api/groups");
|
||||
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
const normalized = items
|
||||
.map((item) => ({
|
||||
id: Number(item.id),
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
memberCount: Number.isFinite(Number(item.memberCount)) ? Number(item.memberCount) : 0
|
||||
}))
|
||||
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
|
||||
setAvailableGroups(normalized);
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Benutzergruppen konnten nicht geladen werden.";
|
||||
setGroupsError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Benutzergruppen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setGroupsLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserDetails();
|
||||
}, [fetchUserDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAvailableGroups();
|
||||
}, [fetchAvailableGroups]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!hasLoaded || !user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initial = initialFormValuesRef.current;
|
||||
if (!initial) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialUsername = initial.username || "";
|
||||
const currentUsername = formValues.username || "";
|
||||
|
||||
const initialEmail = initial.email || "";
|
||||
const currentEmail = formValues.email || "";
|
||||
|
||||
const initialGroupId = Number.isFinite(initial.groupId) ? Number(initial.groupId) : null;
|
||||
const currentGroupId = Number.isFinite(formValues.groupId) ? Number(formValues.groupId) : null;
|
||||
|
||||
const initialAvatar = initial.avatarColor || "";
|
||||
const currentAvatar = formValues.avatarColor || "";
|
||||
|
||||
const passwordChanged = Boolean(formValues.password && formValues.password.trim().length > 0);
|
||||
|
||||
return (
|
||||
initialUsername !== currentUsername ||
|
||||
initialEmail !== currentEmail ||
|
||||
(!isSuperuserUser && initialGroupId !== currentGroupId) ||
|
||||
initialAvatar !== currentAvatar ||
|
||||
passwordChanged
|
||||
);
|
||||
}, [formValues, hasLoaded, user, isSuperuserUser]);
|
||||
|
||||
const renderSelectedAvatarLabel = useCallback(
|
||||
(element) => {
|
||||
if (element?.props?.children) {
|
||||
return element.props.children;
|
||||
}
|
||||
if (!formValues.avatarColor) {
|
||||
return "Standardfarbe";
|
||||
}
|
||||
return formValues.avatarColor;
|
||||
},
|
||||
[formValues.avatarColor]
|
||||
);
|
||||
|
||||
const handleUsernameChange = useCallback((event) => {
|
||||
if (!canEditUsers) return;
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
username: value
|
||||
}));
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handleEmailChange = useCallback((event) => {
|
||||
if (!canEditUsers) return;
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
email: value
|
||||
}));
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handlePasswordChange = useCallback((event) => {
|
||||
if (!canEditUsers) return;
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
password: value
|
||||
}));
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handleGroupChange = useCallback((value) => {
|
||||
if (!canEditUsers) return;
|
||||
if (!value) {
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
groupId: null
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
groupId: Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
||||
}));
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handleAvatarColorChange = useCallback((value) => {
|
||||
if (!canEditUsers) return;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
avatarColor: value || ""
|
||||
}));
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handleSaveUser = useCallback(async () => {
|
||||
if (!canEditUsers || !user || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingUser(true);
|
||||
setSaveError("");
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
username: formValues.username,
|
||||
email: formValues.email,
|
||||
password: formValues.password,
|
||||
avatarColor: formValues.avatarColor
|
||||
};
|
||||
|
||||
if (!isSuperuserUser) {
|
||||
payload.groupId = formValues.groupId;
|
||||
}
|
||||
|
||||
const response = await axios.put(`/api/users/${user.id}`, payload);
|
||||
const updatedUser = mapUser(response.data?.item || response.data?.user);
|
||||
setUser(updatedUser);
|
||||
const nextInitial = buildInitialFormValues(updatedUser);
|
||||
initialFormValuesRef.current = { ...nextInitial };
|
||||
setFormValues(nextInitial);
|
||||
setGroupsError("");
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Benutzer gespeichert",
|
||||
description: "Die Änderungen wurden erfolgreich gespeichert."
|
||||
});
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Die Benutzerdaten konnten nicht gespeichert werden.";
|
||||
|
||||
if (serverError === "USERNAME_REQUIRED") {
|
||||
message = "Bitte einen Benutzernamen angeben.";
|
||||
} else if (serverError === "USERNAME_TAKEN") {
|
||||
message = "Der Benutzername wird bereits verwendet.";
|
||||
} else if (serverError === "INVALID_EMAIL") {
|
||||
message = "Bitte eine gültige E-Mail-Adresse eingeben.";
|
||||
} else if (serverError === "EMAIL_TAKEN") {
|
||||
message = "Die E-Mail-Adresse wird bereits verwendet.";
|
||||
} else if (serverError === "INVALID_PASSWORD") {
|
||||
message = "Das Passwort ist ungültig.";
|
||||
} else if (serverError === "PASSWORD_TOO_SHORT") {
|
||||
message = "Das Passwort muss mindestens 8 Zeichen enthalten.";
|
||||
} else if (serverError === "INVALID_AVATAR_COLOR") {
|
||||
message = "Bitte eine gültige Avatar-Farbe auswählen.";
|
||||
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Die ausgewählte Benutzergruppe existiert nicht mehr.";
|
||||
setGroupsError(message);
|
||||
}
|
||||
|
||||
setSaveError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Speichern fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setSavingUser(false);
|
||||
}
|
||||
}, [canEditUsers, user, hasChanges, formValues, showToast, isSuperuserUser]);
|
||||
|
||||
const fetchSecurityPhrase = useCallback(async () => {
|
||||
if (!canManageSecurityPhrase || !numericUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSecurityPhraseLoading(true);
|
||||
setSecurityPhraseError("");
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/users/${numericUserId}/security-phrase`);
|
||||
const words = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
|
||||
setSecurityPhraseWords(words);
|
||||
setSecurityPhraseDownloadedAt(response.data?.item?.downloadedAt || null);
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Sicherheitsschlüssel konnte nicht geladen werden.";
|
||||
|
||||
if (serverError === "USER_NOT_FOUND") {
|
||||
message = "Der Benutzer wurde nicht gefunden.";
|
||||
} else if (serverError === "INVALID_USER_ID") {
|
||||
message = "Die Benutzer-ID ist ungültig.";
|
||||
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
|
||||
message = "Keine Berechtigung zum Anzeigen des Sicherheitsschlüssels.";
|
||||
}
|
||||
|
||||
setSecurityPhraseError(message);
|
||||
setSecurityPhraseWords([]);
|
||||
setSecurityPhraseDownloadedAt(null);
|
||||
} finally {
|
||||
setSecurityPhraseLoading(false);
|
||||
}
|
||||
}, [canManageSecurityPhrase, numericUserId]);
|
||||
|
||||
const handleReloadSecurityPhrase = useCallback(() => {
|
||||
if (!canRenewSecurityPhrase) {
|
||||
return;
|
||||
}
|
||||
if (securityPhraseLoading || renewingSecurityPhrase) {
|
||||
return;
|
||||
}
|
||||
fetchSecurityPhrase();
|
||||
}, [canRenewSecurityPhrase, fetchSecurityPhrase, renewingSecurityPhrase, securityPhraseLoading]);
|
||||
|
||||
const handleRenewSecurityPhrase = useCallback(async () => {
|
||||
if (!canRenewSecurityPhrase || !numericUserId || maintenanceLocked || renewingSecurityPhrase) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRenewingSecurityPhrase(true);
|
||||
setSecurityPhraseError("");
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/users/${numericUserId}/security-phrase/renew`);
|
||||
const words = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
|
||||
setSecurityPhraseWords(words);
|
||||
setSecurityPhraseDownloadedAt(response.data?.item?.downloadedAt || null);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Sicherheitsschlüssel erneuert",
|
||||
description: "Der Benutzer muss den neuen Schlüssel erneut herunterladen."
|
||||
});
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Der Sicherheitsschlüssel konnte nicht erneuert werden.";
|
||||
|
||||
if (serverError === "USER_NOT_FOUND") {
|
||||
message = "Der Benutzer wurde nicht gefunden.";
|
||||
} else if (serverError === "INVALID_USER_ID") {
|
||||
message = "Die Benutzer-ID ist ungültig.";
|
||||
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
|
||||
message = "Keine Berechtigung zum Erneuern des Sicherheitsschlüssels.";
|
||||
}
|
||||
|
||||
setSecurityPhraseError(message);
|
||||
} finally {
|
||||
setRenewingSecurityPhrase(false);
|
||||
}
|
||||
}, [
|
||||
canRenewSecurityPhrase,
|
||||
numericUserId,
|
||||
maintenanceLocked,
|
||||
renewingSecurityPhrase,
|
||||
showToast
|
||||
]);
|
||||
|
||||
const avatarLabel = useMemo(() => {
|
||||
const source = (formValues.username || formValues.email || "").trim();
|
||||
if (!source) {
|
||||
return "?";
|
||||
}
|
||||
return source.charAt(0).toUpperCase();
|
||||
}, [formValues.username, formValues.email]);
|
||||
|
||||
const avatarColorClass = useMemo(() => {
|
||||
if (formValues.avatarColor) {
|
||||
return formValues.avatarColor;
|
||||
}
|
||||
return user?.avatarColor || "";
|
||||
}, [formValues.avatarColor, user]);
|
||||
|
||||
const securityPhraseRows = useMemo(() => {
|
||||
if (!Array.isArray(securityPhraseWords) || securityPhraseWords.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const rows = [];
|
||||
for (let index = 0; index < securityPhraseWords.length; index += 4) {
|
||||
rows.push(securityPhraseWords.slice(index, index + 4));
|
||||
}
|
||||
return rows;
|
||||
}, [securityPhraseWords]);
|
||||
|
||||
const securityPhraseStatus = useMemo(() => {
|
||||
if (!securityPhraseDownloadedAt) {
|
||||
return {
|
||||
text: "Noch nicht heruntergeladen",
|
||||
tone: "text-red-500"
|
||||
};
|
||||
}
|
||||
const date = new Date(securityPhraseDownloadedAt);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return {
|
||||
text: "Zuletzt heruntergeladen: unbekannt",
|
||||
tone: "text-blue-gray-500"
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: `Zuletzt heruntergeladen: ${date.toLocaleString("de-DE")}`,
|
||||
tone: "text-blue-gray-500"
|
||||
};
|
||||
}, [securityPhraseDownloadedAt]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!Array.isArray(availableGroups)) {
|
||||
return [];
|
||||
}
|
||||
return availableGroups.filter((group) => (group?.name || "").toLowerCase() !== "superuser");
|
||||
}, [availableGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuperuserUser) {
|
||||
return;
|
||||
}
|
||||
const currentGroupId = formValues.groupId ? Number(formValues.groupId) : null;
|
||||
const stillExists = filteredGroups.some((group) => Number(group.id) === currentGroupId);
|
||||
if (!stillExists) {
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
groupId: null
|
||||
}));
|
||||
}
|
||||
}, [filteredGroups, formValues.groupId, isSuperuserUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canManageSecurityPhrase || !numericUserId) {
|
||||
setSecurityPhraseWords([]);
|
||||
setSecurityPhraseDownloadedAt(null);
|
||||
setSecurityPhraseError("");
|
||||
setSecurityPhraseLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchSecurityPhrase();
|
||||
}, [hasLoaded, canManageSecurityPhrase, fetchSecurityPhrase, numericUserId]);
|
||||
|
||||
const inputDisabled = maintenanceLocked || savingUser || !user || !canEditUsers;
|
||||
const selectDisabled = maintenanceLocked || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
|
||||
const avatarSelectDisabled = maintenanceLocked || savingUser || !user || !canEditUsers || superuserFieldsLocked;
|
||||
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-12 flex flex-col gap-12">
|
||||
{(maintenanceActive || updateRunning) && (
|
||||
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||
</span>
|
||||
{updateRunning && (
|
||||
<span className="text-xs text-indigo-900">
|
||||
Phase: {updateStageLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
||||
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||
</div>
|
||||
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||
<CardBody className="p-4">
|
||||
<div className="mb-10 flex flex-wrap items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div
|
||||
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${avatarColorClass}`}
|
||||
aria-label={formValues.username || "Benutzeravatar"}
|
||||
>
|
||||
{avatarLabel}
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
{formValues.username || "–"}
|
||||
</Typography>
|
||||
<Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
|
||||
{formValues.email || "–"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<Button
|
||||
color="green"
|
||||
className="normal-case"
|
||||
onClick={handleSaveUser}
|
||||
disabled={maintenanceLocked || savingUser}
|
||||
>
|
||||
{savingUser ? "Speichert ..." : "Änderungen speichern"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{loading && !user && (
|
||||
<div className="mb-6 flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Benutzerdaten werden geladen ...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{saveError && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||
Allgemeine Einstellungen
|
||||
</Typography>
|
||||
{(isSuperuserUser) && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Systemgruppe – Name und Beschreibung sind geschützt.
|
||||
{superuserFieldsLocked && (
|
||||
<span className="mt-1 block text-xs text-amber-600">
|
||||
Nur der Superuser selbst kann Benutzername, E-Mail-Adresse und Passwort ändern.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Benutzername
|
||||
</Typography>
|
||||
<Input
|
||||
value={formValues.username}
|
||||
onChange={handleUsernameChange}
|
||||
placeholder="Benutzername"
|
||||
disabled={inputDisabled || superuserFieldsLocked}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
E-Mail-Adresse
|
||||
</Typography>
|
||||
<Input
|
||||
type="email"
|
||||
value={formValues.email}
|
||||
onChange={handleEmailChange}
|
||||
placeholder="benutzer@example.com"
|
||||
disabled={inputDisabled || superuserFieldsLocked}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Neues Passwort
|
||||
</Typography>
|
||||
<Input
|
||||
type="password"
|
||||
value={formValues.password}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder="Passwort setzen"
|
||||
disabled={inputDisabled || superuserFieldsLocked}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
<Typography className="mt-1 text-xs text-blue-gray-400">
|
||||
Das Passwort wird nur geändert, wenn ein neuer Wert eingetragen wird.
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Globale Rolle
|
||||
</Typography>
|
||||
{isSuperuserUser ? (
|
||||
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Die Rolle für den Superuser kann nicht geändert werden.
|
||||
</Typography>
|
||||
) : groupsLoading && availableGroups.length === 0 ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Benutzergruppen werden geladen ...</span>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Es sind keine weiteren Benutzergruppen vorhanden. Bitte lege zunächst eine neue Gruppe an.
|
||||
</Typography>
|
||||
) : (
|
||||
<Select
|
||||
label="Benutzergruppe wählen"
|
||||
value={groupSelectValue}
|
||||
onChange={handleGroupChange}
|
||||
disabled={selectDisabled}
|
||||
variant="outlined"
|
||||
>
|
||||
{filteredGroups.map((group) => (
|
||||
<Option key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Avatar-Farbe
|
||||
</Typography>
|
||||
<Select
|
||||
label="Avatar-Farbe auswählen"
|
||||
variant="outlined"
|
||||
value={formValues.avatarColor}
|
||||
onChange={handleAvatarColorChange}
|
||||
disabled={avatarSelectDisabled}
|
||||
selected={renderSelectedAvatarLabel}
|
||||
>
|
||||
<Option value="">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded border border-blue-gray-100" />
|
||||
<span className="text-xs">Keine</span>
|
||||
</span>
|
||||
</Option>
|
||||
{AVATAR_COLORS.map((color) => (
|
||||
<Option key={color} value={color}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
|
||||
<span className="text-xs">{color}</span>
|
||||
</span>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||
Platform Settings
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-12">
|
||||
{platformSettingsData.map(({ title, options }) => (
|
||||
<div key={title}>
|
||||
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
{title}
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-6">
|
||||
{options.map(({ checked, label }) => (
|
||||
<Switch
|
||||
key={label}
|
||||
id={label}
|
||||
label={label}
|
||||
defaultChecked={checked}
|
||||
labelProps={{
|
||||
className: "text-sm font-normal text-blue-gray-500",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManageSecurityPhrase && (
|
||||
<div className="mt-10 border-t border-blue-gray-50 pt-8">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
Sicherheitsschlüssel
|
||||
</Typography>
|
||||
<Typography className={`text-sm ${securityPhraseStatus.tone}`}>
|
||||
{securityPhraseStatus.text}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="blue"
|
||||
className="normal-case"
|
||||
onClick={handleReloadSecurityPhrase}
|
||||
disabled={
|
||||
!canRenewSecurityPhrase ||
|
||||
maintenanceLocked ||
|
||||
securityPhraseLoading ||
|
||||
renewingSecurityPhrase
|
||||
}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
className="normal-case"
|
||||
onClick={handleRenewSecurityPhrase}
|
||||
disabled={
|
||||
!canRenewSecurityPhrase ||
|
||||
maintenanceLocked ||
|
||||
securityPhraseLoading ||
|
||||
renewingSecurityPhrase
|
||||
}
|
||||
>
|
||||
{renewingSecurityPhrase ? "Erneuert ..." : "Schlüssel erneuern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{securityPhraseLoading ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Sicherheitsschlüssel wird geladen ...</span>
|
||||
</div>
|
||||
) : securityPhraseError ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<span>{securityPhraseError}</span>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
color="red"
|
||||
className="normal-case"
|
||||
onClick={handleReloadSecurityPhrase}
|
||||
disabled={
|
||||
!canRenewSecurityPhrase ||
|
||||
maintenanceLocked ||
|
||||
securityPhraseLoading ||
|
||||
renewingSecurityPhrase
|
||||
}
|
||||
>
|
||||
Erneut laden
|
||||
</Button>
|
||||
</div>
|
||||
) : securityPhraseRows.length > 0 ? (
|
||||
<div className="rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 p-4">
|
||||
<div className="space-y-2 text-center font-mono text-base tracking-wide text-blue-gray-900 md:text-lg">
|
||||
{securityPhraseRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`security-phrase-row-${rowIndex}`}
|
||||
className="flex flex-wrap items-center justify-center gap-4"
|
||||
>
|
||||
{row.map((word, wordIndex) => (
|
||||
<span
|
||||
key={`security-phrase-${rowIndex}-${wordIndex}`}
|
||||
className="uppercase"
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Typography className="text-sm text-blue-gray-500">
|
||||
Kein Sicherheitsschlüssel verfügbar.
|
||||
</Typography>
|
||||
)}
|
||||
<Typography className="mt-3 text-sm text-blue-gray-500">
|
||||
Nach dem Erneuern muss der Benutzer den neuen Sicherheitsschlüssel erneut herunterladen.
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</CardBody>
|
||||
</Card></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserDetails;
|
||||
@@ -0,0 +1,960 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Typography,
|
||||
Button,
|
||||
Spinner,
|
||||
Select,
|
||||
Option,
|
||||
Input,
|
||||
Chip,
|
||||
Radio,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemPrefix
|
||||
} from "@material-tailwind/react";
|
||||
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||
|
||||
const _ = AVATAR_COLORS.join(" ");
|
||||
|
||||
const UPDATE_STAGE_LABELS = {
|
||||
initializing: "Vorbereitung",
|
||||
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||
"executing-script": "Skript wird ausgeführt",
|
||||
waiting: "Warte auf Portainer",
|
||||
completed: "Abgeschlossen",
|
||||
failed: "Fehlgeschlagen"
|
||||
};
|
||||
|
||||
const mapGroup = (item) => ({
|
||||
id: item?.id ?? null,
|
||||
name: item?.name || "",
|
||||
description: item?.description || "",
|
||||
avatarColor: item?.avatarColor || null,
|
||||
createdAt: item?.createdAt || null,
|
||||
updatedAt: item?.updatedAt || null,
|
||||
memberCount: Number.isFinite(Number(item?.memberCount)) ? Number(item.memberCount) : 0,
|
||||
members: Array.isArray(item?.members)
|
||||
? item.members
|
||||
.map((member) => ({
|
||||
id: member?.id ?? null,
|
||||
username: member?.username || ""
|
||||
}))
|
||||
.filter((member) => member.username)
|
||||
: []
|
||||
});
|
||||
|
||||
const buildInitialFormValues = (group) => {
|
||||
if (!group) {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
avatarColor: ""
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: group.name || "",
|
||||
description: group.description || "",
|
||||
avatarColor: group.avatarColor || ""
|
||||
};
|
||||
};
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: "full", label: "Vollzugriff" },
|
||||
{ value: "read", label: "Nur lesen" },
|
||||
{ value: "none", label: "Kein Zugriff" }
|
||||
];
|
||||
const LEVEL_PRIORITY = {
|
||||
none: 0,
|
||||
read: 1,
|
||||
full: 2
|
||||
};
|
||||
|
||||
const normalizePermissionLevel = (key, value) =>
|
||||
key === "maintenance-server-delete" && value !== "full" ? "none" : value;
|
||||
|
||||
export function UserGroupDetail() {
|
||||
const { groupId } = useParams();
|
||||
const { showToast } = useToast();
|
||||
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||
const { hasPermission, user: authUser } = useAuth();
|
||||
|
||||
const [group, setGroup] = useState(null);
|
||||
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||
const initialFormValuesRef = useRef(buildInitialFormValues(null));
|
||||
const [initialPermissionSelection, setInitialPermissionSelection] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [savingGroup, setSavingGroup] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [permissionSections, setPermissionSections] = useState([]);
|
||||
const [permissionSelection, setPermissionSelection] = useState({});
|
||||
const [permissionDefaults, setPermissionDefaults] = useState({});
|
||||
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
||||
const [permissionsError, setPermissionsError] = useState("");
|
||||
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
const maintenanceMessage = maintenanceMeta?.message;
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
|
||||
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
|
||||
const canEditGroupDetails = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
||||
const canViewPermissionSettings = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "read"));
|
||||
const canAdjustPermissions = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
||||
const superuserGroupLocked = isSuperuserGroup && !isSuperuserAccount;
|
||||
|
||||
const numericGroupId = useMemo(() => {
|
||||
const candidate = Number(groupId);
|
||||
return Number.isFinite(candidate) ? candidate : null;
|
||||
}, [groupId]);
|
||||
|
||||
const fetchGroupPermissions = useCallback(
|
||||
async (targetGroupId) => {
|
||||
const numericTargetId = Number(targetGroupId);
|
||||
if (!Number.isFinite(numericTargetId) || numericTargetId <= 0) {
|
||||
setPermissionSections([]);
|
||||
setPermissionSelection({});
|
||||
setPermissionDefaults({});
|
||||
setPermissionsError("");
|
||||
setInitialPermissionSelection({});
|
||||
setPermissionsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPermissionsLoading(true);
|
||||
setPermissionsError("");
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/groups/${numericTargetId}/permissions`);
|
||||
const rawSections = Array.isArray(response.data?.sections) ? response.data.sections : [];
|
||||
const rawValues =
|
||||
response.data?.values && typeof response.data.values === "object" ? response.data.values : {};
|
||||
|
||||
const defaults = {};
|
||||
const initialSelection = {};
|
||||
|
||||
const normalizeItem = (item, index) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = item.key || "";
|
||||
const defaultLevel =
|
||||
typeof item.defaultLevel === "string" && item.defaultLevel ? item.defaultLevel : "none";
|
||||
defaults[key] = defaultLevel;
|
||||
|
||||
const assigned = rawValues[key];
|
||||
let normalizedAssigned =
|
||||
typeof assigned === "string" && assigned ? assigned : defaultLevel;
|
||||
|
||||
const availableLevels =
|
||||
Array.isArray(item.availableLevels) && item.availableLevels.length
|
||||
? item.availableLevels
|
||||
: LEVEL_OPTIONS.map((option) => option.value);
|
||||
if (!availableLevels.includes(normalizedAssigned)) {
|
||||
normalizedAssigned = defaultLevel;
|
||||
}
|
||||
initialSelection[key] = normalizedAssigned;
|
||||
|
||||
const dependencies = Array.isArray(item.dependencies) ? item.dependencies : [];
|
||||
|
||||
return {
|
||||
...item,
|
||||
availableLevels,
|
||||
dependencies,
|
||||
sortOrder: typeof item.sortOrder === "number" ? item.sortOrder : index
|
||||
};
|
||||
};
|
||||
|
||||
const normalizedSections = rawSections.map((section, sectionIndex) => {
|
||||
const sectionItems = Array.isArray(section.items)
|
||||
? section.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const sectionGroups = Array.isArray(section.groups)
|
||||
? section.groups.map((group, groupIdx) => {
|
||||
const groupItems = Array.isArray(group.items)
|
||||
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
...group,
|
||||
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
|
||||
items: groupItems
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
...section,
|
||||
sortOrder: typeof section.sortOrder === "number" ? section.sortOrder : sectionIndex,
|
||||
hasNavigation: Boolean(section.hasNavigation),
|
||||
items: sectionItems,
|
||||
groups: sectionGroups
|
||||
};
|
||||
});
|
||||
|
||||
setPermissionSections(normalizedSections);
|
||||
setPermissionDefaults(defaults);
|
||||
setPermissionSelection({ ...initialSelection });
|
||||
setInitialPermissionSelection({ ...initialSelection });
|
||||
} catch (err) {
|
||||
let message = "Die Berechtigungen konnten nicht geladen werden.";
|
||||
if (err.response?.data?.error === "GROUP_NOT_FOUND") {
|
||||
message = "Die angeforderte Benutzergruppe wurde nicht gefunden.";
|
||||
}
|
||||
setPermissionsError(message);
|
||||
setPermissionSections([]);
|
||||
setPermissionSelection({});
|
||||
setPermissionDefaults({});
|
||||
setInitialPermissionSelection({});
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Fehler beim Laden",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setPermissionsLoading(false);
|
||||
}
|
||||
},
|
||||
[showToast]
|
||||
);
|
||||
|
||||
const fetchGroupDetails = useCallback(async () => {
|
||||
if (!numericGroupId) {
|
||||
setError("Ungültige Gruppen-ID.");
|
||||
setGroup(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setPermissionSections([]);
|
||||
setPermissionSelection({});
|
||||
setPermissionDefaults({});
|
||||
setPermissionsError("");
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/groups/${numericGroupId}`);
|
||||
const item = mapGroup(response.data?.item);
|
||||
if (!item.id) {
|
||||
throw new Error("GROUP_NOT_FOUND");
|
||||
}
|
||||
setGroup(item);
|
||||
const initialValues = buildInitialFormValues(item);
|
||||
initialFormValuesRef.current = { ...initialValues };
|
||||
setFormValues(initialValues);
|
||||
setSaveError("");
|
||||
|
||||
const superuserDetected = (item.name || "").toLowerCase() === "superuser";
|
||||
if (superuserDetected) {
|
||||
setPermissionSections([]);
|
||||
setPermissionSelection({});
|
||||
setPermissionDefaults({});
|
||||
setPermissionsError("");
|
||||
setInitialPermissionSelection({});
|
||||
setPermissionsLoading(false);
|
||||
} else if (canViewPermissionSettings) {
|
||||
fetchGroupPermissions(item.id);
|
||||
} else {
|
||||
setPermissionSections([]);
|
||||
setPermissionSelection({});
|
||||
setPermissionDefaults({});
|
||||
setPermissionsError("");
|
||||
setInitialPermissionSelection({});
|
||||
setPermissionsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Gruppendetails konnten nicht geladen werden.";
|
||||
|
||||
if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Die angeforderte Benutzergruppe wurde nicht gefunden.";
|
||||
} else if (serverError === "INVALID_GROUP_ID") {
|
||||
message = "Die angegebene Gruppen-ID ist ungültig.";
|
||||
} else if (err.response?.status === 404) {
|
||||
message = "Die angeforderte Benutzergruppe existiert nicht.";
|
||||
}
|
||||
|
||||
setGroup(null);
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
setPermissionSections([]);
|
||||
setPermissionSelection({});
|
||||
setPermissionDefaults({});
|
||||
setPermissionsError("");
|
||||
setPermissionsLoading(false);
|
||||
setError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Fehler beim Laden",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
}
|
||||
}, [numericGroupId, showToast, fetchGroupPermissions, canViewPermissionSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroupDetails();
|
||||
}, [fetchGroupDetails]);
|
||||
|
||||
const detailsChanged = useMemo(() => {
|
||||
if (!hasLoaded || !group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initial = initialFormValuesRef.current;
|
||||
if (!initial) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialName = initial.name || "";
|
||||
const currentName = formValues.name || "";
|
||||
|
||||
const initialDescription = initial.description || "";
|
||||
const currentDescription = formValues.description || "";
|
||||
|
||||
const initialAvatar = initial.avatarColor || "";
|
||||
const currentAvatar = formValues.avatarColor || "";
|
||||
|
||||
return (
|
||||
initialName !== currentName ||
|
||||
initialDescription !== currentDescription ||
|
||||
initialAvatar !== currentAvatar
|
||||
);
|
||||
}, [formValues, hasLoaded, group]);
|
||||
|
||||
const permissionsChanged = useMemo(() => {
|
||||
if (isSuperuserGroup) {
|
||||
return false;
|
||||
}
|
||||
const initial = initialPermissionSelection || {};
|
||||
const current = permissionSelection || {};
|
||||
const allKeys = new Set([...Object.keys(initial), ...Object.keys(current)]);
|
||||
for (const key of allKeys) {
|
||||
const initialValue = initial[key] ?? null;
|
||||
const currentValue = current[key] ?? null;
|
||||
if (initialValue !== currentValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [permissionSelection, initialPermissionSelection, isSuperuserGroup]);
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() => detailsChanged || permissionsChanged,
|
||||
[detailsChanged, permissionsChanged]
|
||||
);
|
||||
|
||||
const handleNameChange = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
name: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleDescriptionChange = useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
description: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAvatarColorChange = useCallback((value) => {
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
avatarColor: value || ""
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSaveGroup = useCallback(async () => {
|
||||
if (!group || (!detailsChanged && !permissionsChanged)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (detailsChanged && !canEditGroupDetails) {
|
||||
setSaveError("Dir fehlt die Berechtigung, diese Gruppe zu bearbeiten.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (permissionsChanged && !canAdjustPermissions) {
|
||||
setSaveError("Dir fehlt die Berechtigung, die Gruppenrechte anzupassen.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingGroup(true);
|
||||
setSaveError("");
|
||||
|
||||
try {
|
||||
if (detailsChanged) {
|
||||
const payload = {
|
||||
avatarColor: formValues.avatarColor
|
||||
};
|
||||
|
||||
if (!isSuperuserGroup) {
|
||||
payload.name = formValues.name;
|
||||
payload.description = formValues.description;
|
||||
}
|
||||
|
||||
const response = await axios.put(`/api/groups/${group.id}`, payload);
|
||||
const updated = mapGroup(response.data?.item || response.data?.group);
|
||||
if (updated?.id) {
|
||||
setGroup(updated);
|
||||
const nextInitial = buildInitialFormValues(updated);
|
||||
initialFormValuesRef.current = { ...nextInitial };
|
||||
setFormValues(nextInitial);
|
||||
}
|
||||
}
|
||||
|
||||
if (permissionsChanged) {
|
||||
await axios.put(`/api/groups/${group.id}/permissions`, {
|
||||
values: permissionSelection
|
||||
});
|
||||
setPermissionSelection((prev) => ({
|
||||
...prev,
|
||||
...permissionSelection
|
||||
}));
|
||||
setInitialPermissionSelection({ ...permissionSelection });
|
||||
}
|
||||
|
||||
if (detailsChanged) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Gruppe gespeichert",
|
||||
description: "Die Änderungen wurden erfolgreich gespeichert."
|
||||
});
|
||||
} else if (permissionsChanged) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Berechtigungen gespeichert",
|
||||
description: "Die Berechtigungen wurden erfolgreich gespeichert."
|
||||
});
|
||||
}
|
||||
|
||||
setSaveError("");
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Die Änderungen konnten nicht gespeichert werden.";
|
||||
|
||||
if (serverError === "GROUP_NAME_REQUIRED") {
|
||||
message = "Bitte einen Gruppennamen angeben.";
|
||||
} else if (serverError === "GROUP_NAME_TAKEN") {
|
||||
message = "Der Gruppenname wird bereits verwendet.";
|
||||
} else if (serverError === "INVALID_AVATAR_COLOR") {
|
||||
message = "Bitte eine gültige Avatar-Farbe auswählen.";
|
||||
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Die Benutzergruppe wurde nicht gefunden.";
|
||||
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
|
||||
message = "Für die Superuser-Gruppe können Berechtigungen nicht angepasst werden.";
|
||||
} else if (serverError === "PERMISSION_INVALID_PAYLOAD") {
|
||||
message = "Ungültige Berechtigungsdaten übermittelt.";
|
||||
} else if (serverError === "PERMISSION_INVALID_LEVEL") {
|
||||
message = "Für mindestens eine Berechtigung wurde ein nicht erlaubter Wert übermittelt.";
|
||||
} else if (serverError === "PERMISSION_UNKNOWN_KEY") {
|
||||
message = "Es wurde eine unbekannte Berechtigung übermittelt.";
|
||||
}
|
||||
|
||||
setSaveError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Speichern fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setSavingGroup(false);
|
||||
}
|
||||
}, [
|
||||
group,
|
||||
detailsChanged,
|
||||
permissionsChanged,
|
||||
formValues,
|
||||
showToast,
|
||||
isSuperuserGroup,
|
||||
permissionSelection,
|
||||
canEditGroupDetails,
|
||||
canAdjustPermissions
|
||||
]);
|
||||
|
||||
const avatarLabel = useMemo(() => {
|
||||
const source = (formValues.name || formValues.description || "").trim();
|
||||
if (!source) {
|
||||
return "?";
|
||||
}
|
||||
return source.charAt(0).toUpperCase();
|
||||
}, [formValues.name, formValues.description]);
|
||||
|
||||
const avatarColorClass = useMemo(() => {
|
||||
if (formValues.avatarColor) {
|
||||
return formValues.avatarColor;
|
||||
}
|
||||
return group?.avatarColor || "";
|
||||
}, [formValues.avatarColor, group]);
|
||||
|
||||
const inputDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
|
||||
const selectDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
|
||||
const selectValue = formValues.avatarColor || "";
|
||||
|
||||
const handlePermissionChange = useCallback((permissionKey, value) => {
|
||||
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
|
||||
return;
|
||||
}
|
||||
const nextValue = normalizePermissionLevel(permissionKey, value);
|
||||
setPermissionSelection((prev) => {
|
||||
const shouldResetDelete =
|
||||
permissionKey === "maintenance-server-manage" && nextValue !== "full";
|
||||
|
||||
if (!shouldResetDelete && prev[permissionKey] === nextValue) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
...prev,
|
||||
[permissionKey]: nextValue
|
||||
};
|
||||
|
||||
if (shouldResetDelete) {
|
||||
nextState["maintenance-server-delete"] = "none";
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
}, [canAdjustPermissions, isSuperuserGroup]);
|
||||
|
||||
const getPermissionValue = useCallback(
|
||||
(permissionKey) => {
|
||||
if (!permissionKey) {
|
||||
return null;
|
||||
}
|
||||
let value = null;
|
||||
if (Object.prototype.hasOwnProperty.call(permissionSelection, permissionKey)) {
|
||||
value = permissionSelection[permissionKey];
|
||||
} else if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) {
|
||||
value = permissionDefaults[permissionKey];
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return normalizePermissionLevel(permissionKey, value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
[permissionSelection, permissionDefaults]
|
||||
);
|
||||
|
||||
const radioIcon = useMemo(
|
||||
() => (
|
||||
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-400 bg-black" />
|
||||
),
|
||||
[]
|
||||
);
|
||||
const radioCheckedIcon = useMemo(
|
||||
() => (
|
||||
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-700 bg-gray-900" />
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const isPermissionRowVisible = useCallback(
|
||||
(row) => {
|
||||
if (!row || !row.key) {
|
||||
return false;
|
||||
}
|
||||
const dependencies = Array.isArray(row.dependencies) ? row.dependencies : [];
|
||||
return dependencies.every((dependency) => {
|
||||
if (!dependency || !dependency.key) {
|
||||
return true;
|
||||
}
|
||||
const value = getPermissionValue(dependency.key) ?? "none";
|
||||
const requirement = dependency.requiredLevel || "!=none";
|
||||
|
||||
if (requirement === "!=none") {
|
||||
return value !== "none";
|
||||
}
|
||||
if (requirement.startsWith("!=")) {
|
||||
return value !== requirement.substring(2);
|
||||
}
|
||||
if (requirement.startsWith("=")) {
|
||||
return value === requirement.substring(1);
|
||||
}
|
||||
if (!requirement) {
|
||||
return true;
|
||||
}
|
||||
return value === requirement;
|
||||
});
|
||||
},
|
||||
[getPermissionValue]
|
||||
);
|
||||
|
||||
const renderPermissionRow = (row, ownerKey, options = {}) => {
|
||||
if (!isPermissionRowVisible(row)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rowName = `permission-${ownerKey}-${row.key}`;
|
||||
const currentValue = getPermissionValue(row.key) ?? "none";
|
||||
const displayValue = normalizePermissionLevel(row.key, currentValue);
|
||||
const serverManageLevel = getPermissionValue("maintenance-server-manage") ?? "none";
|
||||
const { addTopBorder = true } = options;
|
||||
|
||||
const rowClasses = [
|
||||
"flex flex-col gap-2.5 px-4 py-4 md:flex-row md:items-center md:gap-6",
|
||||
addTopBorder ? "border-t border-blue-gray-100" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const baseLevels = Array.isArray(row.availableLevels) && row.availableLevels.length
|
||||
? row.availableLevels
|
||||
: LEVEL_OPTIONS.map((option) => option.value);
|
||||
const availableLevels = new Set(
|
||||
row.key === "maintenance-server-delete"
|
||||
? baseLevels.filter((level) => level === "full" || level === "none")
|
||||
: baseLevels
|
||||
);
|
||||
const levelOptions = LEVEL_OPTIONS;
|
||||
|
||||
return (
|
||||
<div key={`${ownerKey}-${row.key}`} className={rowClasses}>
|
||||
<div className="text-sm font-medium leading-5 text-blue-gray-600 md:w-1/2">
|
||||
{row.label}
|
||||
</div>
|
||||
<List className="flex-col gap-1.5 md:ml-auto md:flex-row md:flex-nowrap md:w-1/2 md:items-center md:justify-end md:gap-3">
|
||||
{levelOptions.map((option) => {
|
||||
const optionId = `${rowName}-${option.value}`;
|
||||
const checked = displayValue === option.value;
|
||||
const requiresManageFull =
|
||||
row.key === "maintenance-server-delete" &&
|
||||
option.value === "full" &&
|
||||
(LEVEL_PRIORITY[serverManageLevel] ?? 0) < LEVEL_PRIORITY.full;
|
||||
const disabled =
|
||||
!availableLevels.has(option.value) ||
|
||||
permissionsLoading ||
|
||||
savingGroup ||
|
||||
!canAdjustPermissions ||
|
||||
isSuperuserGroup ||
|
||||
requiresManageFull;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`${ownerKey}-${row.key}-${option.value}`}
|
||||
className="w-full min-w-[140px] p-0 md:w-auto md:flex-1"
|
||||
>
|
||||
<label
|
||||
htmlFor={optionId}
|
||||
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
|
||||
}`}
|
||||
>
|
||||
<ListItemPrefix className="flex-shrink-0">
|
||||
<Radio
|
||||
id={optionId}
|
||||
name={rowName}
|
||||
value={option.value}
|
||||
checked={checked}
|
||||
onChange={() => handlePermissionChange(row.key, option.value)}
|
||||
disabled={disabled}
|
||||
ripple={false}
|
||||
className="h-4 w-4 hover:before:opacity-0"
|
||||
containerProps={{
|
||||
className: "p-0"
|
||||
}}
|
||||
icon={radioIcon}
|
||||
checkedIcon={radioCheckedIcon}
|
||||
/>
|
||||
</ListItemPrefix>
|
||||
<Typography
|
||||
color="blue-gray"
|
||||
className={`text-sm font-medium leading-5 whitespace-nowrap ${disabled ? "text-blue-gray-300" : "text-blue-gray-600"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Typography>
|
||||
</label>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-12 flex flex-col gap-12">
|
||||
{(maintenanceActive || updateRunning) && (
|
||||
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||
</span>
|
||||
{updateRunning && (
|
||||
<span className="text-xs text-indigo-900">
|
||||
Phase: {updateStageLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
||||
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||
</div>
|
||||
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||
<CardBody className="p-4">
|
||||
<div className="mb-10 flex flex-wrap items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div
|
||||
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${avatarColorClass}`}
|
||||
aria-label={formValues.name || "Gruppenavatar"}
|
||||
>
|
||||
{avatarLabel}
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
{formValues.name || "–"}
|
||||
</Typography>
|
||||
<Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
|
||||
Gruppen-ID: {group?.id ?? "–"}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<Button
|
||||
color="green"
|
||||
className="normal-case"
|
||||
onClick={handleSaveGroup}
|
||||
disabled={maintenanceLocked || savingGroup}
|
||||
>
|
||||
{savingGroup ? "Speichert ..." : "Änderungen speichern"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{loading && !group && (
|
||||
<div className="mb-6 flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Gruppendaten werden geladen ...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{saveError && !loading && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||
Gruppendaten
|
||||
</Typography>
|
||||
{isSuperuserGroup && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Systemgruppe – Name und Beschreibung sind geschützt.
|
||||
{superuserGroupLocked && (
|
||||
<span className="mt-1 block text-xs text-amber-600">
|
||||
Nur der Superuser darf die Superuser-Gruppe bearbeiten.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Gruppenname
|
||||
</Typography>
|
||||
<Input
|
||||
value={formValues.name}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Gruppenname"
|
||||
disabled={inputDisabled}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Beschreibung
|
||||
</Typography>
|
||||
<Input
|
||||
value={formValues.description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="Beschreibung der Gruppe"
|
||||
disabled={inputDisabled}
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Avatar-Farbe
|
||||
</Typography>
|
||||
<Select
|
||||
label="Avatar-Farbe auswählen"
|
||||
variant="outlined"
|
||||
value={selectValue}
|
||||
onChange={handleAvatarColorChange}
|
||||
disabled={selectDisabled}
|
||||
selected={(element) => {
|
||||
if (element?.props?.children) {
|
||||
return element.props.children;
|
||||
}
|
||||
if (!formValues.avatarColor) {
|
||||
return "Standardfarbe";
|
||||
}
|
||||
return formValues.avatarColor;
|
||||
}}
|
||||
>
|
||||
<Option value="">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded border border-blue-gray-100" />
|
||||
<span className="text-xs">Keine</span>
|
||||
</span>
|
||||
</Option>
|
||||
{AVATAR_COLORS.map((color) => (
|
||||
<Option key={color} value={color}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
|
||||
<span className="text-xs">{color}</span>
|
||||
</span>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||
Mitglieder
|
||||
</Typography>
|
||||
{!group || group.members.length === 0 ? (
|
||||
<Typography className="text-sm text-stormGrey-500">
|
||||
Aktuell sind keine Benutzer dieser Gruppe zugeordnet.
|
||||
</Typography>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.members.map((member) => (
|
||||
<Chip
|
||||
key={`${group.id}-${member.id}-${member.username}`}
|
||||
value={member.username}
|
||||
size="sm"
|
||||
color="blue-gray"
|
||||
variant="ghost"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 text-xs text-stormGrey-400">
|
||||
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-2 xl:col-span-3">
|
||||
{(!maintenanceActive && !updateRunning) && (
|
||||
<>
|
||||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||
Berechtigungen (Ansicht)
|
||||
</Typography>
|
||||
|
||||
{isSuperuserGroup ? (
|
||||
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
|
||||
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
|
||||
</div>
|
||||
) : !canViewPermissionSettings ? null : (
|
||||
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
|
||||
{permissionsLoading && (
|
||||
<div className="border-b border-blue-gray-100 px-4 py-4">
|
||||
<div className="flex items-center gap-3 text-sm text-blue-gray-500">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span>Berechtigungen werden geladen ...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!permissionsLoading && permissionsError && (
|
||||
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-red-700">
|
||||
{permissionsError}
|
||||
</div>
|
||||
)}
|
||||
{!permissionsLoading && !permissionsError &&
|
||||
(permissionSections.length === 0 ? (
|
||||
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-blue-gray-500">
|
||||
Für diese Gruppe sind keine Berechtigungen definiert.
|
||||
</div>
|
||||
) : (
|
||||
permissionSections.map((section, sectionIndex) => {
|
||||
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={section.key || sectionIndex}
|
||||
className={sectionIndex === 0 ? "" : "border-t border-blue-gray-100"}
|
||||
>
|
||||
<div className="border-b border-blue-gray-100 px-4 py-4">
|
||||
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
|
||||
{section.title}
|
||||
</Typography>
|
||||
</div>
|
||||
{sectionItems.map((row, rowIndex) =>
|
||||
renderPermissionRow(row, section.key || sectionIndex, {
|
||||
addTopBorder: rowIndex !== 0
|
||||
})
|
||||
)}
|
||||
{sectionGroups.map((group, groupIdx) => {
|
||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||
const hasVisibleRows = groupItems.some(isPermissionRowVisible);
|
||||
|
||||
if (!hasVisibleRows) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
|
||||
<div className="border-b border-blue-gray-100 px-4 py-3">
|
||||
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
|
||||
{group.title}
|
||||
</Typography>
|
||||
</div>
|
||||
{groupItems.map((row, rowIndex) =>
|
||||
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
|
||||
addTopBorder: rowIndex !== 0
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserGroupDetail;
|
||||
@@ -0,0 +1,561 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Select,
|
||||
Option,
|
||||
Input
|
||||
} from "@material-tailwind/react";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
const UPDATE_STAGE_LABELS = {
|
||||
initializing: "Vorbereitung",
|
||||
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||
"executing-script": "Skript wird ausgeführt",
|
||||
waiting: "Warte auf Portainer",
|
||||
completed: "Abgeschlossen",
|
||||
failed: "Fehlgeschlagen"
|
||||
};
|
||||
|
||||
export function Usergroups() {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDescription, setNewGroupDescription] = useState("");
|
||||
const [createGroupError, setCreateGroupError] = useState("");
|
||||
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||
const [deletingGroupId, setDeletingGroupId] = useState(null);
|
||||
|
||||
const { showToast } = useToast();
|
||||
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
const maintenanceMessage = maintenanceMeta?.message;
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const canEditGroups = hasPermission("user-groups-edit", "full");
|
||||
const canDeleteGroups = hasPermission("user-groups-delete", "full");
|
||||
|
||||
const {
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
perPageIsAll,
|
||||
handlePerPageChange,
|
||||
setPage,
|
||||
setTotals,
|
||||
resetPagination
|
||||
} = usePage();
|
||||
|
||||
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||
const noop = useCallback(() => { }, []);
|
||||
|
||||
const fetchGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await axios.get("/api/groups");
|
||||
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
const normalized = items
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
avatarColor: item.avatarColor || null,
|
||||
memberCount: Number(item.memberCount) || 0,
|
||||
members: Array.isArray(item.members)
|
||||
? item.members.map((member) => ({
|
||||
id: member.id,
|
||||
username: member.username || ""
|
||||
})).filter((member) => member.username)
|
||||
: [],
|
||||
createdAt: item.createdAt || null,
|
||||
updatedAt: item.updatedAt || null
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
|
||||
setGroups(normalized);
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Unbekannter Fehler";
|
||||
setError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Benutzergruppen konnten nicht geladen werden",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
const handleSearchChange = useCallback((event) => {
|
||||
setSearchQuery(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== 1) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [searchQuery, page, setPage]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return groups;
|
||||
}
|
||||
return groups.filter((group) => {
|
||||
const haystacks = [
|
||||
group.name,
|
||||
group.description,
|
||||
...group.members.map((member) => member.username)
|
||||
].map((value) => String(value || "").toLowerCase());
|
||||
return haystacks.some((value) => value.includes(query));
|
||||
});
|
||||
}, [groups, searchQuery]);
|
||||
|
||||
const perPageNumber = useMemo(() => {
|
||||
if (perPageIsAll) {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(perPage);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||
}, [perPage, perPageIsAll]);
|
||||
|
||||
const paginatedGroups = useMemo(() => {
|
||||
if (perPageIsAll || !perPageNumber) {
|
||||
return filteredGroups;
|
||||
}
|
||||
const startIndex = (page - 1) * perPageNumber;
|
||||
return filteredGroups.slice(startIndex, startIndex + perPageNumber);
|
||||
}, [filteredGroups, page, perPageIsAll, perPageNumber]);
|
||||
|
||||
const filteredCount = filteredGroups.length;
|
||||
const visibleCount = paginatedGroups.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (perPageIsAll) {
|
||||
setTotals(filteredCount, filteredCount);
|
||||
if (page !== 1) {
|
||||
setPage(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const numeric = Number(perPage);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
setTotals(filteredCount, filteredCount);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPages = Math.max(1, Math.ceil(filteredCount / numeric));
|
||||
if (page > maxPages) {
|
||||
setPage(maxPages);
|
||||
return;
|
||||
}
|
||||
|
||||
setTotals(filteredCount, visibleCount);
|
||||
}, [filteredCount, visibleCount, page, perPage, perPageIsAll, setPage, setTotals]);
|
||||
|
||||
const formatTimestamp = useCallback((value) => {
|
||||
if (!value) {
|
||||
return "–";
|
||||
}
|
||||
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short"
|
||||
}).format(parsed);
|
||||
}, []);
|
||||
|
||||
const resetNewGroupForm = useCallback(() => {
|
||||
setNewGroupName("");
|
||||
setNewGroupDescription("");
|
||||
setCreateGroupError("");
|
||||
}, []);
|
||||
|
||||
const handleCreateGroup = useCallback(async () => {
|
||||
if (maintenanceLocked) {
|
||||
setCreateGroupError("Im Wartungsmodus oder während eines Updates können keine Gruppen angelegt werden.");
|
||||
return;
|
||||
}
|
||||
if (!canEditGroups) {
|
||||
setCreateGroupError("Du verfügst nicht über die Berechtigung zum Anlegen von Gruppen.");
|
||||
return;
|
||||
}
|
||||
const trimmedName = newGroupName.trim();
|
||||
if (!trimmedName) {
|
||||
setCreateGroupError("Gruppenname ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
setCreatingGroup(true);
|
||||
setCreateGroupError("");
|
||||
try {
|
||||
const payload = { name: trimmedName };
|
||||
const trimmedDescription = newGroupDescription.trim();
|
||||
if (trimmedDescription) {
|
||||
payload.description = trimmedDescription;
|
||||
}
|
||||
const response = await axios.post("/api/groups", payload);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Gruppe angelegt",
|
||||
description: `Die Benutzergruppe "${response.data?.item?.name ?? trimmedName}" wurde erstellt.`
|
||||
});
|
||||
resetNewGroupForm();
|
||||
fetchGroups();
|
||||
} catch (err) {
|
||||
const errorCode = err.response?.data?.error;
|
||||
let message = "Benutzergruppe konnte nicht angelegt werden.";
|
||||
if (errorCode === "GROUP_NAME_REQUIRED") {
|
||||
message = "Gruppenname ist erforderlich.";
|
||||
} else if (errorCode === "GROUP_NAME_TAKEN") {
|
||||
message = "Der Gruppenname wird bereits verwendet.";
|
||||
}
|
||||
setCreateGroupError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Erstellen fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setCreatingGroup(false);
|
||||
}
|
||||
}, [
|
||||
maintenanceLocked,
|
||||
canEditGroups,
|
||||
newGroupName,
|
||||
newGroupDescription,
|
||||
showToast,
|
||||
resetNewGroupForm,
|
||||
fetchGroups
|
||||
]);
|
||||
|
||||
const createGroupDisabled = maintenanceLocked || creatingGroup;
|
||||
|
||||
const handleDeleteGroup = useCallback(async (group) => {
|
||||
if (maintenanceLocked || !group?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canDeleteGroups) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
|
||||
if (isSuperuserGroup) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen nicht möglich",
|
||||
description: "Die Superuser-Gruppe kann nicht gelöscht werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const memberCount = Number(group.memberCount) || 0;
|
||||
if (memberCount > 0) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen nicht möglich",
|
||||
description: "Die Gruppe enthält noch Benutzer. Bitte entfernen Sie diese zuerst."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmation = window.confirm(`Benutzergruppe "${group.name}" wirklich löschen?`);
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numericId = Number(group.id);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen fehlgeschlagen",
|
||||
description: "Ungültige Gruppen-ID."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingGroupId(numericId);
|
||||
try {
|
||||
await axios.delete(`/api/groups/${numericId}`);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Gruppe gelöscht",
|
||||
description: `Die Benutzergruppe "${group.name}" wurde entfernt.`
|
||||
});
|
||||
fetchGroups();
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Die Benutzergruppe konnte nicht gelöscht werden.";
|
||||
|
||||
if (serverError === "INVALID_GROUP_ID") {
|
||||
message = "Die Gruppen-ID ist ungültig.";
|
||||
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Die Benutzergruppe wurde bereits entfernt.";
|
||||
} else if (serverError === "GROUP_HAS_MEMBERS") {
|
||||
message = "Die Gruppe enthält noch Benutzer. Bitte entfernen Sie diese zuerst.";
|
||||
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
|
||||
message = "Die Superuser-Gruppe kann nicht gelöscht werden.";
|
||||
}
|
||||
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setDeletingGroupId(null);
|
||||
}
|
||||
}, [maintenanceLocked, canDeleteGroups, showToast, fetchGroups]);
|
||||
|
||||
return (
|
||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||
{(maintenanceActive || updateRunning) && (
|
||||
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||
</span>
|
||||
{updateRunning && (
|
||||
<span className="text-xs text-indigo-900">
|
||||
Phase: {updateStageLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="white"
|
||||
>
|
||||
<span>Benutzergruppen</span>
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{canEditGroups && (
|
||||
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
|
||||
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||
Neue Benutzergruppe anlegen
|
||||
</Typography>
|
||||
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
|
||||
Der Gruppenname ist Pflicht, die Beschreibung optional.
|
||||
</Typography>
|
||||
{createGroupError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{createGroupError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="Gruppenname"
|
||||
value={newGroupName}
|
||||
onChange={(event) => setNewGroupName(event.target.value)}
|
||||
disabled={createGroupDisabled}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
label="Beschreibung (optional)"
|
||||
value={newGroupDescription}
|
||||
onChange={(event) => setNewGroupDescription(event.target.value)}
|
||||
disabled={createGroupDisabled}
|
||||
crossOrigin=""
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
|
||||
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
onClick={resetNewGroupForm}
|
||||
disabled={creatingGroup || maintenanceLocked}
|
||||
>
|
||||
Formular zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="md:flex-1">
|
||||
<Input
|
||||
label="Suchen nach Name, E-Mail oder Gruppe"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
disabled={loading && !groups.length}
|
||||
crossOrigin=""
|
||||
/>
|
||||
</div>
|
||||
<div className="md:mt-0 mt-8 md:flex-1">
|
||||
<Select
|
||||
variant="static"
|
||||
label="Einträge pro Seite"
|
||||
onChange={noop}
|
||||
value={perPage}
|
||||
>
|
||||
{perPageOptions.map(({ value, label }) => (
|
||||
<Option key={value} value={value}>
|
||||
{label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
||||
<table className="w-full min-w-[720px] table-auto text-left">
|
||||
<thead>
|
||||
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
|
||||
<th className="px-6 py-4 font-semibold">Gruppenname</th>
|
||||
<th className="px-6 py-4 font-semibold">Beschreibung</th>
|
||||
<th className="px-6 py-4 font-semibold">Mitglieder</th>
|
||||
<th className="px-6 py-4 font-semibold">Erstellt am</th>
|
||||
<th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th>
|
||||
<th className="px-6 py-4 font-semibold">Aktionen</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && groups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-stormGrey-400">
|
||||
Gruppen werden geladen ...
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedGroups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-stormGrey-400">
|
||||
Keine Gruppen gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedGroups.map((group, index) => {
|
||||
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
|
||||
return (
|
||||
<tr key={group.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small" className="font-medium text-stormGrey-900">
|
||||
{group.name || "–"}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small">
|
||||
{group.description ? (
|
||||
group.description
|
||||
) : (
|
||||
<span className="text-stormGrey-400">–</span>
|
||||
)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||
{group.memberCount.toLocaleString("de-DE")}
|
||||
</Typography>
|
||||
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||
{formatTimestamp(group.createdAt)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||
{formatTimestamp(group.updatedAt)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outlined"
|
||||
color="blue-gray"
|
||||
onClick={() => navigate(`/dashboard/usergroups/${group.id}`)}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
{canDeleteGroups && !isSuperuserGroup && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
color="red"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
disabled={
|
||||
maintenanceLocked ||
|
||||
deletingGroupId === group.id ||
|
||||
Number(group.memberCount) > 0
|
||||
}
|
||||
>
|
||||
{deletingGroupId === group.id ? "Löscht ..." : "Löschen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Typography variant="small" color="gray">
|
||||
{!loading && groups.length === 0
|
||||
? "Noch keine Gruppen angelegt."
|
||||
: `${filteredCount.toLocaleString("de-DE")} Gruppen gefunden`}
|
||||
</Typography>
|
||||
<PaginationControls disabled={loading && groups.length === 0} />
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Usergroups;
|
||||
@@ -0,0 +1,805 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Select,
|
||||
Option,
|
||||
Input
|
||||
} from "@material-tailwind/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
const UPDATE_STAGE_LABELS = {
|
||||
initializing: "Vorbereitung",
|
||||
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||
"executing-script": "Skript wird ausgeführt",
|
||||
waiting: "Warte auf Portainer",
|
||||
completed: "Abgeschlossen",
|
||||
failed: "Fehlgeschlagen"
|
||||
};
|
||||
|
||||
const normalizeUserGroups = (rawGroups) => {
|
||||
if (!Array.isArray(rawGroups)) {
|
||||
return [];
|
||||
}
|
||||
return rawGroups
|
||||
.map((group) => {
|
||||
if (group && typeof group === "object") {
|
||||
const id = Number(group.id);
|
||||
const name = typeof group.name === "string" ? group.name : "";
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: Number.isFinite(id) ? id : null,
|
||||
name
|
||||
};
|
||||
}
|
||||
if (typeof group === "string") {
|
||||
const name = group.trim();
|
||||
return name ? { id: null, name } : null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export function Users() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [availableGroups, setAvailableGroups] = useState([]);
|
||||
const [groupsLoading, setGroupsLoading] = useState(false);
|
||||
const [groupsError, setGroupsError] = useState("");
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newGroupId, setNewGroupId] = useState("");
|
||||
const [createError, setCreateError] = useState("");
|
||||
const [creatingUser, setCreatingUser] = useState(false);
|
||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||
const [togglingUserId, setTogglingUserId] = useState(null);
|
||||
|
||||
const { showToast } = useToast();
|
||||
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
const maintenanceMessage = maintenanceMeta?.message;
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
const navigate = useNavigate();
|
||||
const noop = useCallback(() => { }, []);
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const canManageUsers = hasPermission("users-edit", "full");
|
||||
const canDeleteUsers = hasPermission("users-delete", "full");
|
||||
|
||||
const {
|
||||
page,
|
||||
perPage,
|
||||
perPageOptions,
|
||||
perPageIsAll,
|
||||
handlePerPageChange,
|
||||
setPage,
|
||||
setTotals,
|
||||
resetPagination
|
||||
} = usePage();
|
||||
|
||||
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await axios.get("/api/users");
|
||||
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
const sorted = items
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
username: item.username || "",
|
||||
email: item.email || "",
|
||||
isActive: Boolean(item.isActive),
|
||||
avatarColor: typeof item.avatarColor === "string" ? item.avatarColor.trim() : null,
|
||||
lastLogin: item.lastLogin || null,
|
||||
createdAt: item.createdAt || null,
|
||||
updatedAt: item.updatedAt || null,
|
||||
groups: normalizeUserGroups(item.groups)
|
||||
}))
|
||||
.sort((a, b) => a.username.localeCompare(b.username, "de-DE"));
|
||||
setUsers(sorted);
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Unbekannter Fehler";
|
||||
setError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Benutzer konnten nicht geladen werden",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
const fetchGroups = useCallback(async () => {
|
||||
setGroupsLoading(true);
|
||||
setGroupsError("");
|
||||
try {
|
||||
const response = await axios.get("/api/groups");
|
||||
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
const normalized = items
|
||||
.map((item) => ({
|
||||
id: Number(item.id),
|
||||
name: item.name || ""
|
||||
}))
|
||||
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name)
|
||||
.filter((group) => group.name.toLowerCase() !== "superuser")
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
|
||||
setAvailableGroups(normalized);
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Benutzergruppen konnten nicht geladen werden.";
|
||||
setGroupsError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Benutzergruppen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setGroupsLoading(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchUsers();
|
||||
fetchGroups();
|
||||
}, [fetchUsers, fetchGroups]);
|
||||
|
||||
const handleSearchChange = useCallback((event) => {
|
||||
setSearchQuery(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== 1) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [searchQuery, page, setPage]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return users;
|
||||
}
|
||||
return users.filter((user) => {
|
||||
const haystacks = [
|
||||
user.username,
|
||||
user.email,
|
||||
...user.groups.map((group) => group.name)
|
||||
].map((value) => String(value || "").toLowerCase());
|
||||
return haystacks.some((value) => value.includes(query));
|
||||
});
|
||||
}, [users, searchQuery]);
|
||||
|
||||
const perPageNumber = useMemo(() => {
|
||||
if (perPageIsAll) {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(perPage);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||
}, [perPage, perPageIsAll]);
|
||||
|
||||
const paginatedUsers = useMemo(() => {
|
||||
if (perPageIsAll || !perPageNumber) {
|
||||
return filteredUsers;
|
||||
}
|
||||
const startIndex = (page - 1) * perPageNumber;
|
||||
return filteredUsers.slice(startIndex, startIndex + perPageNumber);
|
||||
}, [filteredUsers, page, perPageIsAll, perPageNumber]);
|
||||
|
||||
const filteredCount = filteredUsers.length;
|
||||
const visibleCount = paginatedUsers.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (perPageIsAll) {
|
||||
setTotals(filteredCount, filteredCount);
|
||||
if (page !== 1) {
|
||||
setPage(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const numeric = Number(perPage);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
setTotals(filteredCount, filteredCount);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPages = Math.max(1, Math.ceil(filteredCount / numeric));
|
||||
if (page > maxPages) {
|
||||
setPage(maxPages);
|
||||
return;
|
||||
}
|
||||
|
||||
setTotals(filteredCount, visibleCount);
|
||||
}, [filteredCount, visibleCount, page, perPage, perPageIsAll, setPage, setTotals]);
|
||||
|
||||
const formatTimestamp = useCallback((value) => {
|
||||
if (!value) {
|
||||
return "–";
|
||||
}
|
||||
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short"
|
||||
}).format(parsed);
|
||||
}, []);
|
||||
|
||||
const resetNewUserForm = useCallback(() => {
|
||||
setNewUsername("");
|
||||
setNewEmail("");
|
||||
setNewPassword("");
|
||||
setNewGroupId("");
|
||||
setCreateError("");
|
||||
}, []);
|
||||
|
||||
const handleDeleteUser = useCallback(async (user) => {
|
||||
if (maintenanceLocked || !user?.id) {
|
||||
return;
|
||||
}
|
||||
if (!canDeleteUsers) {
|
||||
return;
|
||||
}
|
||||
const isSuperuserUser = Array.isArray(user?.groups)
|
||||
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
|
||||
: false;
|
||||
if (isSuperuserUser) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen nicht möglich",
|
||||
description: "Der Superuser kann nicht gelöscht werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
const confirmation = window.confirm(`Benutzer "${user.username}" wirklich löschen?`);
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numericId = Number(user.id);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen fehlgeschlagen",
|
||||
description: "Ungültige Benutzer-ID."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingUserId(numericId);
|
||||
try {
|
||||
await axios.delete(`/api/users/${numericId}`);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Benutzer gelöscht",
|
||||
description: `Der Benutzer "${user.username}" wurde entfernt.`
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Der Benutzer konnte nicht gelöscht werden.";
|
||||
|
||||
if (serverError === "INVALID_USER_ID") {
|
||||
message = "Die Benutzer-ID ist ungültig.";
|
||||
} else if (serverError === "USER_NOT_FOUND") {
|
||||
message = "Der Benutzer wurde bereits entfernt.";
|
||||
} else if (serverError === "USER_SUPERUSER_PROTECTED") {
|
||||
message = "Der Superuser kann nicht gelöscht werden.";
|
||||
}
|
||||
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Löschen fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setDeletingUserId(null);
|
||||
}
|
||||
}, [maintenanceLocked, canDeleteUsers, showToast, fetchUsers]);
|
||||
|
||||
const handleToggleUserStatus = useCallback(async (user) => {
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
if (!canManageUsers) {
|
||||
return;
|
||||
}
|
||||
if (maintenanceLocked) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Aktion nicht möglich",
|
||||
description: "Im Wartungsmodus oder während eines Updates sind Statusänderungen gesperrt."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isSuperuserUser = Array.isArray(user?.groups)
|
||||
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
|
||||
: false;
|
||||
|
||||
if (isSuperuserUser && user.isActive) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Änderung nicht möglich",
|
||||
description: "Der Superuser kann nicht deaktiviert werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const numericId = Number(user.id);
|
||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Statusänderung fehlgeschlagen",
|
||||
description: "Ungültige Benutzer-ID."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextActive = !user.isActive;
|
||||
setTogglingUserId(numericId);
|
||||
|
||||
try {
|
||||
await axios.put(`/api/users/${numericId}/active`, { isActive: nextActive });
|
||||
fetchUsers();
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Status aktualisiert",
|
||||
description: `Der Benutzer wurde ${nextActive ? "aktiviert" : "deaktiviert"}.`
|
||||
});
|
||||
} catch (err) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Der Benutzerstatus konnte nicht geändert werden.";
|
||||
|
||||
if (serverError === "INVALID_USER_ID") {
|
||||
message = "Die Benutzer-ID ist ungültig.";
|
||||
} else if (serverError === "USER_NOT_FOUND") {
|
||||
message = "Der Benutzer wurde nicht gefunden.";
|
||||
} else if (serverError === "USER_SUPERUSER_PROTECTED") {
|
||||
message = "Der Superuser kann nicht deaktiviert werden.";
|
||||
}
|
||||
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Statusänderung fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setTogglingUserId(null);
|
||||
}
|
||||
}, [maintenanceLocked, canManageUsers, showToast, fetchUsers]);
|
||||
|
||||
const handleCreateUser = useCallback(async () => {
|
||||
if (maintenanceLocked) {
|
||||
setCreateError("Im Wartungsmodus oder während eines Updates können keine Benutzer angelegt werden.");
|
||||
return;
|
||||
}
|
||||
if (!canManageUsers) {
|
||||
setCreateError("Du verfügst nicht über die Berechtigung zum Anlegen von Benutzern.");
|
||||
return;
|
||||
}
|
||||
const trimmedUsername = newUsername.trim();
|
||||
if (!trimmedUsername) {
|
||||
setCreateError("Benutzername ist erforderlich.");
|
||||
return;
|
||||
}
|
||||
const sanitizedPassword = typeof newPassword === "string" ? newPassword.trim() : "";
|
||||
if (sanitizedPassword.length < 8) {
|
||||
setCreateError("Passwort muss mindestens 8 Zeichen enthalten.");
|
||||
return;
|
||||
}
|
||||
if (!newGroupId) {
|
||||
setCreateError("Bitte eine globale Rolle auswählen.");
|
||||
return;
|
||||
}
|
||||
setCreatingUser(true);
|
||||
setCreateError("");
|
||||
try {
|
||||
const payload = {
|
||||
username: trimmedUsername,
|
||||
password: sanitizedPassword,
|
||||
groupId: Number(newGroupId)
|
||||
};
|
||||
const trimmedEmail = newEmail.trim();
|
||||
if (trimmedEmail) {
|
||||
payload.email = trimmedEmail;
|
||||
}
|
||||
const response = await axios.post("/api/users", payload);
|
||||
resetNewUserForm();
|
||||
fetchUsers();
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Benutzer angelegt",
|
||||
description: `Der Benutzer "${response.data?.item?.username ?? trimmedUsername}" wurde erstellt.`
|
||||
});
|
||||
} catch (err) {
|
||||
const errorCode = err.response?.data?.error;
|
||||
let message = "Benutzer konnte nicht angelegt werden.";
|
||||
switch (errorCode) {
|
||||
case "USERNAME_REQUIRED":
|
||||
message = "Benutzername ist erforderlich.";
|
||||
break;
|
||||
case "INVALID_EMAIL":
|
||||
message = "Bitte eine gültige E-Mail-Adresse angeben.";
|
||||
break;
|
||||
case "INVALID_PASSWORD":
|
||||
case "PASSWORD_TOO_SHORT":
|
||||
message = "Passwort muss mindestens 8 Zeichen enthalten.";
|
||||
break;
|
||||
case "INVALID_GROUP_ID":
|
||||
case "GROUP_NOT_FOUND":
|
||||
message = "Bitte eine gültige globale Rolle auswählen.";
|
||||
break;
|
||||
case "USERNAME_TAKEN":
|
||||
message = "Benutzername wird bereits verwendet.";
|
||||
break;
|
||||
case "EMAIL_TAKEN":
|
||||
message = "E-Mail-Adresse wird bereits verwendet.";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setCreateError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Erstellen fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setCreatingUser(false);
|
||||
}
|
||||
}, [
|
||||
maintenanceLocked,
|
||||
canManageUsers,
|
||||
newUsername,
|
||||
newEmail,
|
||||
newPassword,
|
||||
newGroupId,
|
||||
showToast,
|
||||
resetNewUserForm,
|
||||
fetchUsers
|
||||
]);
|
||||
|
||||
const hasSelectableGroups = availableGroups.length > 0;
|
||||
const createDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
|
||||
const groupSelectDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
|
||||
const renderSelectedGroup = useCallback(
|
||||
(element) => {
|
||||
if (element && element.props && element.props.children) {
|
||||
return element.props.children;
|
||||
}
|
||||
if (!newGroupId) {
|
||||
return "Bitte auswählen";
|
||||
}
|
||||
const selected = availableGroups.find((group) => String(group.id) === newGroupId);
|
||||
return selected ? selected.name : "Bitte auswählen";
|
||||
},
|
||||
[availableGroups, newGroupId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||
{(maintenanceActive || updateRunning) && (
|
||||
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||
</span>
|
||||
{updateRunning && (
|
||||
<span className="text-xs text-indigo-900">
|
||||
Phase: {updateStageLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="white"
|
||||
>
|
||||
<span>Benutzerverwaltung</span>
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
|
||||
|
||||
{canManageUsers && (
|
||||
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
|
||||
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||
Neuen Benutzer anlegen
|
||||
</Typography>
|
||||
{hasSelectableGroups ? (
|
||||
<>
|
||||
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
|
||||
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
|
||||
</Typography>
|
||||
{createError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
{groupsError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{groupsError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="Benutzername"
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.target.value)}
|
||||
disabled={maintenanceLocked || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail (optional)"
|
||||
value={newEmail}
|
||||
onChange={(event) => setNewEmail(event.target.value)}
|
||||
disabled={maintenanceLocked || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="Passwort"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
disabled={maintenanceLocked || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Select
|
||||
label="Globale Rolle"
|
||||
variant="outlined"
|
||||
value={newGroupId}
|
||||
onChange={setNewGroupId}
|
||||
disabled={groupSelectDisabled}
|
||||
selected={renderSelectedGroup}
|
||||
>
|
||||
<Option value="">Bitte auswählen</Option>
|
||||
{availableGroups.map((group) => (
|
||||
<Option key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-stormGrey-500">
|
||||
{groupsLoading
|
||||
? "Benutzergruppen werden geladen ..."
|
||||
: ""}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
|
||||
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
onClick={resetNewUserForm}
|
||||
disabled={creatingUser || maintenanceLocked}
|
||||
>
|
||||
Formular zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Es existiert keine verfügbare Benutzergruppe. Bitte lege zuerst eine Gruppe an.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
|
||||
<div className="md:flex-1">
|
||||
<Input
|
||||
label="Suchen nach Name, E-Mail oder Gruppe"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
disabled={loading && !users.length}
|
||||
crossOrigin=""
|
||||
/>
|
||||
</div>
|
||||
<div className="md:mt-0 mt-8 md:flex-1">
|
||||
<Select
|
||||
variant="static"
|
||||
label="Einträge pro Seite"
|
||||
onChange={noop}
|
||||
value={perPage}
|
||||
>
|
||||
{perPageOptions.map(({ value, label }) => (
|
||||
<Option key={value} value={value}>
|
||||
{label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
||||
<table className="w-full min-w-[720px] table-auto text-left">
|
||||
<thead>
|
||||
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
|
||||
<th className="px-6 py-4 font-semibold">Benutzername</th>
|
||||
<th className="px-6 py-4 font-semibold">E-Mail</th>
|
||||
<th className="px-6 py-4 font-semibold">Gruppen</th>
|
||||
<th className="px-6 py-4 font-semibold">Status</th>
|
||||
<th className="px-6 py-4 font-semibold">Letzte Anmeldung</th>
|
||||
<th className="px-6 py-4 font-semibold">Erstellt am</th>
|
||||
<th className="px-6 py-4 font-semibold">Aktionen</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-blue-gray-400">
|
||||
Benutzerdaten werden geladen ...
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-blue-gray-400">
|
||||
Keine Benutzer gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedUsers.map((user, index) => {
|
||||
const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||
const isSuperuserUser = Array.isArray(user?.groups)
|
||||
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
|
||||
: false;
|
||||
return (
|
||||
<tr key={user.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small" className="font-medium text-stormGrey-900">
|
||||
{user.username || "–"}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small">
|
||||
{user.email || "–"}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{user.groups.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.groups.map((group) => (
|
||||
<Chip
|
||||
key={group.id ?? group.name}
|
||||
value={group.name}
|
||||
size="sm"
|
||||
color="blue-gray"
|
||||
variant="ghost"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-stormGrey-400">–</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{canManageUsers ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceLocked ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||
onClick={() => handleToggleUserStatus(user)}
|
||||
disabled={
|
||||
maintenanceLocked ||
|
||||
togglingUserId === user.id ||
|
||||
(Array.isArray(user.groups) &&
|
||||
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||
size="sm"
|
||||
color={user.isActive ? "green" : "red"}
|
||||
variant="ghost"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Chip
|
||||
value={user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||
size="sm"
|
||||
color={user.isActive ? "green" : "red"}
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||
{formatTimestamp(user.lastLogin)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||
{formatTimestamp(user.createdAt)}
|
||||
</Typography>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outlined"
|
||||
color="blue-gray"
|
||||
onClick={() => navigate(`/dashboard/users/${user.id}`)}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
{canDeleteUsers && !isSuperuserUser && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
color="red"
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={maintenanceLocked || deletingUserId === user.id}
|
||||
>
|
||||
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Typography variant="small" color="gray">
|
||||
{""}
|
||||
</Typography>
|
||||
<PaginationControls disabled={loading && users.length === 0} />
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
export default Users;
|
||||
@@ -0,0 +1 @@
|
||||
export { Setup as default, Setup } from "./setup";
|
||||
@@ -0,0 +1,821 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Typography,
|
||||
Input,
|
||||
Button,
|
||||
Alert,
|
||||
Stepper,
|
||||
Step,
|
||||
Radio
|
||||
} from "@material-tailwind/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const STEP_LABELS = [
|
||||
"Willkommen",
|
||||
"Superuser",
|
||||
"Server & API-Key",
|
||||
"Stack auswählen (optional)",
|
||||
"Abschluss"
|
||||
];
|
||||
|
||||
const matchesStackpulse = (stack) =>
|
||||
typeof stack?.Name === "string" && stack.Name.toLowerCase().includes("stackpulse");
|
||||
|
||||
const initialSuperuser = {
|
||||
username: "",
|
||||
email: "",
|
||||
password: ""
|
||||
};
|
||||
|
||||
const initialServer = {
|
||||
name: "",
|
||||
url: ""
|
||||
};
|
||||
|
||||
export function Setup() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||
const [fetchError, setFetchError] = useState(null);
|
||||
|
||||
const [superuserForm, setSuperuserForm] = useState(initialSuperuser);
|
||||
const [serverForm, setServerForm] = useState(initialServer);
|
||||
const [apiKeyValue, setApiKeyValue] = useState("");
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState({
|
||||
loading: false,
|
||||
success: false,
|
||||
tried: false,
|
||||
message: ""
|
||||
});
|
||||
|
||||
const [stackLookup, setStackLookup] = useState({
|
||||
loading: false,
|
||||
fetched: false,
|
||||
error: "",
|
||||
items: [],
|
||||
updatedAt: null
|
||||
});
|
||||
const [selectedStackId, setSelectedStackId] = useState("");
|
||||
const [manualStackId, setManualStackId] = useState("");
|
||||
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
const [finalizing, setFinalizing] = useState(false);
|
||||
const [finalError, setFinalError] = useState(null);
|
||||
const [finalSuccess, setFinalSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadStatus = async () => {
|
||||
setLoadingStatus(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
const response = await fetch("/api/setup/status", {
|
||||
signal: controller.signal,
|
||||
credentials: "include"
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("STATUS_REQUEST_FAILED");
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!isActive) return;
|
||||
|
||||
setStatus(data);
|
||||
|
||||
if (data.setupComplete) {
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setSuperuserForm((prev) => ({
|
||||
username: data.envDefaults?.superuserUsername?.length
|
||||
? data.envDefaults.superuserUsername
|
||||
: prev.username,
|
||||
email: data.envDefaults?.superuserEmail?.length
|
||||
? data.envDefaults.superuserEmail
|
||||
: prev.email,
|
||||
password: data.envDefaults?.superuserPassword?.length
|
||||
? data.envDefaults.superuserPassword
|
||||
: prev.password
|
||||
}));
|
||||
|
||||
setServerForm((prev) => ({
|
||||
name: data.envDefaults?.serverName?.length
|
||||
? data.envDefaults.serverName
|
||||
: prev.name,
|
||||
url: data.envDefaults?.serverUrl?.length
|
||||
? data.envDefaults.serverUrl
|
||||
: prev.url
|
||||
}));
|
||||
|
||||
setApiKeyValue((prev) =>
|
||||
data.envDefaults?.apiKeyValue?.length ? data.envDefaults.apiKeyValue : prev
|
||||
);
|
||||
|
||||
const defaultSelfStack = data.selfStack?.current || data.envDefaults?.selfStackId || "";
|
||||
setManualStackId(defaultSelfStack);
|
||||
setSelectedStackId(data.selfStack?.current ? String(data.selfStack.current) : "");
|
||||
setActiveStep(0);
|
||||
setConnectionStatus({ loading: false, success: false, tried: false, message: "" });
|
||||
setStackLookup({ loading: false, fetched: false, error: "", items: [], updatedAt: null });
|
||||
setFinalError(null);
|
||||
setFinalSuccess(false);
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") return;
|
||||
console.error("⚠️ [Setup] Status konnte nicht geladen werden:", error);
|
||||
if (isActive) {
|
||||
setFetchError("Setup-Status konnte nicht geladen werden. Bitte Seite aktualisieren.");
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadStatus();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
const envDefaults = status?.envDefaults || {};
|
||||
const requireSuperuser = Boolean(status?.requirements?.superuser);
|
||||
const requireServer = Boolean(status?.requirements?.server);
|
||||
const requireApiKey = Boolean(status?.requirements?.apiKey);
|
||||
const hasAnyApiKey = Boolean(status?.apiKeys?.count);
|
||||
|
||||
const serverEnvProvided = Boolean(envDefaults.serverUrl || status?.servers?.envProvided);
|
||||
const apiKeyEnvProvided = Boolean(envDefaults.apiKeyValue || status?.apiKeys?.envProvided);
|
||||
|
||||
const showServerSection = requireServer || serverEnvProvided;
|
||||
const showApiKeyField = requireApiKey || !hasAnyApiKey || apiKeyEnvProvided;
|
||||
|
||||
const superuserUsernameReadOnly = Boolean(envDefaults.superuserUsername);
|
||||
const superuserEmailReadOnly = Boolean(envDefaults.superuserEmail);
|
||||
const superuserPasswordReadOnly = Boolean(envDefaults.superuserPassword);
|
||||
const serverNameReadOnly = Boolean(envDefaults.serverNameFromEnv);
|
||||
const serverUrlReadOnly = Boolean(envDefaults.serverUrl);
|
||||
const apiKeyReadOnly = Boolean(envDefaults.apiKeyValue);
|
||||
|
||||
const resetConnectionStatus = useCallback(() => {
|
||||
setConnectionStatus((prev) =>
|
||||
prev.loading || !prev.tried
|
||||
? prev
|
||||
: { loading: false, success: false, tried: false, message: "" }
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSuperuserChange = useCallback((field) => (event) => {
|
||||
const value = event.target.value;
|
||||
setSuperuserForm((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleServerChange = useCallback(
|
||||
(field) => (event) => {
|
||||
if (field === "name" && serverNameReadOnly) return;
|
||||
if (field === "url" && serverUrlReadOnly) return;
|
||||
const value = event.target.value;
|
||||
setServerForm((prev) => ({ ...prev, [field]: value }));
|
||||
resetConnectionStatus();
|
||||
},
|
||||
[resetConnectionStatus, serverNameReadOnly, serverUrlReadOnly]
|
||||
);
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(event) => {
|
||||
if (apiKeyReadOnly) return;
|
||||
setApiKeyValue(event.target.value);
|
||||
resetConnectionStatus();
|
||||
},
|
||||
[apiKeyReadOnly, resetConnectionStatus]
|
||||
);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
const serverUrl = serverForm.url.trim();
|
||||
const keyValue = apiKeyValue.trim();
|
||||
|
||||
if (!serverUrl) {
|
||||
setConnectionStatus({
|
||||
loading: false,
|
||||
success: false,
|
||||
tried: true,
|
||||
message: "Bitte gib eine Server-URL an."
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!keyValue) {
|
||||
setConnectionStatus({
|
||||
loading: false,
|
||||
success: false,
|
||||
tried: true,
|
||||
message: "Bitte gib einen API-Key ein."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionStatus({ loading: true, success: false, tried: true, message: "" });
|
||||
try {
|
||||
const response = await fetch("/api/setup/test-portainer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
server: {
|
||||
name: serverForm.name.trim() || null,
|
||||
url: serverUrl
|
||||
},
|
||||
apiKey: keyValue
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const message = payload.message || "Verbindungstest fehlgeschlagen. Bitte Daten prüfen.";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
setConnectionStatus({
|
||||
loading: false,
|
||||
success: true,
|
||||
tried: true,
|
||||
message: "Verbindung wurde erfolgreich hergestellt."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Setup] Verbindungstest fehlgeschlagen:", error);
|
||||
setConnectionStatus({
|
||||
loading: false,
|
||||
success: false,
|
||||
tried: true,
|
||||
message: error.message || "Verbindungstest fehlgeschlagen."
|
||||
});
|
||||
}
|
||||
}, [apiKeyValue, serverForm]);
|
||||
|
||||
const handleFetchStacks = useCallback(
|
||||
async ({ silent = false } = {}) => {
|
||||
if (!connectionStatus.success) {
|
||||
setStackLookup((prev) => ({ ...prev, error: "Bitte teste zuerst die Verbindung." }));
|
||||
return;
|
||||
}
|
||||
const serverUrl = serverForm.url.trim();
|
||||
const keyValue = apiKeyValue.trim();
|
||||
if (!serverUrl || !keyValue) return;
|
||||
|
||||
setStackLookup((prev) => ({
|
||||
...prev,
|
||||
loading: !silent,
|
||||
error: "",
|
||||
fetched: true
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/setup/portainer-stacks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
server: {
|
||||
name: serverForm.name.trim() || null,
|
||||
url: serverUrl
|
||||
},
|
||||
apiKey: keyValue
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const message = payload.message || "Stacks konnten nicht geladen werden.";
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await response.json().catch(() => ({ stacks: [] }));
|
||||
const stacks = Array.isArray(data.stacks) ? data.stacks : [];
|
||||
const filteredStacks = stacks.filter(matchesStackpulse);
|
||||
setStackLookup({
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: filteredStacks.length ? "" : "Es wurden keine passenden Stacks gefunden.",
|
||||
items: filteredStacks,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Setup] Stack-Abruf fehlgeschlagen:", error);
|
||||
setStackLookup({
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: error.message || "Stacks konnten nicht geladen werden.",
|
||||
items: [],
|
||||
updatedAt: new Date()
|
||||
});
|
||||
}
|
||||
},
|
||||
[apiKeyValue, connectionStatus.success, serverForm]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeStep === 3 && connectionStatus.success && !stackLookup.loading && !stackLookup.fetched) {
|
||||
handleFetchStacks({ silent: true });
|
||||
}
|
||||
}, [activeStep, connectionStatus.success, handleFetchStacks, stackLookup.fetched, stackLookup.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionStatus.success) {
|
||||
setStackLookup((prev) => ({ ...prev, items: [], fetched: false, error: "" }));
|
||||
setSelectedStackId("");
|
||||
}
|
||||
}, [connectionStatus.success]);
|
||||
|
||||
const finalSelfStackId = (selectedStackId || manualStackId || "").trim();
|
||||
|
||||
const canProceed = useMemo(() => {
|
||||
switch (activeStep) {
|
||||
case 0:
|
||||
return true;
|
||||
case 1:
|
||||
if (!requireSuperuser) return true;
|
||||
return (
|
||||
superuserForm.username.trim() &&
|
||||
superuserForm.email.trim() &&
|
||||
superuserForm.password.trim()
|
||||
);
|
||||
case 2: {
|
||||
const serverUrlValid = showServerSection ? Boolean(serverForm.url.trim()) : true;
|
||||
const apiKeyValid = showApiKeyField ? Boolean(apiKeyValue.trim()) : true;
|
||||
const connectionValid = (showServerSection || showApiKeyField)
|
||||
? connectionStatus.success
|
||||
: true;
|
||||
return serverUrlValid && apiKeyValid && connectionValid;
|
||||
}
|
||||
case 3:
|
||||
return true;
|
||||
case 4:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
activeStep,
|
||||
apiKeyValue,
|
||||
connectionStatus.success,
|
||||
requireSuperuser,
|
||||
showApiKeyField,
|
||||
showServerSection,
|
||||
superuserForm.email,
|
||||
superuserForm.password,
|
||||
superuserForm.username,
|
||||
serverForm.url
|
||||
]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (activeStep >= STEP_LABELS.length - 1) return;
|
||||
setActiveStep((prev) => Math.min(prev + 1, STEP_LABELS.length - 1));
|
||||
}, [activeStep]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
setActiveStep((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const handleFinishSetup = useCallback(async () => {
|
||||
if (!status || finalizing) return;
|
||||
|
||||
setFinalError(null);
|
||||
setFinalSuccess(false);
|
||||
setFinalizing(true);
|
||||
try {
|
||||
const payload = {};
|
||||
|
||||
if (requireSuperuser) {
|
||||
payload.superuser = {
|
||||
username: superuserForm.username.trim(),
|
||||
email: superuserForm.email.trim(),
|
||||
password: superuserForm.password
|
||||
};
|
||||
}
|
||||
|
||||
if (showServerSection) {
|
||||
payload.server = {
|
||||
name: serverForm.name.trim(),
|
||||
url: serverForm.url.trim()
|
||||
};
|
||||
}
|
||||
|
||||
if (showApiKeyField || apiKeyValue.trim()) {
|
||||
payload.apiKey = { value: apiKeyValue.trim() };
|
||||
}
|
||||
|
||||
if (finalSelfStackId) {
|
||||
payload.selfStackId = finalSelfStackId;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/setup/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const message = (() => {
|
||||
switch (payload.error) {
|
||||
case "SERVER_DETAILS_REQUIRED":
|
||||
return "Bitte gib eine gültige Server-URL an.";
|
||||
case "API_KEY_REQUIRED":
|
||||
return "Bitte gib einen gültigen API-Key an.";
|
||||
case "USERNAME_REQUIRED":
|
||||
return "Benutzername wird benötigt.";
|
||||
case "EMAIL_INVALID":
|
||||
return "Bitte gib eine gültige E-Mail-Adresse an.";
|
||||
case "PASSWORD_TOO_SHORT":
|
||||
return "Passwort muss mindestens 8 Zeichen enthalten.";
|
||||
case "INVALID_PASSWORD":
|
||||
return "Das Passwort ist ungültig.";
|
||||
case "API_KEY_ENCRYPT_FAILED":
|
||||
return "API-Key konnte nicht verschlüsselt werden.";
|
||||
case "SUPERUSER_EXISTS":
|
||||
return "Der Superuser wurde bereits angelegt.";
|
||||
default:
|
||||
return "Setup konnte nicht abgeschlossen werden.";
|
||||
}
|
||||
})();
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
setStatus(result.status);
|
||||
setFinalSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Setup] Abschluss fehlgeschlagen:", error);
|
||||
setFinalError(error.message || "Setup fehlgeschlagen.");
|
||||
} finally {
|
||||
setFinalizing(false);
|
||||
}
|
||||
}, [
|
||||
apiKeyValue,
|
||||
finalSelfStackId,
|
||||
finalizing,
|
||||
navigate,
|
||||
requireSuperuser,
|
||||
showApiKeyField,
|
||||
showServerSection,
|
||||
status,
|
||||
superuserForm.email,
|
||||
superuserForm.password,
|
||||
superuserForm.username,
|
||||
serverForm.name,
|
||||
serverForm.url
|
||||
]);
|
||||
|
||||
const stackItemsForDisplay = useMemo(() => stackLookup.items, [stackLookup.items]);
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (activeStep) {
|
||||
case 0:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Willkommen bei StackPulse
|
||||
</Typography>
|
||||
<Typography className="text-sm text-blue-gray-600">
|
||||
Dieser Assistent führt dich durch fünf Schritte:
|
||||
</Typography>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm text-blue-gray-600">
|
||||
<li>Superuser-Zugang anlegen</li>
|
||||
<li>Server-URL und Portainer API-Key hinterlegen und testen</li>
|
||||
<li>Den eigenen Stack in Portainer identifizieren</li>
|
||||
<li>Alle Angaben überprüfen und Setup abschließen</li>
|
||||
</ol>
|
||||
<Alert color="blue" className="bg-blue-50 text-blue-800">
|
||||
Du kannst jederzeit zum vorherigen Schritt zurückkehren. Vorwärts geht es,
|
||||
sobald alle Pflichtangaben eines Schritts erledigt sind.
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
case 1:
|
||||
return requireSuperuser ? (
|
||||
<div className="space-y-4">
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Superuser anlegen
|
||||
</Typography>
|
||||
<Typography className="text-sm text-blue-gray-600">
|
||||
Dieser Benutzer hat Zugriff auf alle Funktionen.
|
||||
</Typography>
|
||||
<div className="grid gap-4">
|
||||
<Input
|
||||
label="Benutzername"
|
||||
value={superuserForm.username}
|
||||
onChange={handleSuperuserChange("username")}
|
||||
readOnly={superuserUsernameReadOnly}
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
value={superuserForm.email}
|
||||
onChange={handleSuperuserChange("email")}
|
||||
readOnly={superuserEmailReadOnly}
|
||||
/>
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={superuserForm.password}
|
||||
onChange={handleSuperuserChange("password")}
|
||||
readOnly={superuserPasswordReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Alert color="green" className="bg-green-50 text-green-800">
|
||||
Superuser ist bereits vorhanden. Du kannst direkt zum nächsten Schritt gehen.
|
||||
</Alert>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Server und Portainer-Zugriff
|
||||
</Typography>
|
||||
<Typography className="text-sm text-blue-gray-600">
|
||||
Gib die URL zu deiner Portainer-Instanz sowie den dazugehörigen Business-API-Key ein
|
||||
und teste anschließend die Verbindung.
|
||||
</Typography>
|
||||
<div className="grid gap-4">
|
||||
<Input
|
||||
label="Server-Name"
|
||||
value={serverForm.name}
|
||||
onChange={handleServerChange("name")}
|
||||
readOnly={serverNameReadOnly}
|
||||
/>
|
||||
<Input
|
||||
label="Server-URL"
|
||||
required={showServerSection}
|
||||
value={serverForm.url}
|
||||
onChange={handleServerChange("url")}
|
||||
readOnly={serverUrlReadOnly}
|
||||
/>
|
||||
<Input
|
||||
label="Portainer API-Key"
|
||||
type="password"
|
||||
required={showApiKeyField}
|
||||
value={apiKeyValue}
|
||||
onChange={handleApiKeyChange}
|
||||
readOnly={apiKeyReadOnly}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
color="blue"
|
||||
onClick={handleTestConnection}
|
||||
disabled={connectionStatus.loading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{connectionStatus.loading ? "Teste Verbindung…" : "Verbindung testen"}
|
||||
</Button>
|
||||
{connectionStatus.tried && (
|
||||
<span
|
||||
className={`text-sm ${connectionStatus.success ? "text-green-600" : "text-red-600"}`}
|
||||
>
|
||||
{connectionStatus.message ||
|
||||
(connectionStatus.success
|
||||
? "Verbindung erfolgreich"
|
||||
: "Verbindungstest fehlgeschlagen")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Eigenen Stack wählen (optional)
|
||||
</Typography>
|
||||
<Typography className="text-sm text-blue-gray-600">
|
||||
StackPulse kann deinen eigenen Stack hinterlegen, um spätere Wartungsschritte zu erleichtern.
|
||||
Wir versuchen, passende Stacks automatisch zu finden.
|
||||
</Typography>
|
||||
<Alert color="blue" className="bg-blue-50 text-blue-800">
|
||||
Dieser Schritt ist optional. Du kannst die Self-Stack-ID später jederzeit im Bereich
|
||||
„Wartung > Server & API-Keys“ hinterlegen oder anpassen.
|
||||
</Alert>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
color="blue"
|
||||
variant="outlined"
|
||||
onClick={() => handleFetchStacks({ silent: false })}
|
||||
disabled={!connectionStatus.success || stackLookup.loading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{stackLookup.loading ? "Lade…" : "Stacks neu laden"}
|
||||
</Button>
|
||||
{stackLookup.updatedAt && (
|
||||
<span className="text-xs text-blue-gray-500">
|
||||
Stand: {stackLookup.updatedAt.toLocaleTimeString("de-DE")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{stackLookup.error && (
|
||||
<Alert color="red" className="bg-red-50 text-red-700">
|
||||
{stackLookup.error}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{stackItemsForDisplay.length === 0 && !stackLookup.loading ? (
|
||||
<p className="text-sm text-blue-gray-500">
|
||||
Es wurden keine Stacks gefunden, die "stackpulse" im Namen enthalten. Bitte die ID
|
||||
manuell eingeben oder die Suche erneut ausführen.
|
||||
</p>
|
||||
) : (
|
||||
stackItemsForDisplay.map((stack) => (
|
||||
<label
|
||||
key={stack.Id}
|
||||
className="flex cursor-pointer items-center justify-between rounded-md border border-blue-gray-100 p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-gray-900">{stack.Name || "Ohne Namen"}</p>
|
||||
<p className="text-xs text-blue-gray-500">ID: {stack.Id}</p>
|
||||
</div>
|
||||
<Radio
|
||||
name="stack-selection"
|
||||
value={String(stack.Id)}
|
||||
checked={selectedStackId === String(stack.Id)}
|
||||
onChange={() => {
|
||||
setSelectedStackId(String(stack.Id));
|
||||
setManualStackId("");
|
||||
}}
|
||||
ripple={false}
|
||||
/>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Typography className="text-sm font-semibold text-blue-gray-700">
|
||||
Alternative: Stack-ID manuell eintragen (optional)
|
||||
</Typography>
|
||||
<Input
|
||||
label="Stack-ID (optional)"
|
||||
value={manualStackId}
|
||||
onChange={(event) => {
|
||||
setManualStackId(event.target.value);
|
||||
setSelectedStackId("");
|
||||
}}
|
||||
placeholder="z. B. 42"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Zusammenfassung
|
||||
</Typography>
|
||||
<div className="space-y-4 rounded-md border border-blue-gray-100 p-4 text-sm text-blue-gray-700">
|
||||
<div>
|
||||
<p className="font-semibold">Superuser</p>
|
||||
<p>
|
||||
{requireSuperuser
|
||||
? `${superuserForm.username || "-"} (${superuserForm.email || "ohne E-Mail"})`
|
||||
: "Bereits vorhanden"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Server</p>
|
||||
<p>{serverForm.url || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">API-Key</p>
|
||||
<p>{apiKeyValue ? "(ausgefüllt)" : "nicht angegeben"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Self-Stack-ID</p>
|
||||
<p>{finalSelfStackId || "nicht ausgewählt"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{finalError && (
|
||||
<Alert color="red" className="bg-red-50 text-red-700">
|
||||
{finalError}
|
||||
</Alert>
|
||||
)}
|
||||
{finalSuccess && (
|
||||
<Alert color="green" className="bg-green-50 text-green-800">
|
||||
Setup abgeschlossen. Du wirst zum Login weitergeleitet …
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingStatus) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Lade Setup-Status …</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<Alert color="red" className="w-full max-w-lg border border-red-200 bg-red-50 text-red-700">
|
||||
{fetchError}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Keine Setup-Informationen verfügbar.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center py-10">
|
||||
<Card className="w-full max-w-3xl border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
floated={false}
|
||||
shadow={false}
|
||||
className="grid gap-1 rounded-none bg-transparent p-6 text-left"
|
||||
>
|
||||
<Typography variant="h4" color="blue-gray">
|
||||
System Setup
|
||||
</Typography>
|
||||
<Typography color="gray" className="text-sm">
|
||||
Folge den Schritten, um StackPulse mit deiner Portainer-Instanz zu verbinden.
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<Stepper activeStep={activeStep} className="w-full py-6">
|
||||
{STEP_LABELS.map((label, index) => (
|
||||
<Step
|
||||
key={label}
|
||||
onClick={() => setActiveStep(index)}
|
||||
className="cursor-pointer"
|
||||
activeClassName="bg-blue-600 text-white"
|
||||
completedClassName="bg-blue-600 text-white"
|
||||
>
|
||||
{index + 1}
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 text-center text-xs font-medium text-blue-gray-600 sm:grid-cols-5">
|
||||
{STEP_LABELS.map((label) => (
|
||||
<span key={label}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 min-h-[260px] space-y-6">{renderStepContent()}</div>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
variant="text"
|
||||
color="blue"
|
||||
onClick={handlePrev}
|
||||
disabled={activeStep === 0 || finalizing}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
{activeStep < STEP_LABELS.length - 1 ? (
|
||||
<Button
|
||||
color="blue"
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed || finalizing}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="green"
|
||||
onClick={handleFinishSetup}
|
||||
disabled={!canProceed || finalizing}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{finalizing ? "Setup wird abgeschlossen…" : "Setup abschließen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Setup;
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Square3Stack3DIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
ListBulletIcon,
|
||||
ServerStackIcon,
|
||||
RectangleStackIcon,
|
||||
ArrowLeftOnRectangleIcon,
|
||||
UserIcon,
|
||||
UserGroupIcon,
|
||||
KeyIcon
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Stacks, Maintenance, Logs, Users, Usergroups } from "@/pages/dashboard";
|
||||
import { SignIn, SignUp, SignOut, ForgotPassword } from "@/pages/auth";
|
||||
import PermissionGate from "@/components/PermissionGate.jsx";
|
||||
|
||||
const icon = {
|
||||
className: "w-5 h-5 text-inherit",
|
||||
};
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
layout: "dashboard",
|
||||
pages: [
|
||||
{
|
||||
icon: <Square3Stack3DIcon {...icon} />,
|
||||
name: "stacks",
|
||||
path: "/stacks",
|
||||
element: <Stacks />,
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
icon: <WrenchScrewdriverIcon {...icon} />,
|
||||
name: "wartung",
|
||||
path: "/maintenance",
|
||||
element: (
|
||||
<PermissionGate permission="maintenance-access" requiredLevel="read">
|
||||
<Maintenance />
|
||||
</PermissionGate>
|
||||
),
|
||||
permission: { key: "maintenance-access", requiredLevel: "read" },
|
||||
},
|
||||
{
|
||||
icon: <ListBulletIcon {...icon} />,
|
||||
name: "logs",
|
||||
path: "/logs",
|
||||
element: (
|
||||
<PermissionGate permission="logs-access" requiredLevel="full">
|
||||
<Logs />
|
||||
</PermissionGate>
|
||||
),
|
||||
permission: { key: "logs-access", requiredLevel: "full" },
|
||||
},
|
||||
{
|
||||
icon: <UserIcon {...icon} />,
|
||||
name: "benutzer",
|
||||
path: "/users",
|
||||
element: (
|
||||
<PermissionGate permission="users-access" requiredLevel="read">
|
||||
<Users />
|
||||
</PermissionGate>
|
||||
),
|
||||
permission: { key: "users-access", requiredLevel: "read" },
|
||||
},
|
||||
{
|
||||
icon: <UserGroupIcon {...icon} />,
|
||||
name: "rechtegruppen",
|
||||
path: "/usergroups",
|
||||
element: (
|
||||
<PermissionGate permission="user-groups-access" requiredLevel="read">
|
||||
<Usergroups />
|
||||
</PermissionGate>
|
||||
),
|
||||
permission: { key: "user-groups-access", requiredLevel: "read" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "auth pages",
|
||||
layout: "auth",
|
||||
pages: [
|
||||
{
|
||||
icon: <ServerStackIcon {...icon} />,
|
||||
name: "sign in",
|
||||
path: "/sign-in",
|
||||
element: <SignIn />,
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon {...icon} />,
|
||||
name: "passwort vergessen",
|
||||
path: "/forgot-password",
|
||||
element: <ForgotPassword />,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
icon: <ArrowLeftOnRectangleIcon {...icon} />,
|
||||
name: "log out",
|
||||
path: "/logout",
|
||||
element: <SignOut />,
|
||||
},
|
||||
{
|
||||
icon: <RectangleStackIcon {...icon} />,
|
||||
name: "sign up",
|
||||
path: "/sign-up",
|
||||
element: <SignUp />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "@/widgets/cards/statistics-card";
|
||||
export * from "@/widgets/cards/profile-info-card";
|
||||
export * from "@/widgets/cards/message-card";
|
||||
@@ -0,0 +1,45 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Avatar, Typography } from "@material-tailwind/react";
|
||||
|
||||
export function MessageCard({ img, name, message, action }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
src={img}
|
||||
alt={name}
|
||||
variant="rounded"
|
||||
className="shadow-lg shadow-blue-gray-500/25"
|
||||
/>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="mb-1 font-semibold"
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography className="text-xs font-normal text-blue-gray-400">
|
||||
{message}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MessageCard.defaultProps = {
|
||||
action: null,
|
||||
};
|
||||
|
||||
MessageCard.propTypes = {
|
||||
img: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
message: PropTypes.node.isRequired,
|
||||
action: PropTypes.node,
|
||||
};
|
||||
|
||||
MessageCard.displayName = "/src/widgets/cards/message-card.jsx";
|
||||
|
||||
export default MessageCard;
|
||||
@@ -0,0 +1,79 @@
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
|
||||
export function ProfileInfoCard({ title, description, details, action }) {
|
||||
return (
|
||||
<Card color="transparent" shadow={false}>
|
||||
<CardHeader
|
||||
color="transparent"
|
||||
shadow={false}
|
||||
floated={false}
|
||||
className="mx-0 mt-0 mb-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
{title}
|
||||
</Typography>
|
||||
{action}
|
||||
</CardHeader>
|
||||
<CardBody className="p-0">
|
||||
{description && (
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
{description && details ? (
|
||||
<hr className="my-8 border-blue-gray-50" />
|
||||
) : null}
|
||||
{details && (
|
||||
<ul className="flex flex-col gap-4 p-0">
|
||||
{Object.keys(details).map((el, key) => (
|
||||
<li key={key} className="flex items-center gap-4">
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="font-semibold capitalize"
|
||||
>
|
||||
{el}:
|
||||
</Typography>
|
||||
{typeof details[el] === "string" ? (
|
||||
<Typography
|
||||
variant="small"
|
||||
className="font-normal text-blue-gray-500"
|
||||
>
|
||||
{details[el]}
|
||||
</Typography>
|
||||
) : (
|
||||
details[el]
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
ProfileInfoCard.defaultProps = {
|
||||
action: null,
|
||||
description: null,
|
||||
details: {},
|
||||
};
|
||||
|
||||
ProfileInfoCard.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.node,
|
||||
details: PropTypes.object,
|
||||
};
|
||||
|
||||
ProfileInfoCard.displayName = "/src/widgets/cards/profile-info-card.jsx";
|
||||
|
||||
export default ProfileInfoCard;
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export function StatisticsCard({ color, icon, title, value, footer }) {
|
||||
return (
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
variant="gradient"
|
||||
color={color}
|
||||
floated={false}
|
||||
shadow={false}
|
||||
className="absolute grid h-12 w-12 place-items-center"
|
||||
>
|
||||
{icon}
|
||||
</CardHeader>
|
||||
<CardBody className="p-4 text-right">
|
||||
<Typography variant="small" className="font-normal text-blue-gray-600">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="blue-gray">
|
||||
{value}
|
||||
</Typography>
|
||||
</CardBody>
|
||||
{footer && (
|
||||
<CardFooter className="border-t border-blue-gray-50 p-4">
|
||||
{footer}
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
StatisticsCard.defaultProps = {
|
||||
color: "blue",
|
||||
footer: null,
|
||||
};
|
||||
|
||||
StatisticsCard.propTypes = {
|
||||
color: PropTypes.oneOf([
|
||||
"white",
|
||||
"blue-gray",
|
||||
"gray",
|
||||
"brown",
|
||||
"deep-orange",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"lime",
|
||||
"light-green",
|
||||
"green",
|
||||
"teal",
|
||||
"cyan",
|
||||
"light-blue",
|
||||
"blue",
|
||||
"indigo",
|
||||
"deep-purple",
|
||||
"purple",
|
||||
"pink",
|
||||
"red",
|
||||
]),
|
||||
icon: PropTypes.node.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
value: PropTypes.node.isRequired,
|
||||
footer: PropTypes.node,
|
||||
};
|
||||
|
||||
StatisticsCard.displayName = "/src/widgets/cards/statistics-card.jsx";
|
||||
|
||||
export default StatisticsCard;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@/widgets/charts/statistics-chart";
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import PropTypes from "prop-types";
|
||||
import Chart from "react-apexcharts";
|
||||
|
||||
export function StatisticsChart({ color, chart, title, description, footer }) {
|
||||
return (
|
||||
<Card className="border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader variant="gradient" color={color} floated={false} shadow={false}>
|
||||
<Chart {...chart} />
|
||||
</CardHeader>
|
||||
<CardBody className="px-6 pt-0">
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="small" className="font-normal text-blue-gray-600">
|
||||
{description}
|
||||
</Typography>
|
||||
</CardBody>
|
||||
{footer && (
|
||||
<CardFooter className="border-t border-blue-gray-50 px-6 py-5">
|
||||
{footer}
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
StatisticsChart.defaultProps = {
|
||||
color: "blue",
|
||||
footer: null,
|
||||
};
|
||||
|
||||
StatisticsChart.propTypes = {
|
||||
color: PropTypes.oneOf([
|
||||
"white",
|
||||
"blue-gray",
|
||||
"gray",
|
||||
"brown",
|
||||
"deep-orange",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"lime",
|
||||
"light-green",
|
||||
"green",
|
||||
"teal",
|
||||
"cyan",
|
||||
"light-blue",
|
||||
"blue",
|
||||
"indigo",
|
||||
"deep-purple",
|
||||
"purple",
|
||||
"pink",
|
||||
"red",
|
||||
]),
|
||||
chart: PropTypes.object.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
description: PropTypes.node.isRequired,
|
||||
footer: PropTypes.node,
|
||||
};
|
||||
|
||||
StatisticsChart.displayName = "/src/widgets/charts/statistics-chart.jsx";
|
||||
|
||||
export default StatisticsChart;
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Switch,
|
||||
Typography,
|
||||
Chip,
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
useMaterialTailwindController,
|
||||
setOpenConfigurator,
|
||||
setSidenavColor,
|
||||
setSidenavType,
|
||||
setFixedNavbar,
|
||||
} from "@/components";
|
||||
|
||||
function formatNumber(number, decPlaces) {
|
||||
decPlaces = Math.pow(10, decPlaces);
|
||||
|
||||
const abbrev = ["K", "M", "B", "T"];
|
||||
|
||||
for (let i = abbrev.length - 1; i >= 0; i--) {
|
||||
var size = Math.pow(10, (i + 1) * 3);
|
||||
|
||||
if (size <= number) {
|
||||
number = Math.round((number * decPlaces) / size) / decPlaces;
|
||||
|
||||
if (number == 1000 && i < abbrev.length - 1) {
|
||||
number = 1;
|
||||
i++;
|
||||
}
|
||||
|
||||
number += abbrev[i];
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
export function Configurator() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { openConfigurator, sidenavColor, sidenavType, fixedNavbar } =
|
||||
controller;
|
||||
const [stars, setStars] = React.useState(0);
|
||||
|
||||
const sidenavColors = {
|
||||
dark: "from-black to-black border-gray-200",
|
||||
green: "from-green-400 to-green-600",
|
||||
orange: "from-orange-400 to-orange-600",
|
||||
red: "from-red-400 to-red-600",
|
||||
pink: "from-pink-400 to-pink-600",
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const stars = fetch(
|
||||
"https://api.github.com/repos/creativetimofficial/material-tailwind-dashboard-react"
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => setStars(formatNumber(data.stargazers_count, 1)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed top-0 right-0 z-50 h-screen w-96 bg-white px-2.5 shadow-lg transition-transform duration-300 ${
|
||||
openConfigurator ? "translate-x-0" : "translate-x-96"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between px-6 pt-8 pb-6">
|
||||
<div>
|
||||
<Typography variant="h5" color="blue-gray">
|
||||
Dashboard Configurator
|
||||
</Typography>
|
||||
<Typography className="font-normal text-blue-gray-600">
|
||||
See our dashboard options.
|
||||
</Typography>
|
||||
</div>
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
onClick={() => setOpenConfigurator(dispatch, false)}
|
||||
>
|
||||
<XMarkIcon strokeWidth={2.5} className="h-5 w-5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="py-4 px-6">
|
||||
<div className="mb-12">
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
Sidenav Colors
|
||||
</Typography>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{Object.keys(sidenavColors).map((color) => (
|
||||
<span
|
||||
key={color}
|
||||
className={`h-6 w-6 cursor-pointer rounded-full border bg-gradient-to-br transition-transform hover:scale-105 ${
|
||||
sidenavColors[color]
|
||||
} ${
|
||||
sidenavColor === color ? "border-black" : "border-transparent"
|
||||
}`}
|
||||
onClick={() => setSidenavColor(dispatch, color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-12">
|
||||
<hr />
|
||||
<div className="flex items-center justify-between py-5">
|
||||
<Typography variant="h6" color="blue-gray">
|
||||
Navbar Fixed
|
||||
</Typography>
|
||||
<Switch defaultChecked
|
||||
id="navbar-fixed"
|
||||
value={fixedNavbar}
|
||||
onChange={() => setFixedNavbar(dispatch, !fixedNavbar)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
Configurator.displayName = "/src/widgets/layout/configurator.jsx";
|
||||
|
||||
export default Configurator;
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import {
|
||||
Navbar,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Breadcrumbs,
|
||||
Menu,
|
||||
MenuHandler,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
} from "@material-tailwind/react";
|
||||
import {
|
||||
UserCircleIcon,
|
||||
Cog6ToothIcon,
|
||||
BellIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
Bars3Icon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import {
|
||||
useMaterialTailwindController,
|
||||
setOpenConfigurator,
|
||||
setOpenSidenav,
|
||||
} from "@/components";
|
||||
|
||||
export function DashboardNavbar() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { fixedNavbar, openSidenav } = controller;
|
||||
const { pathname } = useLocation();
|
||||
const [layout, page] = pathname.split("/").filter((el) => el !== "");
|
||||
|
||||
return (
|
||||
<Navbar
|
||||
color={fixedNavbar ? "white" : "transparent"}
|
||||
className={`rounded-xl transition-all ${
|
||||
fixedNavbar
|
||||
? "sticky top-4 z-40 py-3 shadow-md shadow-blue-gray-500/5"
|
||||
: "px-0 py-1"
|
||||
}`}
|
||||
fullWidth
|
||||
blurred={fixedNavbar}
|
||||
>
|
||||
<div className="flex justify-between gap-6 flex-row items-center">
|
||||
<div className="capitalize">
|
||||
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
className="grid xl:hidden"
|
||||
onClick={() => setOpenSidenav(dispatch, !openSidenav)}
|
||||
>
|
||||
<Bars3Icon strokeWidth={3} className="h-6 w-6 text-blue-gray-500" />
|
||||
</IconButton>
|
||||
{/* <Link to="/auth/sign-in">
|
||||
<Button
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
className="hidden items-center gap-1 px-4 xl:flex normal-case"
|
||||
>
|
||||
<UserCircleIcon className="h-5 w-5 text-blue-gray-500" />
|
||||
Sign In
|
||||
</Button>
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
className="grid xl:hidden"
|
||||
>
|
||||
<UserCircleIcon className="h-5 w-5 text-blue-gray-500" />
|
||||
</IconButton>
|
||||
</Link>
|
||||
<Menu>
|
||||
<MenuHandler>
|
||||
<IconButton variant="text" color="blue-gray">
|
||||
<BellIcon className="h-5 w-5 text-blue-gray-500" />
|
||||
</IconButton>
|
||||
</MenuHandler>
|
||||
<MenuList className="w-max border-0">
|
||||
<MenuItem className="flex items-center gap-3">
|
||||
<Avatar
|
||||
src="https://demos.creative-tim.com/material-dashboard/assets/img/team-2.jpg"
|
||||
alt="item-1"
|
||||
size="sm"
|
||||
variant="circular"
|
||||
/>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="mb-1 font-normal"
|
||||
>
|
||||
<strong>New message</strong> from Laur
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="flex items-center gap-1 text-xs font-normal opacity-60"
|
||||
>
|
||||
<ClockIcon className="h-3.5 w-3.5" /> 13 minutes ago
|
||||
</Typography>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem className="flex items-center gap-4">
|
||||
<Avatar
|
||||
src="https://demos.creative-tim.com/material-dashboard/assets/img/small-logos/logo-spotify.svg"
|
||||
alt="item-1"
|
||||
size="sm"
|
||||
variant="circular"
|
||||
/>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="mb-1 font-normal"
|
||||
>
|
||||
<strong>New album</strong> by Travis Scott
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="flex items-center gap-1 text-xs font-normal opacity-60"
|
||||
>
|
||||
<ClockIcon className="h-3.5 w-3.5" /> 1 day ago
|
||||
</Typography>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem className="flex items-center gap-4">
|
||||
<div className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-tr from-blue-gray-800 to-blue-gray-900">
|
||||
<CreditCardIcon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="mb-1 font-normal"
|
||||
>
|
||||
Payment successfully completed
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="flex items-center gap-1 text-xs font-normal opacity-60"
|
||||
>
|
||||
<ClockIcon className="h-3.5 w-3.5" /> 2 days ago
|
||||
</Typography>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu> */}
|
||||
<IconButton
|
||||
variant="text"
|
||||
color="blue-gray"
|
||||
onClick={() => setOpenConfigurator(dispatch, true)}
|
||||
>
|
||||
<Cog6ToothIcon className="h-5 w-5 text-blue-gray-500 " />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
DashboardNavbar.displayName = "/src/widgets/layout/dashboard-navbar.jsx";
|
||||
|
||||
export default DashboardNavbar;
|
||||
@@ -0,0 +1,20 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Typography } from "@material-tailwind/react";
|
||||
import { HeartIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer>
|
||||
<div className="flex w-full flex-wrap items-center justify-center md:justify-end">
|
||||
<Typography variant="small" className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">
|
||||
StackPulse: © {year} by Michael Böhmländer
|
||||
</Typography>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
Footer.displayName = "/src/widgets/layout/footer.jsx";
|
||||
export default Footer;
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "@/widgets/layout/sidenav";
|
||||
export * from "@/widgets/layout/dashboard-navbar";
|
||||
export * from "@/widgets/layout/configurator";
|
||||
export * from "@/widgets/layout/footer";
|
||||
export * from "@/widgets/layout/navbar";
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Navbar as MTNavbar,
|
||||
Collapse,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
} from "@material-tailwind/react";
|
||||
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export function Navbar({ brandName, routes, action }) {
|
||||
const [openNav, setOpenNav] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener(
|
||||
"resize",
|
||||
() => window.innerWidth >= 960 && setOpenNav(false)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const navList = (
|
||||
<ul className="mb-4 mt-2 flex flex-col gap-2 lg:mb-0 lg:mt-0 lg:flex-row lg:items-center lg:gap-6">
|
||||
{routes.map(({ name, path, icon }) => (
|
||||
<Typography
|
||||
key={name}
|
||||
as="li"
|
||||
variant="small"
|
||||
color="blue-gray"
|
||||
className="capitalize"
|
||||
>
|
||||
<Link to={path} className="flex items-center gap-1 p-1 font-normal">
|
||||
{icon &&
|
||||
React.createElement(icon, {
|
||||
className: "w-[18px] h-[18px] opacity-50 mr-1",
|
||||
})}
|
||||
{name}
|
||||
</Link>
|
||||
</Typography>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<MTNavbar className="p-3">
|
||||
<div className="container mx-auto flex items-center justify-between text-blue-gray-900">
|
||||
<Link to="/">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mr-4 ml-2 cursor-pointer py-1.5 font-bold"
|
||||
>
|
||||
{brandName}
|
||||
</Typography>
|
||||
</Link>
|
||||
<div className="hidden lg:block">{navList}</div>
|
||||
{React.cloneElement(action, {
|
||||
className: "hidden lg:inline-block",
|
||||
})}
|
||||
<IconButton
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="hml-auto text-inherit hover:bg-transparent focus:bg-transparent active:bg-transparent lg:hidden"
|
||||
onClick={() => setOpenNav(!openNav)}
|
||||
>
|
||||
{openNav ? (
|
||||
<XMarkIcon strokeWidth={2} className="h-6 w-6" />
|
||||
) : (
|
||||
<Bars3Icon strokeWidth={2} className="h-6 w-6" />
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
<Collapse open={openNav}>
|
||||
<div className="container mx-auto">
|
||||
{navList}
|
||||
{React.cloneElement(action, {
|
||||
className: "w-full block lg:hidden",
|
||||
})}
|
||||
</div>
|
||||
</Collapse>
|
||||
</MTNavbar>
|
||||
);
|
||||
}
|
||||
|
||||
Navbar.defaultProps = {
|
||||
brandName: "Material Tailwind React",
|
||||
action: (
|
||||
<a
|
||||
href="https://www.creative-tim.com/product/material-tailwind-dashboard-react"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="gradient" size="sm" fullWidth>
|
||||
free download
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
};
|
||||
|
||||
Navbar.propTypes = {
|
||||
brandName: PropTypes.string,
|
||||
routes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
action: PropTypes.node,
|
||||
};
|
||||
|
||||
Navbar.displayName = "/src/widgets/layout/navbar.jsx";
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,123 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { Avatar, Button, IconButton, Typography } from "@material-tailwind/react";
|
||||
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
import logo from "@/assets/images/stackpulse.png";
|
||||
|
||||
export function Sidenav({ brandImg, brandName, routes }) {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { sidenavColor, sidenavType, openSidenav } = controller;
|
||||
const { initialized: authInitialized, hasPermission } = useAuth();
|
||||
const sidenavTypes = {
|
||||
dark: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||
white: "bg-white shadow-sm",
|
||||
transparent: "bg-transparent",
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${sidenavTypes[sidenavType]} ${openSidenav ? "translate-x-0" : "-translate-x-80"
|
||||
} fixed inset-0 z-50 my-4 ml-4 h-[calc(100vh-32px)] w-72 rounded-xl transition-transform duration-300 xl:translate-x-0 border border-blue-gray-100`}
|
||||
>
|
||||
<div className="relative py-6 px-8 text-right">
|
||||
<img src={logo} alt="StackPulse" className="w-auto" /><span className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">v0.5</span>
|
||||
|
||||
<IconButton
|
||||
variant="text"
|
||||
size="sm"
|
||||
ripple={false}
|
||||
className="absolute right-0 top-0 grid rounded-br-none rounded-tl-none"
|
||||
onClick={() => setOpenSidenav(dispatch, false)}
|
||||
>
|
||||
<XMarkIcon strokeWidth={2.5} className="h-5 w-5 text-black" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="m-4">
|
||||
{routes.map(({ layout, title, pages }, key) => {
|
||||
const visiblePages = pages.filter(({ permission, hidden }) => {
|
||||
if (hidden) {
|
||||
return false;
|
||||
}
|
||||
if (!permission || !permission.key) {
|
||||
return true;
|
||||
}
|
||||
if (!authInitialized) {
|
||||
return false;
|
||||
}
|
||||
const requiredLevel = permission.requiredLevel || permission.level || "full";
|
||||
return hasPermission(permission.key, requiredLevel);
|
||||
});
|
||||
|
||||
if (layout !== "dashboard" && layout !== "auth") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (visiblePages.length === 0 && !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul key={key} className="mb-4 flex flex-col gap-1">
|
||||
{title && (
|
||||
<li className="mx-3.5 mt-4 mb-2">
|
||||
<Typography
|
||||
variant="small"
|
||||
color={sidenavType === "dark" ? "white" : "blue-gray"}
|
||||
className="font-black uppercase opacity-75"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</li>
|
||||
)}
|
||||
{visiblePages.map(({ icon, name, path }) => (
|
||||
<li key={name}>
|
||||
<NavLink to={`/${layout}${path}`}>
|
||||
{({ isActive }) => (
|
||||
<Button
|
||||
variant={isActive ? "gradient" : "text"}
|
||||
color={
|
||||
isActive
|
||||
? sidenavColor
|
||||
: sidenavType === "dark"
|
||||
? "white"
|
||||
: "blue-gray"
|
||||
}
|
||||
className="flex items-center gap-4 px-4 capitalize"
|
||||
fullWidth
|
||||
>
|
||||
{icon}
|
||||
<Typography
|
||||
color="inherit"
|
||||
className="font-medium capitalize"
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
</Button>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
Sidenav.defaultProps = {
|
||||
brandImg: "/img/logo-ct.png",
|
||||
brandName: "Material Tailwind React",
|
||||
};
|
||||
|
||||
Sidenav.propTypes = {
|
||||
brandImg: PropTypes.string,
|
||||
brandName: PropTypes.string,
|
||||
routes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
};
|
||||
|
||||
Sidenav.displayName = "/src/widgets/layout/sidnave.jsx";
|
||||
|
||||
export default Sidenav;
|
||||
@@ -0,0 +1,38 @@
|
||||
const withMT = require("@material-tailwind/react/utils/withMT");
|
||||
const colors = require("tailwindcss/colors");
|
||||
|
||||
module.exports = withMT({
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
sky: colors.sky,
|
||||
green: colors.green,
|
||||
slate: colors.slate,
|
||||
orange: colors.orange, // falls du z. B. text-orange-300 brauchst
|
||||
auroraTeal: { 50: '#f0fafb', 100: '#e0f5f7', 200: '#c3ecef', 300: '#9de0e4', 400: '#6ad3d8', 500: '#38c6cc', 600: '#2aa9b0', 700: '#1f8d94', 800: '#166e76', 900: '#0c4f58' },
|
||||
sunsetCoral: { 50: '#fff6f5', 100: '#ffece9', 200: '#ffd7d3', 300: '#ffb8b5', 400: '#ff9189', 500: '#ff6a5d', 600: '#e65048', 700: '#b93d38', 800: '#8a2c28', 900: '#5c1c1a' },
|
||||
lavenderSmoke: { 50: '#faf8fc', 100: '#f5f1f9', 200: '#ece2f3', 300: '#dac4e6', 400: '#b88acf', 500: '#9670b8', 600: '#7a5894', 700: '#5d4373', 800: '#412f52', 900: '#291e38' },
|
||||
mossGreen: { 50: '#f5faf2', 100: '#ebf5e5', 200: '#d9ebca', 300: '#b8d99e', 400: '#8cc56f', 500: '#60b240', 600: '#4d8f33', 700: '#3b6b27', 800: '#2a491d', 900: '#1c3012' },
|
||||
deepOceanBlue: { 50: '#f4faff', 100: '#e9f5ff', 200: '#cfe6ff', 300: '#add1ff', 400: '#7ab9ff', 500: '#479fff', 600: '#3c86e6', 700: '#305eaf', 800: '#23437f', 900: '#162a50' },
|
||||
warmAmberGlow: { 50: '#fff9f2', 100: '#fff3e5', 200: '#ffe6cc', 300: '#ffcc99', 400: '#ffad4d', 500: '#ff8f00', 600: '#e67600', 700: '#b35a00', 800: '#804000', 900: '#4d2600' },
|
||||
peachMist: { 50: '#fff8f7', 100: '#fff0ed', 200: '#ffe1db', 300: '#ffc2b7', 400: '#ff9a87', 500: '#ff7257', 600: '#e65f4b', 700: '#b34938', 800: '#803622', 900: '#4d230f' },
|
||||
slatePurple: { 50: '#f7f5fa', 100: '#efe9f5', 200: '#ded1ea', 300: '#bdb3d5', 400: '#9584b5', 500: '#6d5795', 600: '#5c4a7c', 700: '#473861', 800: '#322648', 900: '#1f1530' },
|
||||
mintTea: { 50: '#f2fcfa', 100: '#e5f9f6', 200: '#ccefee', 300: '#99dfdc', 400: '#66cfc9', 500: '#33bfb6', 600: '#2aa395', 700: '#228174', 800: '#195953', 900: '#103330' },
|
||||
copperRust: { 50: '#faf7f6', 100: '#f5efee', 200: '#ebe0dc', 300: '#d6c1b8', 400: '#b28a86', 500: '#8d4d55', 600: '#7a444b', 700: '#5f3338', 800: '#442426', 900: '#2d1718' },
|
||||
// Neue 10
|
||||
arcticBlue: { 50: '#f2f9fd', 100: '#e6f3fb', 200: '#cce6f7', 300: '#99ceef', 400: '#66b6e7', 500: '#339ede', 600: '#1f86c3', 700: '#16699b', 800: '#0f4b6f', 900: '#082d43' },
|
||||
roseQuartz: { 50: '#fdf7f9', 100: '#faeff3', 200: '#f5dfe6', 300: '#ecbfd0', 400: '#e08dad', 500: '#d45b89', 600: '#b94771', 700: '#8e3556', 800: '#63233b', 900: '#391324' },
|
||||
citrusPunch: { 50: '#fffdf6', 100: '#fffbe9', 200: '#fff5cc', 300: '#ffeb99', 400: '#ffe066', 500: '#ffd633', 600: '#e6bd00', 700: '#b39300', 800: '#806900', 900: '#4d3f00' },
|
||||
midnightPlum: { 50: '#faf7fb', 100: '#f4ecf6', 200: '#e4cfee', 300: '#cba0de', 400: '#a869cc', 500: '#8431b9', 600: '#6d299b', 700: '#521f74', 800: '#38154d', 900: '#1f0b28' },
|
||||
sandstone: { 50: '#fbf9f7', 100: '#f6f2ee', 200: '#ece3da', 300: '#d8c6b3', 400: '#c0a27f', 500: '#a87e4b', 600: '#906a3e', 700: '#6b4f2f', 800: '#47341f', 900: '#241a10' },
|
||||
emeraldMist: { 50: '#f3faf7', 100: '#e7f6ef', 200: '#ccecdc', 300: '#9ddabf', 400: '#6ec8a3', 500: '#3fb586', 600: '#359771', 700: '#2a765a', 800: '#1f5442', 900: '#13332a' },
|
||||
velvetRed: { 50: '#fdf7f7', 100: '#faeeee', 200: '#f4d6d6', 300: '#e7a8a8', 400: '#d56666', 500: '#c32424', 600: '#a61e1e', 700: '#801717', 800: '#590f0f', 900: '#330808' },
|
||||
stormGrey: { 50: '#f7f8fa', 100: '#eef0f3', 200: '#d9dce2', 300: '#b6bbc8', 400: '#8b91a4', 500: '#606780', 600: '#4f556b', 700: '#3e4456', 800: '#2c313f', 900: '#1b1e29' },
|
||||
neonLime: { 50: '#f7ffe6', 100: '#eeffc9', 200: '#dbff96', 300: '#c2ff52', 400: '#a8ff0e', 500: '#8de600', 600: '#74bf00', 700: '#5b9900', 800: '#426f00', 900: '#294600' },
|
||||
skyMauve: { 50: '#faf8fc', 100: '#f4f0f9', 200: '#e5dbf2', 300: '#c9b2e6', 400: '#a27dd4', 500: '#7b48c2', 600: '#633aa0', 700: '#4b2b7c', 800: '#341d58', 900: '#1e1035' },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
darkMode: 'class', // Aktiviert Dark Mode via class="dark"
|
||||
plugins: [],
|
||||
}
|
||||
|
||||