Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f109423f54 | |||
| fc3ed57a40 | |||
| 9b25cb807a | |||
| adbf38c655 | |||
| 81b7613c73 | |||
| fc778da204 | |||
| d88edcd6ef | |||
| 71ab82b592 | |||
| 3140a024e6 | |||
| ae3a3f93e8 | |||
| 36a6e2b66c | |||
| 4eea4b23bd | |||
| e96cd63f33 | |||
| d65585b821 | |||
| a76567969a | |||
| f0b1ca4b68 | |||
| 9133928401 | |||
| 5e40b3105c | |||
| 7072a21ed8 | |||
| 754b023435 | |||
| ac57b932c2 | |||
| e607310689 | |||
| 1f8906bc3d | |||
| d6ddf12c04 | |||
| 23d46e7c40 | |||
| d8e0bdc814 | |||
| f45a2525e6 | |||
| 5852fc6616 | |||
| aaf9afdc9f | |||
| 98a6df5081 | |||
| 2c3379026b | |||
| 84be8e5828 | |||
| 1a5de83d50 | |||
| 476b09499a | |||
| 974462e983 | |||
| 749f04c2c4 | |||
| 7de4e85a38 | |||
| 4266adc853 | |||
| f7be617e1d | |||
| 4dd2ba9d9d | |||
| f8209c3712 | |||
| d776b80fe0 | |||
| e3d802eeef | |||
| ca59231480 | |||
| 1d0ece6940 | |||
| 5fcc7fdbf1 | |||
| 3b12557a89 | |||
| 666e4e67ad | |||
| 11f4759e78 | |||
| de4a05a6e6 | |||
| b682f3378c | |||
| 56891182d5 | |||
| d25d34b8cd | |||
| 016cb6671a | |||
| 9313c01637 | |||
| 96b1446292 | |||
| 14b1459767 | |||
| e2cc586584 | |||
| c71025d717 | |||
| e8aeecec43 | |||
| 4e63ae529e | |||
| a8c01b9cb2 | |||
| b6579f511d | |||
| db46b366ab | |||
| efa732f347 | |||
| a09a68ed39 | |||
| 81f7660768 | |||
| a53a91caeb | |||
| 841a1713d2 | |||
| 737495d186 | |||
| 6ed9b94d3a | |||
| 95336e4350 | |||
| 8a2b4a2ac5 | |||
| cf0b51eb7d | |||
| 3875d275e0 | |||
| 3f68c6e39a | |||
| 53f1b55157 | |||
| dc9b92bdff | |||
| 6e7ec194af | |||
| 3003084112 | |||
| 4592daff36 | |||
| 3c16c4d8e4 | |||
| 959af0cdd4 | |||
| 777ea4d74f | |||
| a9228a2557 | |||
| 2b745e3d78 | |||
| eada55b8d7 | |||
| b1f5ac03f6 | |||
| 2c78ab966d | |||
| cd003a12f8 | |||
| 752f8170ba | |||
| be1890582c | |||
| b74d49a836 | |||
| cba236b95f | |||
| 040cd4d3cb | |||
| 3649d2ee04 | |||
| 0a6d17c64e | |||
| 7a69dd0821 | |||
| 4053db4a3e | |||
| 35d64d6765 | |||
| 6000641e50 |
@@ -15,3 +15,26 @@ 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?
|
||||
/site/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -22,6 +22,9 @@ RUN npm run build
|
||||
# ===============================
|
||||
FROM node:20-alpine AS runtime
|
||||
|
||||
#Install SSH-Client
|
||||
RUN apk add --no-cache openssh-client
|
||||
|
||||
# Arbeitsverzeichnis
|
||||
WORKDIR /app/backend
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# 📦 StackPulse
|
||||

|
||||
|
||||
# 📦 StackPulse 
|
||||
|
||||
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
||||
StackPulse funktioniert nur mit der Business-Edition von Portainer. Die Communitiy-Edition wird nicht unterstützt!
|
||||
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
||||
|
||||
Ziel:
|
||||
@@ -13,7 +16,7 @@ Ziel:
|
||||
## 🚀 Features & Roadmap
|
||||
|
||||
<details>
|
||||
<summary>✅ v0.1.0 – Initial Release</summary>
|
||||
<summary>✅ v0.1 – Initial Release</summary>
|
||||
|
||||
- Projektstruktur mit Frontend & Backend
|
||||
- Lokales Startskript (`scripts/start-dev.sh`)
|
||||
@@ -24,8 +27,8 @@ Ziel:
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>🟡 v0.2.0 – In Entwicklung</summary>
|
||||
<details>
|
||||
<summary>✅ v0.2 – Release</summary>
|
||||
|
||||
### Backend
|
||||
- [x] Anbindung einer SQLite-Datenbank
|
||||
@@ -45,15 +48,85 @@ Ziel:
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>🔮 Geplante Features (v0.3+)</summary>
|
||||
<summary>✅ v0.3 – Release</summary>
|
||||
|
||||
- Notifications (z. B. via Webhooks oder Mail)
|
||||
- Authentifizierung & Benutzerverwaltung
|
||||
- Monitoring (Status, CPU/RAM)
|
||||
- Verbesserte UI/UX
|
||||
### Backend
|
||||
- [x] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
|
||||
- [x] Erweiterung der Logs
|
||||
- [x] API für Filter & Suche (Stacks nach Name/Status abrufen)
|
||||
|
||||
### Frontend
|
||||
- [x] Filter: Stacks nach Name oder Status durchsuchen
|
||||
- [x] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast)
|
||||
- [x] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
|
||||
|
||||
### Features
|
||||
- [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>✅ v0.4 – Release</summary>
|
||||
|
||||
### 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 und Sortierungen
|
||||
- Multi-Server Verwaltung
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -62,6 +135,8 @@ Ziel:
|
||||
```bash
|
||||
stackpulse/
|
||||
├── backend/ # Node.js Backend mit Express
|
||||
│ └── data # SQlite Datenbank
|
||||
│ └── db # Datenbank-Integration
|
||||
├── frontend/ # React Frontend mit Tailwind
|
||||
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
|
||||
│ └── start-dev.sh #Skript für den lokalen Start
|
||||
@@ -88,21 +163,29 @@ stackpulse/
|
||||
|
||||
### Mit Compose starten
|
||||
```bash
|
||||
version: "2.4"
|
||||
version: '3.8'
|
||||
services:
|
||||
stackpulse:
|
||||
container_name: stackpulse
|
||||
image: ghcr.io/mboehmlaender/stackpulse
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '4001:4001'
|
||||
- "4001:4001"
|
||||
volumes:
|
||||
- stackpulse_data:/app/backend/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORTAINER_URL=Deine_Portainer_Adresse/
|
||||
- PORTAINER_API_KEY=Dein_Portainer_API_Key
|
||||
- PORTAINER_ENDPOINT_ID=Deine_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)
|
||||
volumes:
|
||||
stackpulse_data:
|
||||
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Voraussetzungen
|
||||
|
||||
- Node.js >= 20
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 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,245 @@
|
||||
-- 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 user_server_assignments (
|
||||
user_id INTEGER NOT NULL,
|
||||
server_id INTEGER NOT NULL,
|
||||
group_id INTEGER,
|
||||
use_global_group INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, server_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX idx_user_server_assignments_user ON user_server_assignments (user_id);
|
||||
CREATE INDEX idx_user_server_assignments_server ON user_server_assignments (server_id);
|
||||
|
||||
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,
|
||||
parent_group_id INTEGER,
|
||||
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,
|
||||
FOREIGN KEY (parent_group_id) REFERENCES permission_groups(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_global_scope INTEGER NOT NULL DEFAULT 1,
|
||||
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 server_ssh_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
username TEXT NOT NULL,
|
||||
password_cipher TEXT,
|
||||
password_iv TEXT,
|
||||
password_tag TEXT,
|
||||
extra_args TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_server_ssh_configs_server ON server_ssh_configs (server_id);
|
||||
|
||||
CREATE TABLE server_update_scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL UNIQUE,
|
||||
custom_script TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_server_update_scripts_server ON server_update_scripts (server_id);
|
||||
|
||||
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,772 @@
|
||||
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,
|
||||
global: false,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'stacks-redeploy-selection',
|
||||
label: 'Redeploy Auswahl',
|
||||
sortOrder: 1,
|
||||
global: false,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'stacks-redeploy-all',
|
||||
label: 'Redeploy Alle',
|
||||
sortOrder: 2,
|
||||
global: false,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
title: 'Logs',
|
||||
sortOrder: 1,
|
||||
hasNavigation: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'logs-access',
|
||||
label: 'Bereich & Navigation',
|
||||
sortOrder: 0,
|
||||
global: true,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'logs-export',
|
||||
label: 'Logs Exportieren',
|
||||
sortOrder: 1,
|
||||
global: true,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'logs-delete',
|
||||
label: 'Logs löschen',
|
||||
sortOrder: 2,
|
||||
global: true,
|
||||
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,
|
||||
global: true,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'users-edit',
|
||||
label: 'Benutzer bearbeiten',
|
||||
sortOrder: 1,
|
||||
global: true,
|
||||
defaultLevel: 'read',
|
||||
levels: ['full', 'read'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users-delete',
|
||||
label: 'Benutzer löschen',
|
||||
sortOrder: 2,
|
||||
global: true,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users-security-phrase',
|
||||
label: 'Sicherheitsschlüssel',
|
||||
sortOrder: 3,
|
||||
global: true,
|
||||
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,
|
||||
global: true,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
isRequired: 1,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
key: 'user-groups-edit',
|
||||
label: 'Benutzergruppen bearbeiten',
|
||||
sortOrder: 1,
|
||||
global: true,
|
||||
defaultLevel: 'read',
|
||||
levels: ['full', 'read'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'user-groups-delete',
|
||||
label: 'Benutzergruppen löschen',
|
||||
sortOrder: 2,
|
||||
global: true,
|
||||
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,
|
||||
global: true,
|
||||
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,
|
||||
global: true,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-server-edit',
|
||||
label: 'Server bearbeiten',
|
||||
sortOrder: 1,
|
||||
global: false,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-server-delete',
|
||||
label: 'Server löschen',
|
||||
sortOrder: 2,
|
||||
global: false,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: 'full' },
|
||||
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '=full' }
|
||||
]
|
||||
}
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
key: 'maintenance-portainer-group',
|
||||
title: 'Portainer',
|
||||
sortOrder: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-ssh-update',
|
||||
label: 'SSH/Update-Skript',
|
||||
sortOrder: 0,
|
||||
global: false,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '!=none' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-update',
|
||||
label: 'Update durchführen',
|
||||
sortOrder: 1,
|
||||
global: false,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-duplicates-group',
|
||||
title: 'Doppelte Stacks',
|
||||
sortOrder: 2,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-duplicates',
|
||||
label: 'Doppelte Stacks',
|
||||
sortOrder: 0,
|
||||
global: false,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'read', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-superuser-group',
|
||||
title: 'Superuser',
|
||||
sortOrder: 1,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-superuser-delete',
|
||||
label: 'Superuser löschen',
|
||||
sortOrder: 0,
|
||||
global: true,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', 'none'],
|
||||
dependencies: [
|
||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'maintenance-mtls-group',
|
||||
title: 'mTLS',
|
||||
sortOrder: 2,
|
||||
items: [
|
||||
{
|
||||
key: 'maintenance-mtls',
|
||||
label: 'mTLS Sektion',
|
||||
sortOrder: 0,
|
||||
global: true,
|
||||
defaultLevel: 'none',
|
||||
levels: ['full', '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',
|
||||
'server_agents',
|
||||
'agent_tls_material',
|
||||
'agent_bootstrap_tokens'
|
||||
];
|
||||
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, parent_group_id
|
||||
FROM permission_groups
|
||||
WHERE section_id = ? AND key = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const insertGroup = db.prepare(`
|
||||
INSERT INTO permission_groups (section_id, parent_group_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 = ?, parent_group_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
const deleteUnusedGroups = db.prepare(`
|
||||
DELETE FROM permission_groups
|
||||
WHERE key NOT IN (SELECT value FROM json_each(?))
|
||||
`);
|
||||
|
||||
const selectItem = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
section_id,
|
||||
group_id,
|
||||
label,
|
||||
sort_order,
|
||||
default_level,
|
||||
available_levels,
|
||||
is_global_scope,
|
||||
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_global_scope,
|
||||
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_global_scope = ?,
|
||||
is_required = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
const deleteUnusedItems = db.prepare(`
|
||||
DELETE FROM permission_items
|
||||
WHERE key NOT IN (SELECT value FROM json_each(?))
|
||||
`);
|
||||
const deleteOrphanDependencies = db.prepare(`
|
||||
DELETE FROM permission_dependencies
|
||||
WHERE permission_id NOT IN (SELECT id FROM permission_items)
|
||||
OR depends_on_permission_id NOT IN (SELECT id FROM permission_items)
|
||||
`);
|
||||
const deleteOrphanPermissionValues = db.prepare(`
|
||||
DELETE FROM group_permission_values
|
||||
WHERE permission_id NOT IN (SELECT id FROM permission_items)
|
||||
`);
|
||||
|
||||
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();
|
||||
const dependencyQueue = [];
|
||||
const itemKeys = new Set();
|
||||
const groupKeys = new Set();
|
||||
|
||||
const upsertItem = ({ item, sectionId, groupId = null, sortOrder }) => {
|
||||
const existingItem = selectItem.get(item.key);
|
||||
const itemSortOrder = typeof sortOrder === 'number' ? sortOrder : 0;
|
||||
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;
|
||||
const isGlobal = item.global === false ? 0 : 1;
|
||||
const label = item.label || item.key || '';
|
||||
|
||||
if (!existingItem) {
|
||||
const result = insertItem.run(
|
||||
sectionId,
|
||||
groupId,
|
||||
item.key,
|
||||
label,
|
||||
itemSortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isGlobal,
|
||||
isRequired
|
||||
);
|
||||
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
||||
console.log(`ℹ️ Berechtigung ${item.key} ${groupId ? `in Gruppe ${groupId}` : ''} angelegt`);
|
||||
} else {
|
||||
const hasChanges =
|
||||
existingItem.section_id !== sectionId ||
|
||||
existingItem.group_id !== groupId ||
|
||||
existingItem.label !== label ||
|
||||
(existingItem.sort_order ?? itemSortOrder) !== itemSortOrder ||
|
||||
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
||||
existingItem.available_levels !== availableLevels ||
|
||||
Number(existingItem.is_global_scope ?? 1) !== isGlobal ||
|
||||
Number(existingItem.is_required) !== isRequired;
|
||||
updateItem.run(
|
||||
sectionId,
|
||||
groupId,
|
||||
label,
|
||||
itemSortOrder,
|
||||
item.defaultLevel || 'none',
|
||||
availableLevels,
|
||||
isGlobal,
|
||||
isRequired,
|
||||
existingItem.id
|
||||
);
|
||||
itemKeyToId.set(item.key, existingItem.id);
|
||||
if (hasChanges) {
|
||||
console.log(`ℹ️ Berechtigung ${item.key} aktualisiert`);
|
||||
}
|
||||
}
|
||||
|
||||
itemKeys.add(item.key);
|
||||
dependencyQueue.push({ itemKey: item.key, dependencies: item.dependencies || [] });
|
||||
};
|
||||
|
||||
const processGroups = (groups, sectionId, parentGroupId = null) => {
|
||||
const list = Array.isArray(groups) ? groups : [];
|
||||
list.forEach((group, groupIdx) => {
|
||||
const sortOrder = typeof group.sortOrder === 'number' ? group.sortOrder : groupIdx;
|
||||
const existingGroup = selectGroup.get(sectionId, group.key);
|
||||
let groupId;
|
||||
if (!existingGroup) {
|
||||
const result = insertGroup.run(sectionId, parentGroupId, group.key, group.title, sortOrder);
|
||||
groupId = Number(result.lastInsertRowid);
|
||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${sectionId} angelegt`);
|
||||
} else {
|
||||
const hasGroupChanges =
|
||||
existingGroup.title !== group.title ||
|
||||
(existingGroup.sort_order ?? sortOrder) !== sortOrder ||
|
||||
existingGroup.parent_group_id !== parentGroupId;
|
||||
updateGroup.run(group.title, sortOrder, parentGroupId, existingGroup.id);
|
||||
groupId = existingGroup.id;
|
||||
if (hasGroupChanges) {
|
||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${sectionId} aktualisiert`);
|
||||
}
|
||||
}
|
||||
|
||||
groupKeys.add(group.key);
|
||||
|
||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||
groupItems.forEach((item, itemIdx) => {
|
||||
upsertItem({ item, sectionId, groupId, sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx });
|
||||
});
|
||||
|
||||
if (group.groups) {
|
||||
processGroups(group.groups, sectionId, groupId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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) => {
|
||||
upsertItem({ item, sectionId, groupId: null, sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx });
|
||||
});
|
||||
|
||||
processGroups(section.groups, sectionId, null);
|
||||
});
|
||||
|
||||
dependencyQueue.forEach(({ itemKey, dependencies }) => {
|
||||
const permissionId = itemKeyToId.get(itemKey);
|
||||
if (!permissionId) return;
|
||||
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 ${itemKey} -> ${dependency.dependsOnKey} angelegt`);
|
||||
} else if (existing.required_level !== requiredLevel) {
|
||||
updateDependency.run(requiredLevel, existing.id);
|
||||
console.log(`ℹ️ Abhängigkeit ${itemKey} -> ${dependency.dependsOnKey} aktualisiert`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup obsolete entries
|
||||
if (itemKeys.size > 0) {
|
||||
const itemKeyJson = JSON.stringify(Array.from(itemKeys));
|
||||
deleteOrphanDependencies.run();
|
||||
deleteOrphanPermissionValues.run();
|
||||
deleteUnusedItems.run(itemKeyJson);
|
||||
}
|
||||
if (groupKeys.size > 0) {
|
||||
const groupKeyJson = JSON.stringify(Array.from(groupKeys));
|
||||
deleteUnusedGroups.run(groupKeyJson);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
const getSettingStmt = db.prepare('SELECT key, value, updated_at FROM settings WHERE key = ?');
|
||||
const setSettingStmt = db.prepare(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (@key, @value, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`);
|
||||
const deleteSettingStmt = db.prepare('DELETE FROM settings WHERE key = ?');
|
||||
|
||||
export function getSetting(key) {
|
||||
try {
|
||||
return getSettingStmt.get(key) || null;
|
||||
} catch (err) {
|
||||
console.error(`❌ [Settings] Fehler beim Lesen des Settings "${key}":`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSetting(key, value) {
|
||||
try {
|
||||
setSettingStmt.run({ key, value });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`❌ [Settings] Fehler beim Speichern des Settings "${key}":`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSetting(key) {
|
||||
try {
|
||||
deleteSettingStmt.run(key);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`❌ [Settings] Fehler beim Löschen des Settings "${key}":`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getJsonSetting(key, defaultValue = null) {
|
||||
const row = getSetting(key);
|
||||
if (!row || row.value === null || row.value === undefined) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(row.value);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ [Settings] Konnte JSON für Setting "${key}" nicht parsen:`, err.message);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function setJsonSetting(key, value) {
|
||||
try {
|
||||
const payload = value === undefined ? null : JSON.stringify(value);
|
||||
return setSetting(key, payload);
|
||||
} catch (err) {
|
||||
console.error(`❌ [Settings] Fehler beim Serialisieren von Setting "${key}":`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
|
||||
const MAINTENANCE_KEY = 'maintenance_mode';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
active: false,
|
||||
message: null,
|
||||
activatedAt: null,
|
||||
updatedAt: null,
|
||||
extra: null
|
||||
};
|
||||
|
||||
let maintenanceState = loadState();
|
||||
|
||||
function loadState() {
|
||||
const saved = getJsonSetting(MAINTENANCE_KEY, null);
|
||||
if (!saved) {
|
||||
const initial = { ...DEFAULT_STATE, updatedAt: new Date().toISOString() };
|
||||
setJsonSetting(MAINTENANCE_KEY, initial);
|
||||
return initial;
|
||||
}
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...saved
|
||||
};
|
||||
}
|
||||
|
||||
function persistState(state) {
|
||||
maintenanceState = {
|
||||
...DEFAULT_STATE,
|
||||
...state,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
setJsonSetting(MAINTENANCE_KEY, maintenanceState);
|
||||
return maintenanceState;
|
||||
}
|
||||
|
||||
export function getMaintenanceState() {
|
||||
return { ...maintenanceState };
|
||||
}
|
||||
|
||||
export function isMaintenanceModeActive() {
|
||||
return Boolean(maintenanceState?.active);
|
||||
}
|
||||
|
||||
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,
|
||||
message,
|
||||
extra,
|
||||
activatedAt: now
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
extra: null,
|
||||
activatedAt: null
|
||||
});
|
||||
}
|
||||
@@ -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,456 @@
|
||||
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, parent_group_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_global_scope,
|
||||
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, is_global_scope
|
||||
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;
|
||||
let cachedServerScopedKeys = null;
|
||||
let cachedScopeByKey = null;
|
||||
|
||||
const getPermissionItems = () => {
|
||||
if (!cachedPermissionItems) {
|
||||
cachedPermissionItems = selectPermissionItemsForMap.all();
|
||||
}
|
||||
return cachedPermissionItems;
|
||||
};
|
||||
|
||||
const resetPermissionItemsCache = () => {
|
||||
cachedPermissionItems = null;
|
||||
cachedServerScopedKeys = null;
|
||||
cachedScopeByKey = null;
|
||||
};
|
||||
|
||||
const getScopeCache = () => {
|
||||
if (!cachedScopeByKey || !cachedServerScopedKeys) {
|
||||
const items = getPermissionItems();
|
||||
cachedScopeByKey = new Map();
|
||||
cachedServerScopedKeys = new Set();
|
||||
items.forEach((item) => {
|
||||
const isGlobal = item.is_global_scope !== 0;
|
||||
cachedScopeByKey.set(item.key, { isGlobal });
|
||||
if (!isGlobal) {
|
||||
cachedServerScopedKeys.add(item.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
return { scopeByKey: cachedScopeByKey, serverScopedKeys: cachedServerScopedKeys };
|
||||
};
|
||||
|
||||
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();
|
||||
const groupsByParentId = new Map();
|
||||
const sortedGroups = [...groups].sort((a, b) => {
|
||||
const sortDiff = (a.sort_order ?? 0) - (b.sort_order ?? 0);
|
||||
if (sortDiff !== 0) return sortDiff;
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
sortedGroups.forEach((group) => {
|
||||
if (group.parent_group_id) {
|
||||
const list = groupsByParentId.get(group.parent_group_id) || [];
|
||||
list.push(group);
|
||||
groupsByParentId.set(group.parent_group_id, list);
|
||||
} else {
|
||||
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),
|
||||
isGlobal: item.is_global_scope !== 0,
|
||||
dependencies: formattedDependencies
|
||||
};
|
||||
};
|
||||
|
||||
const buildGroupTree = (group) => {
|
||||
const children = groupsByParentId.get(group.id) || [];
|
||||
return {
|
||||
key: group.key,
|
||||
title: group.title,
|
||||
sortOrder: group.sort_order ?? 0,
|
||||
items: (itemsByGroupId.get(group.id) || []).map(formatItem),
|
||||
groups: children.map(buildGroupTree)
|
||||
};
|
||||
};
|
||||
|
||||
const formattedSections = sections.map((section) => {
|
||||
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
|
||||
const rootGroups = groupsBySectionId.get(section.id) || [];
|
||||
const sectionGroups = rootGroups.map(buildGroupTree);
|
||||
|
||||
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 serverEditLevel = getSubmittedLevel('maintenance-server-edit');
|
||||
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
|
||||
|
||||
// Enforce dependency: "server löschen" darf maximal so hoch sein wie manage/edit.
|
||||
const deletePriority = getLevelPriority(serverDeleteLevel);
|
||||
const allowedDeletePriority = Math.min(
|
||||
getLevelPriority(serverManageLevel),
|
||||
getLevelPriority(serverEditLevel)
|
||||
);
|
||||
if (deletePriority > allowedDeletePriority) {
|
||||
const allowedLevelEntry = Object.entries(LEVEL_PRIORITY).find(([, priority]) => priority === allowedDeletePriority);
|
||||
const allowedLevel = allowedLevelEntry ? allowedLevelEntry[0] : 'none';
|
||||
const deleteEntry = normalizedEntries.find((entry) => entry.item.key === 'maintenance-server-delete');
|
||||
if (deleteEntry) {
|
||||
deleteEntry.level = allowedLevel;
|
||||
} else {
|
||||
normalizedEntries.push({
|
||||
item: itemMap.get('maintenance-server-delete'),
|
||||
level: allowedLevel
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 getServerScopedPermissionKeys() {
|
||||
const { serverScopedKeys } = getScopeCache();
|
||||
return Array.from(serverScopedKeys);
|
||||
}
|
||||
|
||||
export function isServerScopedPermission(permissionKey) {
|
||||
if (!permissionKey) return false;
|
||||
const { serverScopedKeys, scopeByKey } = getScopeCache();
|
||||
if (serverScopedKeys.has(permissionKey)) {
|
||||
return true;
|
||||
}
|
||||
const scope = scopeByKey.get(permissionKey);
|
||||
return scope ? scope.isGlobal === false : false;
|
||||
}
|
||||
|
||||
export function applyServerScopedOverride(basePermissions = {}, overridePermissions = {}) {
|
||||
const merged = { ...basePermissions };
|
||||
const { serverScopedKeys } = getScopeCache();
|
||||
serverScopedKeys.forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(overridePermissions, key)) {
|
||||
merged[key] = overridePermissions[key];
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
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-c3010de9.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-c7ee792b.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
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');
|
||||
|
||||
function listServers() {
|
||||
return selectAllServers.all();
|
||||
}
|
||||
|
||||
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 getServerById(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;
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function updateServerDetails(serverId, { name, url }) {
|
||||
const existing = getServerById(serverId);
|
||||
const normalizedUrl = normalizeUrl(url || existing.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 other = selectServerByUrl.get(normalizedUrl);
|
||||
if (other && other.id !== existing.id) {
|
||||
const error = new Error('SERVER_URL_TAKEN');
|
||||
error.code = 'SERVER_URL_TAKEN';
|
||||
throw error;
|
||||
}
|
||||
|
||||
updateServer.run(normalizedName, normalizedUrl, existing.id);
|
||||
const updated = selectServerById.get(existing.id);
|
||||
console.log(`ℹ️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
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 getServerConnection(serverId) {
|
||||
const server = getServerById(serverId);
|
||||
const apiKeyRow = selectApiKeyByServerId.get(server.id);
|
||||
const apiKey = decryptApiKey(apiKeyRow);
|
||||
return { server, apiKey };
|
||||
}
|
||||
|
||||
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 getServerStatus(serverId) {
|
||||
const server = getServerById(serverId);
|
||||
const apiKeyMeta = selectApiKeyByServerId.get(server.id) || null;
|
||||
return {
|
||||
server,
|
||||
apiKey: {
|
||||
hasKey: Boolean(apiKeyMeta),
|
||||
updatedAt: apiKeyMeta?.updated_at ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
getServerById,
|
||||
listServers,
|
||||
setServerApiKey,
|
||||
getServerConnection,
|
||||
updateServerDetails,
|
||||
getServerStatus,
|
||||
getActiveApiKey,
|
||||
getActiveServerUrl,
|
||||
hasServer,
|
||||
hasApiKey,
|
||||
hasCompleteSetup,
|
||||
getSetupStatus,
|
||||
completeSetup,
|
||||
removeServer
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Architektur
|
||||
|
||||
StackPulse folgt einer klassischen Client/Server-Architektur, bei der das Backend den Single-Source-of-Truth darstellt und das Frontend ausschließlich über wohldefinierte REST- und Socket.IO-Schnittstellen kommuniziert.
|
||||
|
||||
```text
|
||||
┌────────────┐ HTTPS + WebSocket ┌───────────────┐ HTTPS ┌────────────────┐
|
||||
│ Frontend │ ─────────────────────────▶ │ Backend │ ───────────────▶ │ Portainer API │
|
||||
│ (React) │ ◀───── Auth-Cookie + JSON ─ │ (Express) │ ◀─────────────── │ Business Ed. │
|
||||
└────────────┘ Redeploy Events └───────────────┘ (Axios Proxy) └────────────────┘
|
||||
│
|
||||
mTLS (optional)│
|
||||
▼
|
||||
StackPulse Agent
|
||||
│
|
||||
▼
|
||||
SQLite Datenbank
|
||||
```
|
||||
|
||||
## Hauptschichten
|
||||
- **Frontend** (Vite + Material Tailwind): stellt Views, Tabellen, Setup-Wizard und Echtzeit-Widgets bereit. `AuthProvider` verwaltet Session, Permissions und Socket-Verbindungen.
|
||||
- **Backend** (Express + better-sqlite3): aggregiert Daten aus Portainer, persistiert Benutzer/Gruppen/Logs, verwaltet Sessions, Redeploy-Queue und Maintenance/Agent-Funktionen.
|
||||
- **Agent (optional)**: kleiner Node-Dienst, der lokal auf dem Portainer-Host läuft. Liefert Stack-/Portainer-Metadaten per REST, authentifiziert via `X-Agent-Token` und kann mTLS-Zertifikate aus dem Backend beziehen.
|
||||
- **Persistenz** (SQLite): Datei `backend/data/stackpulse.db`, WAL-Modus aktiviert. Tabellen umfassen u. a. `users`, `groups`, `permissions`, `servers`, `server_api_keys`, `event_logs`, `settings` sowie Hilfstabellen für Tokens und Sicherheitspassphrasen.
|
||||
|
||||
## Datenflüsse
|
||||
1. **Setup-Phase**: `/api/setup/*` nimmt Serverdaten entgegen, verschlüsselt API-Keys (`aes-256-gcm` via `PORTAINER_API_SECRET`) und erstellt Einträge in `servers`/`server_api_keys`.
|
||||
2. **Authentication**: `/api/auth/login` erstellt Sessions (JWT-ähnliche Tokens in Cookies), `AuthProvider` ruft `/api/auth/session` zyklisch ab und speichert Berechtigungen.
|
||||
3. **Stack-Übersicht**: Frontend ruft `/api/stacks` auf. Das Backend proxyt `Portainer /api/endpoints/:id/docker/stacks`, ergänzt Caching, interne Metadaten und liefert Redeploy-Status.
|
||||
4. **Redeploy**: UI stößt `/api/stacks/:id/redeploy`, `/api/stacks/redeploy-selection` oder `/api/stacks/redeploy-all` an. Das Backend ruft Portainer-Endpoints, protokolliert Events und broadcastet Fortschritt via Socket.IO `redeployStatus`.
|
||||
5. **Logging**: Jeder relevante Vorgang erzeugt Einträge in `event_logs` (siehe `logging/eventLogs.js`). Frontend filtert/paginiert über `/api/logs` und kann Exporte `/api/logs/export` herunterladen.
|
||||
6. **Wartung**: `/api/maintenance/*` verwaltet Maintenance-Mode, SSH-Zugänge, Update-Skripte sowie weitere Wartungsfunktionen.
|
||||
|
||||
## Verzeichnis-Referenz
|
||||
| Pfad | Inhalt |
|
||||
|------|--------|
|
||||
| `backend/index.js` | Herzstück der API inkl. Routing, Socket.IO, Redeploy, Setup, Auth. |
|
||||
| `backend/auth/` | Superuser-Verwaltung, Passwort- und Tokenfunktionen, Sicherheitsphrasen. |
|
||||
| `backend/db/` | SQLite-Initialisierung (`ensure.js`/`schemaEnsure.js`), Settings-Layer, DB-Blueprints. |
|
||||
| `backend/logging/` | Event-Log Modellierung, Filter/Export/Delete. |
|
||||
| `backend/groups/`, `backend/users/`, `backend/permissions/` | Domain-Logik für Benutzer- & Rechteverwaltung. |
|
||||
| `backend/maintenance/` | Maintenance-Mode sowie SSH-/Update-Konfiguration. |
|
||||
| `frontend/src/` | React-App (Layouts, Pages, Widgets, Provider). `routes.jsx` definiert Navigation & Permissions. |
|
||||
| `scripts/` | Hilfsskripte (z. B. `start-dev.sh`). |
|
||||
| `Dockerfile`, `docker-compose*.yml` | Build- & Deployment-Spezifikation. |
|
||||
|
||||
## Konfiguration & Secrets
|
||||
- API-Keys und Passwörter werden verschlüsselt in SQLite abgelegt (`server_api_keys`, `settings`).
|
||||
- Settings (Maintenance, Update-Skripte, SSH) nutzen `db/settings.js` mit JSON-Serialisierung.
|
||||
- Das Backend erzwingt HTTPS-Requests Richtung Portainer (`https.Agent` mit deaktivierter Zertifikatsprüfung für interne Netze).
|
||||
- Socket.IO lauscht auf demselben Port wie Express (`/socket.io`). CORS ist aktuell für alle Origins offen – für produktive Deployments empfiehlt sich ein Reverse Proxy mit Authentifizierung.
|
||||
|
||||
## Skalierung & Grenzen
|
||||
- SQLite eignet sich für Einzelinstanzen. Für höhere Last empfiehlt sich eine Umstellung auf Postgres/MySQL (der Code kapselt Zugriffe weitgehend in `db/*`).
|
||||
- Redeploy-Operationen werden seriell behandelt (`redeployingStacks` + Status-Caching). Parallele Ausführung ist möglich, solange Portainer dies unterstützt.
|
||||
- Logging wird ausschließlich serverseitig betrieben; Client-seitige Filterung erfolgt über Query-Parameter.
|
||||
|
||||
Die weiteren Kapitel vertiefen Backend- und Frontend-spezifische Details.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Backend & API
|
||||
|
||||
Das Backend implementiert alle Anwendungsfälle über Express-Routen, Socket.IO für Echtzeit-Events und eine SQLite-Datenbank als Persistenzschicht. Dieser Abschnitt fasst die wichtigsten Module, Entitäten und Endpunkte zusammen.
|
||||
|
||||
## Laufzeit & Einstiegspunkte
|
||||
- `backend/index.js` initialisiert Express, Socket.IO, lädt `.env`, sorgt für Schema-Validierung (`ensureDatabaseSchema`) und Default-Daten (`ensureSuperuserFromEnv`, `ensureDefaultsFromEnv`).
|
||||
- Start via `npm start` (siehe `package.json`); Migrationen über `npm run migrate`.
|
||||
- Datenbankdatei: `backend/data/stackpulse.db`, WAL-Modus aktiv.
|
||||
|
||||
## Module
|
||||
| Modul | Pfad | Aufgabe |
|
||||
|-------|------|---------|
|
||||
| Auth/Superuser | `auth/superuser.js` | Registrieren/Verifizieren von Superusern, Login, Passwortvalidierung, Sicherheitsphrasen. |
|
||||
| Users & Groups | `users/*`, `groups/*`, `permissions/*` | CRUD für Benutzer, Gruppen, Rechtezuweisung, Ableitung effektiver Berechtigungen. Rechte-Labels z. B. `Bereich & Navigation`, `Benutzergruppen bearbeiten`, `Benutzer löschen`. |
|
||||
| Setup | `setup/index.js` | Verwaltung registrierter Portainer-Server, sichere Ablage der API-Keys, Setup-Status. |
|
||||
| Logging | `logging/eventLogs.js` | Einheitliches Ereignislogging (Insert, Filter, Delete, Export). |
|
||||
| Maintenance | `maintenance/state.js` + Helper in `index.js` | Wartungsmodus, SSH-Konfiguration, Update-Skripte und Ausführung. |
|
||||
| Portainer Proxy | Funktionen in `index.js` | Kommunikation mit Portainer (`axiosInstance` + dynamische `baseURL`), Environment-Erkennung und Redeploy-Strecke. |
|
||||
|
||||
## API-Überblick (Auszug)
|
||||
### Setup & Server
|
||||
| Methode | Route | Beschreibung |
|
||||
|---------|-------|--------------|
|
||||
| GET | `/api/setup/status` | Gibt zurück, ob Server/API-Key/Superuser vorhanden sind. |
|
||||
| POST | `/api/setup/test-portainer` | Verifiziert URL + API-Key gegen Portainer. |
|
||||
| POST | `/api/setup/portainer-stacks` | Listet Stacks (für Preview im Wizard). |
|
||||
| POST | `/api/setup/complete` | Markiert Setup als abgeschlossen und hinterlegt Server/API-Key. |
|
||||
| DELETE | `/api/setup/servers/:id` | Entfernt einen registrierten Portainer-Server. |
|
||||
| PUT | `/api/setup/servers/:id/api-key` | Überschreibt API-Key. |
|
||||
| PUT | `/api/setup/self-stack` | Speichert die ID des StackPulse-Stacks, damit dieser nicht redeployed wird.
|
||||
|
||||
### Authentifizierung
|
||||
| Methode | Route | Beschreibung |
|
||||
|---------|-------|--------------|
|
||||
| POST | `/api/auth/login` | Username/Passwort-Login, setzt HTTP-only Cookie. |
|
||||
| POST | `/api/auth/logout` | Session beenden & Cookie löschen. |
|
||||
| GET | `/api/auth/session` | Liefert eingeloggten Benutzer + Permissions. |
|
||||
| POST | `/api/auth/recover/verify` & `/reset` | Passwortreset über Sicherheitsphrase. |
|
||||
| GET/POST/DELETE | `/api/auth/superuser/*` | Abfrage, Registrierung oder Entfernen des Superusers.
|
||||
|
||||
### Benutzer & Gruppen
|
||||
| Methode | Route | Beschreibung |
|
||||
|---------|-------|--------------|
|
||||
| GET/POST | `/api/users` | Liste anlegen, neue Benutzer erstellen. |
|
||||
| GET/PUT/DELETE | `/api/users/:id` | Details lesen, bearbeiten oder löschen. |
|
||||
| PUT | `/api/users/:id/groups` | Gruppenmitgliedschaften aktualisieren. |
|
||||
| PUT | `/api/users/:id/active` | Benutzer aktivieren/deaktivieren. |
|
||||
| GET/POST | `/api/users/:id/security-phrase`, `/renew` | Sicherheitsphrase anzeigen bzw. neu generieren. |
|
||||
| GET/POST | `/api/groups` | Gruppen auflisten oder neu anlegen. |
|
||||
| PUT/DELETE | `/api/groups/:id` | Gruppe editieren oder entfernen. |
|
||||
| GET/PUT | `/api/groups/:id/permissions` | Berechtigungen lesen/setzen.
|
||||
|
||||
### Stacks & Redeploy
|
||||
| Methode | Route | Beschreibung |
|
||||
|---------|-------|--------------|
|
||||
| GET | `/api/stacks` | Aggregierte Liste inkl. Filter, Self-Stack-Blockade und Redeploy-Status. |
|
||||
| PUT | `/api/stacks/:id/redeploy` | Redeploy eines einzelnen Stacks. |
|
||||
| PUT | `/api/stacks/redeploy-selection` | Redeploy einer Liste übermittelter `stackIds`. |
|
||||
| PUT | `/api/stacks/redeploy-all` | Redeploy aller Stacks (Recht `Redeploy Alle`). |
|
||||
|
||||
Das Backend veröffentlicht den Redeploy-Fortschritt parallel über Socket.IO (`redeployStatus` Event) und unterscheidet die Phasen `queued`, `started`, `success`, `error`, `info`.
|
||||
|
||||
### Logging & Wartung
|
||||
| Methode | Route | Beschreibung |
|
||||
|---------|-------|--------------|
|
||||
| GET | `/api/logs` | Filterbare/Paginierbare Event-Logs. |
|
||||
| DELETE | `/api/logs/:id` oder `/api/logs` | Einzelne oder gefilterte Logs löschen. |
|
||||
| GET | `/api/logs/export` | CSV-Export anhand der Query-Filter. |
|
||||
| GET/POST | `/api/maintenance/mode` | Status abfragen oder toggeln. |
|
||||
| GET | `/api/maintenance/config` | Liefert Maintenance-Status, Update-Skript & SSH-Config. |
|
||||
| PUT/DELETE | `/api/maintenance/ssh-config` | SSH-Credentials speichern/entfernen. |
|
||||
| POST | `/api/maintenance/test-ssh` | Verbindungstest mit optionalem Override. |
|
||||
| PUT/DELETE | `/api/maintenance/update-script` | Custom Update-Skript pflegen. |
|
||||
| POST | `/api/maintenance/portainer-update` | Update-Kommando per SSH ausführen (mit Custom/Default-Skript). |
|
||||
|
||||
## Datenbank & Schema
|
||||
- `ensureDatabaseSchema()` erzeugt Tabellen bei jedem Start, inklusive Default-Gruppen, Permissions, Settings, Server-Einträge und Trigger.
|
||||
- Permissions werden anhand der Blueprint-Datei `backend/db/dbs` eingelesen. Jede Permission definiert Key, Label (z. B. `Bereich & Navigation`, `Server bearbeiten`, `SSH/Update-Skript`, `Update durchführen`, `Doppelte Stacks`), Level (`full`, `read`, `none`) und Abhängigkeiten.
|
||||
- Events werden in `event_logs` gespeichert. Relevante Felder:
|
||||
- `category`, `event_type`, `action`, `status`, `severity`
|
||||
- `entity_type`, `entity_id`, `entity_name`
|
||||
- `actor_type`, `actor_id`, `actor_name`
|
||||
- `context_type`, `context_id`, `context_label`
|
||||
- `message`, `metadata (JSON)`
|
||||
- Filter-Parameter (`ids`, `categories`, `eventTypes`, `status`, `stackIds`, Zeitfenster) werden in `buildEventLogFilter` zentral verarbeitet.
|
||||
|
||||
## Berechtigungssystem
|
||||
- Jeder Benutzer gehört zu beliebig vielen Gruppen; die effektiven Rechte ergeben sich aus der höchsten Ausprägung pro Permission (Labels wie `Bereich & Navigation`, `Server bearbeiten`, `SSH/Update-Skript`, `Update durchführen`, `Doppelte Stacks`).
|
||||
- Superuser erhalten alle Berechtigungen und können nicht über das UI entzogen werden.
|
||||
- `PermissionGate` im Frontend spiegelt die gleichen Keys. Wichtigste Bereiche:
|
||||
- `Redeploy einzeln/Auswahl/Alle`
|
||||
- `Bereich & Navigation (Logs)`, `Logs Exportieren`, `Logs löschen`
|
||||
- `Bereich & Navigation (Benutzer)`, `Benutzer bearbeiten/löschen`, `Sicherheitsschlüssel`
|
||||
- `Bereich & Navigation (Benutzergruppen)`, `Benutzergruppen bearbeiten/löschen`
|
||||
- Wartung: `Bereich & Navigation (Wartung)`, `Server-Sektion`, `Server bearbeiten/löschen`, `SSH/Update-Skript`, `Update durchführen`, `Doppelte Stacks`
|
||||
|
||||
## Redeploy-Workflow
|
||||
1. Eingehender Request wird mit `maintenanceGuard` geprüft (Maintenance-Modus blockiert optional Redeploys).
|
||||
2. Das Backend ermittelt den zugehörigen Portainer-Endpoint (`resolvePortainerEnvironmentId`).
|
||||
3. Der Redeploy wird über Portainer ausgeführt (`/api/stacks/:id/redeploy`), Log-Einträge werden erstellt.
|
||||
4. `broadcastRedeployStatus()` aktualisiert das In-Memory-Tracking (`redeployingStacks`) und sendet Socket.IO Events.
|
||||
5. Fehler führen zu `event_logs`-Einträgen mit `severity=error`, UI zeigt Toasts via `ToastProvider`.
|
||||
|
||||
## Wartungs- und Updatepfad
|
||||
- `maintenance/state.js` persistiert Aktivierung & Nachricht im Setting `maintenance_mode`.
|
||||
- SSH-Konfigurationen (Host, Port, User, Passwort, extra args) werden verschlüsselt gespeichert (`getPortainerSshConfig`).
|
||||
- Update-Skripte lassen sich überschreiben, Standard-Skript liegt in `DEFAULT_PORTAINER_UPDATE_SCRIPT` (Bash-Befehle).
|
||||
- Update-Status hält Laufzeiten, letzte Ergebnisse und Logauszüge in-memory & Settings bereit.
|
||||
|
||||
## Erweiterungstipps
|
||||
- Neue API-Routen sollten `requirePermission(..)` nutzen, Logging via `logEvent()` hinzufügen und – falls Portainer-Aufrufe stattfinden – das `axiosInstance` verwenden.
|
||||
- Einstellungen stets über `db/settings.js` verwalten, damit UI & CLI konsistent bleiben.
|
||||
- Für breaking DB-Änderungen existiert kein Migrations-Framework – erweitere `ensureDatabaseSchema` inkl. Versionsflag, um Idempotenz sicherzustellen.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Umgebungsvariablen
|
||||
|
||||
Diese Seite sammelt alle relevanten Umgebungsvariablen für StackPulse (Backend) und den optionalen StackPulse Agent. Werte ohne Default sind in der Regel optional, sofern sie nicht explizit als „erforderlich“ markiert sind.
|
||||
|
||||
## StackPulse Backend
|
||||
| Variable | Default | Erforderlich | Beschreibung |
|
||||
|----------|---------|--------------|--------------|
|
||||
| `PORTAINER_URL` | – | Nein | Basis-URL zu Portainer. Kann im Setup-UI hinterlegt werden; ohne Wert schlägt Portainer-Zugriff fehl, bis ein Server gespeichert ist. |
|
||||
| `PORTAINER_API_KEY` | – | Nein | API-Key für Portainer. Kann im Setup-UI gespeichert (verschlüsselt) werden; ohne Wert keine API-Aufrufe. |
|
||||
| `PORTAINER_SERVER_NAME` | – | Nein | Anzeigename für den initialen Servereintrag (nur kosmetisch). |
|
||||
| `PORTAINER_API_SECRET` | `stackpulse-portainer-api-key` | Nein | AES-Schlüssel für gespeicherte API-Keys. Stabil halten, sonst müssen Keys neu eingetragen werden. |
|
||||
| `SUPERUSER_USERNAME` | – | Nein | Legt beim Start einen Superuser an, falls noch keiner existiert; alternativ im Setup-UI anlegen. |
|
||||
| `SUPERUSER_EMAIL` | – | Nein | E-Mail des initialen Superusers. |
|
||||
| `SUPERUSER_PASSWORD` | – | Nein | Passwort des initialen Superusers. |
|
||||
| `SELF_STACK_ID` | – | Nein | ID des StackPulse-Stacks; verhindert Self-Redeploy im UI. |
|
||||
| `AUTH_COOKIE_NAME` | `sp_auth_token` | Nein | Name des Auth-Cookies. |
|
||||
| `AUTH_SESSION_TTL_MS` | `43200000` (12h) | Nein | Session-Lebensdauer in Millisekunden. |
|
||||
| `AUTH_COOKIE_SECURE` | `false` | Nein | Setzt Cookie nur über HTTPS, wenn `true`. |
|
||||
| `SECURITY_PHRASE_SECRET` | `stackpulse-security-phrase-secret` | Nein | Secret für verschlüsselte Sicherheitsphrasen; stabil halten, sonst Phrasen neu verteilen. |
|
||||
| `PORTAINER_SSH_SECRET` | – (Fallback API-Key/Default) | Nein | Secret für verschlüsselte SSH-Passwörter; stabil halten, sonst Passwort neu setzen. |
|
||||
| `AGENT_MTLS_KEY` | generiert | Nein | Optional: eigenes PEM-Key für internen mTLS-Server, falls nicht automatisch erzeugt. |
|
||||
| `AGENT_MTLS_CERT` | generiert | Nein | Optional: eigenes PEM-Zertifikat für internen mTLS-Server. |
|
||||
| `AGENT_MTLS_PORT` | `4441` | Nein | Port des internen mTLS-Endpunkts (Agent). |
|
||||
| `DISPLAY` | `:9999` | Nein | Wird an Child-Prozesse (Update-Skript) weitergereicht. |
|
||||
|
||||
## StackPulse Agent
|
||||
| Variable | Default | Erforderlich | Beschreibung |
|
||||
|----------|---------|--------------|--------------|
|
||||
| `AGENT_PORT` | `7070` | Nein | Listenport des Agents (HTTPS mit mTLS). |
|
||||
| `AGENT_TOKEN` | – | Ja | Shared Secret für Auth (`X-Agent-Token`) bei allen Agent-Endpoints. |
|
||||
| `DOCKER_SOCKET` | `/var/run/docker.sock` | Nein | Pfad zum Docker-Socket; nötig für Stack-/Image-Infos. |
|
||||
| `CONTROL_PLANE_URL` | – | Ja (für Bootstrap/Restore) | Basis-URL des StackPulse-Backends für Zertifikatsabruf ohne mTLS (z. B. `https://stackpulse:4001`). |
|
||||
| `CONTROL_PLANE_MTLS_URL` | – | Nein (empfohlen) | Bevorzugte mTLS-URL des Backends (z. B. `https://stackpulse:4441`). Wird für alle Agent→Backend-Calls genutzt, sobald Zertifikate vorliegen; fehlt sie, wird aus `CONTROL_PLANE_URL` + `AGENT_MTLS_PORT` abgeleitet. |
|
||||
| `AGENT_BOOTSTRAP_TOKEN` | – | Nein (für Erstbootstrap) | Einmal-Token aus dem Wartungsbereich, um mTLS-Zertifikate zu ziehen. Pflicht, wenn keine Zertifikate vorliegen und das Backend noch kein Material für den Agent gespeichert hat. |
|
||||
| `AGENT_MTLS_PORT` | `4441` | Nein | Fallback-Port, falls `CONTROL_PLANE_MTLS_URL` keinen Port enthält. |
|
||||
| `AGENT_SERVER_KEY` / `AGENT_SERVER_CERT` | – | Nein | PEM-Material für den Agent-Server; nur nötig bei eigenen Zertifikaten statt Backend-CA. |
|
||||
| `AGENT_CLIENT_KEY` / `AGENT_CLIENT_CERT` / `AGENT_CA_CERT` | – | Nein | PEM-Material für Client/CA; nur bei eigener PKI nötig. |
|
||||
| `REGISTRY_TOKEN` | – | Nein | Bearer-Token für Registry-Abfragen (Digest-Vergleich). |
|
||||
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | – | Nein | Basic-Auth-Creds für Registries. |
|
||||
| `GITHUB_USERNAME` / `GITHUB_TOKEN` / `GITHUB_PAT` | – | Nein | Alternative Credential-Quellen für GitHub Packages. |
|
||||
|
||||
|
||||
Agent-Betrieb (wähle eine Variante):
|
||||
|
||||
- **Dynamisch über Backend (empfohlen)**
|
||||
- Pflicht: `AGENT_TOKEN` + `CONTROL_PLANE_URL` (optional `CONTROL_PLANE_MTLS_URL`).
|
||||
- Falls keine Zertifikate existieren: einmaliges `AGENT_BOOTSTRAP_TOKEN` setzen.
|
||||
- Der Agent zieht Zertifikate vom Backend, schaltet auf mTLS um und braucht danach kein Bootstrap-Token mehr.
|
||||
- **Statisch per ENV**
|
||||
- Liefere PEMs: `AGENT_SERVER_KEY`/`AGENT_SERVER_CERT` + `AGENT_CLIENT_KEY`/`AGENT_CLIENT_CERT`/`AGENT_CA_CERT`.
|
||||
- Dann kein `CONTROL_PLANE_URL`/Bootstrap nötig, da mTLS-Material bereits vorhanden ist.
|
||||
- `AGENT_TOKEN` bleibt erforderlich.
|
||||
|
||||
Hinweise:
|
||||
|
||||
- Server-URL und API-Key kannst du im Setup-UI pflegen; Umgebungsvariablen setzen nur den Initialzustand.
|
||||
- Secrets (`SECURITY_PHRASE_SECRET`, `PORTAINER_SSH_SECRET`, `PORTAINER_API_SECRET`) in Produktion einmalig setzen und stabil halten – sonst werden bestehende verschlüsselte Werte unlesbar.
|
||||
|
||||
- `CONTROL_PLANE_MTLS_URL`: bevorzugte mTLS-Zieladresse. Fehlt sie, leitet der Agent sie aus `CONTROL_PLANE_URL` + `AGENT_MTLS_PORT` ab und nutzt sie für alle Aufrufe, sobald Zertifikate vorliegen.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Frontend
|
||||
|
||||
Das Frontend basiert auf React 18, Vite und dem Material Tailwind Dashboard. Es nutzt Context Provider für Authentifizierung, Seitenstatus, Toaster und Wartungsinformationen.
|
||||
|
||||
## Build & Skripte
|
||||
| Befehl | Beschreibung |
|
||||
|--------|--------------|
|
||||
| `npm run dev` | Startet den Vite Dev-Server (Port 5173, HMR aktiviert). |
|
||||
| `npm run build` | Erstellt ein Production-Bundle (`frontend/dist`). Das Dockerfile kopiert dieses Paket ins Backend (`backend/public`). |
|
||||
| `npm run preview` | Vorschau auf das gebaute Bundle.
|
||||
|
||||
## Projektstruktur
|
||||
```
|
||||
frontend/src
|
||||
├── App.jsx # Entry-Point, registriert Layouts & Provider
|
||||
├── components/ # AuthProvider, PermissionGate, Toast/Maintenance/PageProvider
|
||||
├── layouts/ # Dashboard-Layout (Sidebar, Navbar) & Auth-Layout
|
||||
├── pages/
|
||||
│ ├── auth/ # SignIn, SignUp, ForgotPassword, SignOut
|
||||
│ ├── dashboard/ # Stacks, Logs, Maintenance, Users, Usergroups, Detailseiten
|
||||
│ └── setup/ # Setup-Wizard & Helper Screens
|
||||
├── routes.jsx # zentrale Navigationsdefinition inkl. Permissions
|
||||
├── widgets/, data/, configs/, assets/
|
||||
└── tailwind.css # Tailwind Layer & Custom Styles
|
||||
```
|
||||
|
||||
## Routing & Navigation
|
||||
- `routes.jsx` beschreibt sowohl Dashboard- als auch Auth-Routen. Jede Dashboard-Seite besitzt optional ein `permission`-Objekt, das vom `PermissionGate` ausgewertet wird.
|
||||
- Der Layout-Switch erfolgt über `layouts/index.jsx`, das wiederum Sidebar/Topbar-Komponenten aus dem Material Tailwind Dashboard nutzt.
|
||||
- Auth-Routen (`/sign-in`, `/sign-up`, `/forgot-password`, `/logout`) werden im Auth-Layout ohne Sidebar dargestellt.
|
||||
|
||||
## State-Management & Contexts
|
||||
| Provider | Aufgabe |
|
||||
|----------|---------|
|
||||
| `AuthProvider` | Speichert User, Permissions, Session-Status. Stellt `refreshSession`, `logout`, `hasPermission` bereit. |
|
||||
| `PageProvider` | Zentrale UI-Zustände (z. B. Ladeanzeigen, modale Dialoge) für Dashboard-Komponenten. |
|
||||
| `ToastProvider` | Globale Notifications (z. B. Redeploy-Erfolg/Fehler). |
|
||||
| `MaintenanceProvider` | Cached Maintenance-Status, erkennt aktive Maintenance Modes und zeigt Hinweise. |
|
||||
|
||||
## Wichtige Seiten
|
||||
- **Stacks (`pages/dashboard/stacks.jsx`)**: Hauptübersicht mit Tabellen, Suche, Filtern, Bulk- und Einzelredeploy. Bindet `socket.io-client` für Live-Updates (`redeployStatus`).
|
||||
- **Logs (`pages/dashboard/logs.jsx`)**: Tabellenansicht mit Statusfarben, Filterchips, Export-/Delete-Aktionen. Nutzt axios-Queries gegen `/api/logs`.
|
||||
- **Maintenance (`pages/dashboard/maintenance.jsx`)**: Formulare zur Verwaltung von Update-Skript, SSH-Konfiguration, Maintenance-Mode-Toggle und Portainer-Statusprüfung.
|
||||
- **Users / Usergroups**: CRUD-Oberfläche für Benutzer, Gruppen, Berechtigungszuweisung inkl. Sicherheitsphrasen-Handling.
|
||||
- **Setup (`pages/setup`)**: Schrittweiser Wizard (Server -> API-Key -> Superuser). Wird solange angezeigt, bis `/api/setup/status` vollständig ist.
|
||||
|
||||
## Datenzugriff
|
||||
- HTTP-Anfragen erfolgen via `axios` (global konfiguriert in `frontend/src/components/AuthProvider` bzw. direkt in den Seiten). Cookies werden automatisch vom Browser beigesteuert (`credentials: 'include'`).
|
||||
- Socket.IO wird nur im Stack-Dashboard verwendet; Verbindungsaufbau erfolgt in `stacks.jsx` (`io('/', { path: '/socket.io' })`).
|
||||
- Fehlerbehandlung: Jede Seite zeigt Toasts und/oder Inline-Warnungen. Auth-Fehler leiten zur Loginseite um.
|
||||
|
||||
## Styling & Komponenten
|
||||
- Tailwind Utility-Klassen liefert Material Tailwind bereits vordefiniert (Theme in `configs`).
|
||||
- Custom-Komponenten wie Tabellen, Filterchips oder Formulare sind überwiegend in `widgets/` ausgelagert.
|
||||
- Icons stammen aus `@heroicons/react`. Farben/Typografie folgen Creative Tim Vorgaben, können aber zentral in `frontend/src/configs` angepasst werden.
|
||||
|
||||
## Erweiterungshinweise
|
||||
1. **Neue Seiten**: Route in `routes.jsx` ergänzen, optional `PermissionGate` konfigurieren und Seite unter `pages/dashboard` oder `pages/auth` anlegen.
|
||||
2. **API-Anbindungen**: Verwende `axios` und achte auf `try/catch`, damit 401/403 sauber abgefangen werden. Bei kritischen Anforderungen `refreshSession` aufrufen.
|
||||
3. **State teilen**: Nutze bestehende Provider bzw. erzeuge einen eigenen Context in `components/`.
|
||||
4. **Deployment**: `npm run build` liefert ein Self-Contained Bundle. Das Backend dient anschließend als Static Host (`express.static`).
|
||||
|
||||
Damit bietet das Frontend eine anpassbare, klar strukturierte Oberfläche, die eng mit den Backend-Permissions verzahnt ist.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Getting Started
|
||||
|
||||
Dieser Leitfaden beschreibt ausschließlich den Docker-Deploy über die bereitgestellten GHCR-Images und die anschließende Einrichtung im UI.
|
||||
|
||||
## Voraussetzungen
|
||||
- Docker & Docker Compose
|
||||
- Portainer-Instanz (Business Edition empfohlen; bei Community/Edge zusätzlich Agent einsetzen)
|
||||
|
||||
## Zentrale Umgebungsvariablen (Kurzfassung)
|
||||
| Variable | Erforderlich | Beschreibung |
|
||||
|----------|--------------|--------------|
|
||||
| `PORTAINER_URL` | Nein | Basis-URL zu Portainer; kann im Setup-UI hinterlegt werden, falls nicht gesetzt. |
|
||||
| `PORTAINER_API_KEY` | Nein | API-Key; kann im Setup gespeichert werden (wird verschlüsselt). |
|
||||
| `PORTAINER_SERVER_NAME` | Nein | Anzeige-Name für den initialen Servereintrag. |
|
||||
| `PORTAINER_API_SECRET` | Nein | AES-Schlüssel für gespeicherte API-Keys; setze stabilen Wert, sonst muss der Key neu eingegeben werden. |
|
||||
| `SUPERUSER_USERNAME`, `SUPERUSER_EMAIL`, `SUPERUSER_PASSWORD` | Nein | Legt automatisch einen Superuser an, falls noch keiner existiert; sonst im Setup-UI anlegen. |
|
||||
| `SELF_STACK_ID` | Nein | ID des StackPulse-Stacks; schützt vor Self-Redeploy. |
|
||||
| `AUTH_COOKIE_NAME`, `AUTH_SESSION_TTL_MS`, `AUTH_COOKIE_SECURE` | Nein | Session/Cookie-Tuning (Name, Dauer, Secure-Flag). |
|
||||
| `SECURITY_PHRASE_SECRET` | Nein | Secret für verschlüsselte Sicherheitsphrasen; stabil halten, sonst Phrasen neu verteilen. |
|
||||
| `PORTAINER_SSH_SECRET` | Nein | Secret für verschlüsselte SSH-Passwörter; stabil halten, sonst Passwort neu setzen. |
|
||||
| `DISPLAY` | Nein | Wird an Update-Childprozesse weitergereicht (Default `:9999`). |
|
||||
|
||||
Weitere Variablen (Agent, Registry, mTLS) findest du im Detail unter [Umgebungsvariablen](environment.md). Einstellungen wie Server-URL/API-Key, SSH-Config oder Update-Skript kannst du alternativ im UI (Setup/Wartung) verwalten.
|
||||
|
||||
**Hinweis zu Secrets:**
|
||||
- `SECURITY_PHRASE_SECRET` und `PORTAINER_SSH_SECRET` steuern die AES-256-GCM-Verschlüsselung von Sicherheitsphrasen bzw. hinterlegten SSH-Passwörtern.
|
||||
- Wenn sie nicht gesetzt sind, nutzt StackPulse fixe Fallbacks (`stackpulse-security-phrase-secret` bzw. `stackpulse-portainer-ssh-secret` / API-Key). Setze in Produktion eigene, stabile Werte – ein späterer Secret-Wechsel macht zuvor gespeicherte Phrasen/Passwörter unlesbar.
|
||||
- Eine vollständige Übersicht aller Variablen (inkl. Agent) findest du unter „Technik → Umgebungsvariablen“.
|
||||
|
||||
## Ersteinrichtung im UI
|
||||
1. Öffne das Frontend.
|
||||
2. Lege den Portainer-Server und den zugehörigen API-Key an (der Wizard führt durch den Test).
|
||||
3. Registriere einen Superuser oder verwende den per Umgebungsvariablen automatisch angelegten Account.
|
||||
4. Nach erfolgreicher Einrichtung kannst du dich anmelden und erhältst Zugriff auf Dashboard, Logs, Benutzer & Wartung.
|
||||
|
||||
## Docker Run (Single Container)
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 4001:4001 \
|
||||
-e PORTAINER_URL=https://portainer.example.com \
|
||||
-e PORTAINER_API_KEY=xxxx \
|
||||
-e SUPERUSER_USERNAME=admin \
|
||||
-e SUPERUSER_EMAIL=admin@example.com \
|
||||
-e SUPERUSER_PASSWORD=changeme \
|
||||
-e SELF_STACK_ID=123 \
|
||||
-e SECURITY_PHRASE_SECRET=$(openssl rand -hex 32) \
|
||||
-e PORTAINER_SSH_SECRET=$(openssl rand -hex 32) \
|
||||
-v stackpulse_data:/app/backend/data \
|
||||
ghcr.io/mboehmlaender/stackpulse:latest
|
||||
```
|
||||
Das Volume `stackpulse_data` persistiert die SQLite-Datenbank. Passen Sie Secrets und Ports an Ihre Umgebung an; weitere Variablen siehe Kapitel „Umgebungsvariablen“.
|
||||
|
||||
## Docker-Deployment (Compose)
|
||||
```yaml
|
||||
# docker-compose.yml (Auszug)
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/mboehmlaender/stackpulse:latest
|
||||
ports:
|
||||
- "4001:4001"
|
||||
volumes:
|
||||
- stackpulse_data:/app/backend/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORTAINER_URL=https://portainer.example.com
|
||||
- PORTAINER_API_KEY=xxxx
|
||||
- SUPERUSER_USERNAME=admin
|
||||
- SUPERUSER_EMAIL=admin@example.com
|
||||
- SUPERUSER_PASSWORD=changeme
|
||||
- SELF_STACK_ID=123
|
||||
volumes:
|
||||
stackpulse_data:
|
||||
```
|
||||
|
||||
1. `docker compose up -d`
|
||||
2. Das Frontend ist anschließend unter Port 4001 erreichbar.
|
||||
3. Datenbank & Einstellungen liegen im benannten Volume `stackpulse_data`.
|
||||
|
||||
Damit bist du bereit für die tieferen Kapitel zu Architektur sowie Backend- und Frontend-Details.
|
||||
@@ -0,0 +1,36 @@
|
||||
# StackPulse
|
||||
|
||||
StackPulse ist eine eigenständige Web-Anwendung, die die Portainer Business Edition über deren API steuert und dadurch Redeploys, Wartungsskripte sowie das Berechtigungs- und Logging-Konzept zentralisiert. Die Lösung besteht aus einem Node.js/Express Backend, einem React-Frontend auf Basis von Material Tailwind sowie einer eingebetteten SQLite-Datenbank, die ohne zusätzliche Infrastruktur betrieben werden kann.
|
||||
|
||||
## Warum StackPulse?
|
||||
- **Schneller Überblick** über sämtliche Stacks einer Portainer-Instanz inkl. Status, Filter und Suche
|
||||
- **Redeploy-Orchestrierung** aus einem UI heraus – einzelne Stacks, Auswahlen oder komplette Umgebungen
|
||||
- **Auditierbare Historie** durch strukturierte Event-Logs mit Export-, Lösch- und Filterfunktionen
|
||||
- **Feingranulare Rechteverwaltung** mit Gruppenrechten, Superuser-Handling und Sicherheitsphrasen
|
||||
- **Wartungsautomatisierung** (SSH Update-Skripte, Maintenance-Mode) ohne direkten Portainer-Zugang
|
||||
- **Agent-Integration** (mTLS) für Portainer Community/Edge, um Stacks & Versionen auch ohne Business-API auszulesen
|
||||
- **Bereitstellung als Docker-Image** sowie als lokale Dev-Umgebung via `scripts/start-dev.sh`
|
||||
|
||||
## Technische Eckdaten
|
||||
| Baustein | Technologie | Beschreibung |
|
||||
|-------------|-------------|--------------|
|
||||
| Backend | Node.js 20, Express, Axios, Socket.IO, better-sqlite3 | Stellt REST-API, Authentifizierung, Rechte, Logging, Redeploy-Queue und Portainer-Proxy bereit. |
|
||||
| Datenhaltung| SQLite (Datei `backend/data/stackpulse.db`) | Persistente Speicherung von Benutzern, Gruppen, Servern, API-Keys, Logs und Einstellungen. |
|
||||
| Frontend | React 18, Vite, Material Tailwind Dashboard | Dashboard mit Auth-Flow, Stacks, Logs, Benutzer- und Wartungsansichten. |
|
||||
| Container | Dockerfile + Compose | Multi-Stage Build für Frontend + Backend, Datenvolumen für SQLite. |
|
||||
|
||||
## Release-Status
|
||||
- **v0.5 (aktuell)** – vollständiges Auth/Berechtigungssystem, Benutzer- & Gruppenverwaltung, erweiterte Logs
|
||||
- **v0.4** – neues UI auf Basis des Material Tailwind Dashboards
|
||||
- **v0.3** – Such- & Filterfunktionen, Konfliktbehandlung für Stack-IDs, UI-Benachrichtigungen
|
||||
- **v0.2** – SQLite-Logging inkl. Export/Pagination, Redeploy-Selektor
|
||||
- **v0.1** – Grundstruktur, API-Anbindung, erstes Redeploy
|
||||
|
||||
Weitere geplante Features (Notifications, Monitoring, Multi-Server, Portainer CE) findest du in der [Roadmap im README](../README.md).
|
||||
|
||||
## Wie geht es weiter?
|
||||
- [Getting Started](getting-started.md) erläutert Installation, Konfiguration und erste Schritte.
|
||||
- [Architektur](architecture.md) beschreibt Aufbau, Verzeichnisse und Datenflüsse.
|
||||
- [Backend](backend.md) fasst API, Datenbank und Sicherheitsschichten zusammen.
|
||||
- [Frontend](frontend.md) dokumentiert Layout, Routen und Komponenten.
|
||||
- [Operations & Wartung](operations.md) enthält Betriebshinweise und Troubleshooting.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Lokale Entwicklung & Migration
|
||||
|
||||
Dieser Abschnitt richtet sich an Contributor:innen, die StackPulse lokal bauen oder erweitern möchten. Für produktive Deployments nutze die GHCR-Images wie im Getting Started beschrieben.
|
||||
|
||||
## Voraussetzungen
|
||||
- Node.js **>= 20** und npm
|
||||
- Shell-Zugang zum Host
|
||||
- Docker (optional) für lokale Tests
|
||||
|
||||
## Repository vorbereiten
|
||||
```bash
|
||||
# Repository klonen
|
||||
# git clone <repo-url>
|
||||
cd stackpulse
|
||||
|
||||
# Startskript ausführbar machen
|
||||
chmod +x scripts/start-dev.sh
|
||||
```
|
||||
|
||||
## Schnellstart (Dev-Skript)
|
||||
Das Skript `scripts/start-dev.sh` richtet Backend und Frontend ein und startet beide im Dev-Modus:
|
||||
1. Backend: `npm install`, `npm run migrate`, `npm start`
|
||||
2. Frontend: `npm install`, `npm run dev` (Port 5173) und einmalig `npm run build` für statische Assets
|
||||
|
||||
Nach dem Start:
|
||||
- Frontend Dev Server: http://localhost:5173
|
||||
- Backend API & statische Assets: http://localhost:4001
|
||||
|
||||
Stoppen mit `STRG+C` in der Shell, in der das Skript läuft.
|
||||
|
||||
## Manuelles Starten
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run migrate
|
||||
PORTAINER_URL=https://portainer.local npm start
|
||||
|
||||
# Frontend (zweite Shell)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Das Backend liest Umgebungsvariablen beim Start. Alternativ kannst du eine `.env` neben `backend/index.js` anlegen.
|
||||
|
||||
## Datenbank & Migration
|
||||
- Datei: `backend/data/stackpulse.db` (SQLite, WAL aktiviert)
|
||||
- Migration: `npm run migrate` (führt `backend/db/ensure.js` aus, erzeugt/aktualisiert Schema & Defaults)
|
||||
- Backup: Datei oder Volume regelmäßig sichern; enthält Benutzer, Logs und gespeicherte Secrets
|
||||
|
||||
## Hinweise
|
||||
- Die lokalen Builds sind für Entwicklung gedacht. Für Deployments verwende `ghcr.io/mboehmlaender/stackpulse`.
|
||||
- Einige Einstellungen (z. B. Server-URL, API-Key, SSH-Config) kannst du im UI setzen; für lokale Tests reichen die ENV-Defaults und das Setup-UI.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Operations & Wartung
|
||||
|
||||
Dieser Abschnitt deckt typische Aufgaben für Betriebsteams ab – von Backups über Wartungsmodus bis zu Störungssuche.
|
||||
|
||||
**Relevante Rechte**
|
||||
- `Superuser löschen`
|
||||
- `Bereich & Navigation (Wartung)`
|
||||
|
||||
## Laufender Betrieb
|
||||
- **Services überwachen**: StackPulse stellt nur einen Prozess bereit (Node.js/Express). Nutze Systemd/Docker Healthchecks, indem du regelmäßig `/api/auth/session` (für Auth) oder `/api/maintenance/update-status` (für Backend + DB) abfragst.
|
||||
- **Ports**: Standardmäßig lauscht nur Port 4001 (HTTP + Socket.IO). Hinter einem Reverse Proxy sollte TLS terminiert werden.
|
||||
- **Protokolle**: Das Backend loggt ausführlich auf STDOUT/STDERR (`console.log`). Sammle diese via Docker-Logs oder Journal.
|
||||
|
||||
## Backups & Wiederherstellung
|
||||
1. **Container-Setup**: Mount `backend/data` via benanntem Volume (siehe `docker-compose.yml`).
|
||||
2. **Backup**: Stoppe kurz den Container oder nutze `sqlite3 stackpulse.db ".backup 'stackpulse.db.bak'"` innerhalb der laufenden Instanz.
|
||||
3. **Restore**: Ersetze die Datei im Volume und starte die App neu. Beim ersten Start führt `ensureDatabaseSchema` automatisch eventuelle Nachmigrationen aus.
|
||||
|
||||
## Benutzer- & Rechteverwaltung
|
||||
- Superuser können über `/api/auth/superuser/*` oder im UI registriert werden. Entfernen ist nur mit `Superuser löschen` möglich.
|
||||
- Benutzerkonten lassen sich temporär deaktivieren (`/api/users/:id/active`). Die Historie bleibt erhalten.
|
||||
- Sicherheitsphrasen sind notwendig für Passwort-Reset. Admins können neue Phrasen erzeugen (`/api/users/:id/security-phrase/renew`), müssen diese aber dem Benutzer sicher zustellen.
|
||||
|
||||
## Wartungsmodus & Updates
|
||||
- Maintenance Mode aktivieren: `curl -X POST http://host:4001/api/maintenance/mode -H 'Content-Type: application/json' -d '{"active":true,"message":"Cluster Upgrade"}'` (Recht `Bereich & Navigation (Wartung)`). Das UI informiert alle Nutzer und blockiert Redeploys.
|
||||
- SSH-/Update-Setup: Hinterlege Host, Port, Benutzer, Passwort (optional) und zusätzliche SSH-Argumente. Passwörter werden verschlüsselt gespeichert (`PORTAINER_SSH_SECRET`).
|
||||
- Update-Skript: Das Default-Skript (`DEFAULT_PORTAINER_UPDATE_SCRIPT`) führt `docker stop`, `docker run` etc. aus. Du kannst im UI oder via API ein eigenes Skript setzen – wird erst aktualisiert, wenn kein Update läuft.
|
||||
- Update anstoßen: `POST /api/maintenance/portainer-update` triggert die SSH-Kommandos. Status & Logausgaben können über `/api/maintenance/update-status` überwacht werden.
|
||||
|
||||
## Logs & Compliance
|
||||
- Ereignislogs enthalten jede Redeploy-, Auth- und Verwaltungsaktion. Export über `GET /api/logs/export` (CSV) oder automatisiert mittels Cron (Query-Parameter übernehmen Filter).
|
||||
- Datenbereinigung: Nutze `DELETE /api/logs/:id` oder `DELETE /api/logs?before=<ISO>` um alte Einträge zu entfernen. Achtung: Aktionen sind nicht reversibel.
|
||||
- Für Offload kannst du per Script regelmäßig Exporte ziehen und anschließend löschen.
|
||||
|
||||
## Troubleshooting
|
||||
| Symptom | Prüfschritte |
|
||||
|---------|--------------|
|
||||
| **Setup schlägt fehl (PORTAINER_URL_NOT_CONFIGURED)** | Stelle sicher, dass entweder `PORTAINER_URL` gesetzt ist oder im Setup-UI ein Server gespeichert wurde (`servers`-Tabelle prüfen). |
|
||||
| **Redeploy hängt in `queued`** | `/api/maintenance/update-status` auf laufende Updates prüfen, `redeployingStacks` im Log verfolgen; ggf. Portainer-API erreichbar? |
|
||||
| **Login nicht möglich** | Über `backend/logs` nach Auth-Fehlern suchen, Cookie-Konfiguration prüfen (`AUTH_COOKIE_SECURE` bei HTTP?). |
|
||||
| **SSH-Test fehlgeschlagen** | `POST /api/maintenance/test-ssh` mit temporären Credentials testen, Container-Logs auf `SSH Test fehlgeschlagen` untersuchen. |
|
||||
| **SQLite Locked** | Stelle sicher, dass kein paralleler Backup-Prozess läuft. WAL-Modus erlaubt mehrere Leser, blockiert aber bei langen Schreibtransaktionen (Check Logs auf `SQLITE_BUSY`). |
|
||||
|
||||
## Sicherheitsempfehlungen
|
||||
- Setze `AUTH_COOKIE_SECURE=true` hinter TLS und konfiguriere einen Reverse Proxy (z. B. Traefik, Nginx).
|
||||
- Isoliere das Admin-Interface durch VPN oder IP-Filter.
|
||||
- Drehe API-Keys regelmäßig: `/api/setup/servers/:id/api-key` aktualisiert Schlüssel ohne Neuinstallation.
|
||||
- Nutze dedizierte StackPulse-Benutzer in Portainer, damit Berechtigungen klar getrennt bleiben.
|
||||
|
||||
Mit diesen Richtlinien lässt sich StackPulse sicher betreiben und warten.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Benutzergruppen & Rechte
|
||||
|
||||
Dieser Bereich verwaltet Rollenmodelle und bestimmt, welche Aktionen Benutzer:innen durchführen dürfen.
|
||||
|
||||
**Relevante Rechte**
|
||||
- `Bereich & Navigation (Benutzergruppen)`
|
||||
- `Benutzergruppen bearbeiten`
|
||||
- `Benutzergruppen löschen`
|
||||
|
||||
## Gruppen anlegen
|
||||
1. Button **Gruppe erstellen** wählen.
|
||||
2. Pflichtfelder:
|
||||
- **Name** (z. B. „Ops Team“)
|
||||
- **Beschreibung** (Hilfestellung für Kolleg:innen)
|
||||
3. Optional Tag-Farbe oder Icon setzen (für bessere Übersicht in der Benutzerliste).
|
||||
4. Speichern – die Gruppe erscheint sofort in der Übersicht.
|
||||
|
||||
## Gruppen bearbeiten
|
||||
- Zeile anklicken, um das Detailpanel zu öffnen.
|
||||
- Du kannst Name, Beschreibung und Zugehörige Benutzer (nur Anzeige) sehen.
|
||||
- Zum Bearbeiten der Rechte klicke auf **Berechtigungen bearbeiten**.
|
||||
|
||||
## Rechte vergeben
|
||||
Der Rechte-Dialog zeigt die gesamte Permissionstruktur in Kategorien (Stacks, Logs, Benutzer, Wartung etc.).
|
||||
|
||||
1. Wähle die gewünschte Kategorie aus.
|
||||
2. Setze für jede Permission einen Level:
|
||||
- **none** – kein Zugriff
|
||||
- **read** – Zugriff auf Ansicht/Lesen
|
||||
- **full** – volle Bearbeitungsrechte
|
||||
3. Abhängigkeiten werden automatisch hervorgehoben. Beispiel: Um `Logs löschen` nutzen zu dürfen, muss `Bereich & Navigation (Logs)` mindestens `Vollzugriff` sein.
|
||||
4. Speichern bestätigt die Anpassungen. StackPulse aktualisiert sofort die effektiven Rechte aller Benutzer in dieser Gruppe.
|
||||
|
||||
## Benutzer zu Gruppen hinzufügen
|
||||
- Dies geschieht im Benutzer-Detailpanel (siehe [Benutzerverwaltung](users.md)).
|
||||
- Die Liste zeigt alle vorhandenen Gruppen; ein Benutzer kann mehreren Gruppen angehören.
|
||||
- Die effektiven Rechte sind die jeweils höchsten Levels aller Gruppen.
|
||||
|
||||
## Gruppen löschen
|
||||
- Nur möglich, wenn keine Benutzer ausschließlich von dieser Gruppe abhängen (es erfolgt ein Warnhinweis).
|
||||
- Löschen entfernt keine Benutzer, aber deren effektive Rechte können sich dadurch verringern.
|
||||
|
||||
## Best Practices
|
||||
- Erstelle zunächst eine Basisgruppe (z. B. „Viewer“) mit Leserechten für Stacks und Logs.
|
||||
- Verwende separate Gruppen für sensible Aktionen wie Wartung oder Superuser-Verwaltung.
|
||||
- Nutze sprechende Beschreibungen, damit Kolleg:innen sofort erkennen, wofür eine Gruppe gedacht ist.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Benutzerhandbuch
|
||||
|
||||
Dieses Kapitel beschreibt die Bedienung von StackPulse aus Sicht von Administrator:innen und Operators. Es orientiert sich an den Seiten im Dashboard und erklärt für jeden Bereich typische Abläufe sowie die notwendigen Berechtigungen. Screenshots kannst du später ergänzen; die Texte nennen bereits alle Buttons, Eingabefelder und Statusanzeigen.
|
||||
|
||||
**Reihenfolge**
|
||||
1. [Setup](setup.md) – richtet Portainer-Anbindung und Superuser ein.
|
||||
2. [Stacks](stacks.md) – zeigt alle Stacks, Redeploy-Aktionen und Filter.
|
||||
3. [Logs](logs.md) – Nachvollziehbarkeit, Filter & Export.
|
||||
4. [Wartung](maintenance.md) – Maintenance-Mode, Updates und SSH-Konfiguration.
|
||||
5. [Benutzer](users.md) – Konten verwalten, Phrasen ausgeben, Passwörter setzen.
|
||||
6. [Benutzergruppen & Rechte](groups.md) – Rollen modellieren und Berechtigungen zuweisen.
|
||||
|
||||
Jede Seite nennt die erforderlichen Permissions. Sobald du dich anmeldest, führt dich StackPulse zunächst automatisch durch den Setup-Assistenten; danach landet man auf der Stacks-Übersicht.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Logs-Seite
|
||||
|
||||
Die Logs-Seite dient der Nachvollziehbarkeit aller Aktionen (Redeploy, Login, Wartung, Benutzerverwaltung). Sie steht Benutzer:innen mit der Permission `logs-access` zur Verfügung.
|
||||
|
||||
## Überblick
|
||||
- Filterleiste mit Suchfeld, Zeitspanne, Kategorie und Status.
|
||||
- Tabelle mit Severity-Farben, Nachricht, Zeitstempel und Kontextinformationen.
|
||||
- Aktionen oberhalb der Tabelle: **Exportieren** und **Löschen**.
|
||||
|
||||
## Filtern
|
||||
1. **Zeitfenster:** Datumsfelder „Von“ und „Bis“ setzen. Standardmäßig zeigt StackPulse die letzten 24 Stunden.
|
||||
2. **Kategorie/Status:** Dropdowns nutzen, um z. B. `redeploy` oder `wartung` auszuwählen.
|
||||
3. **Freitext:** Suchfeld durchsucht `message`, `entity_name` und `metadata`.
|
||||
4. **Erweiterte Filter:** Klick auf „Weitere Filter“ öffnet zusätzliche Optionen (Actor, Stack-ID, Severity).
|
||||
|
||||
## Export
|
||||
- Button **CSV exportieren** klickst du erst, nachdem die Filter stehen. StackPulse erstellt eine CSV-Datei mit denselben Parametern.
|
||||
- Der Download startet im Browser; in containerisierten Umgebungen kannst du denselben Endpoint (`/api/logs/export`) auch automatisiert aufrufen.
|
||||
|
||||
## Löschen
|
||||
- Einzelne Logs: Papierkorb in der Zeile (Permission `logs-delete`).
|
||||
- Batch-Löschung: Button **Gefilterte Einträge löschen**; es erscheint ein Sicherheitsdialog mit Zusammenfassung der aktiven Filter.
|
||||
- Beachte, dass gelöschte Logs nicht wiederhergestellt werden können.
|
||||
|
||||
## Detailansicht
|
||||
- Klick auf eine Tabellenzeile, um ein Seitenpanel mit vollständigem JSON (`metadata`), Kontext (Stack, Benutzer, Quelle) und ggf. Redeploy-Timings zu öffnen.
|
||||
- Über **Stack öffnen** gelangst du direkt zur Stacks-Seite mit gesetztem Filter.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Wartungsbereich
|
||||
|
||||
Der Wartungsbereich bündelt Maintenance-Mode, Serververwaltung (inkl. Agent-Integration), Update-Skripte und SSH-Konfiguration. Sichtbar für Benutzer:innen mit `Bereich & Navigation (Wartung)`; Unterbereiche haben eigene Rechte:
|
||||
|
||||
**Relevante Rechte**
|
||||
- `Bereich & Navigation (Wartung)`
|
||||
- `Server-Sektion`
|
||||
- `Server bearbeiten` / `Server löschen`
|
||||
- `SSH/Update-Skript`
|
||||
- `Update durchführen`
|
||||
- `Agent`
|
||||
- `Doppelte Stacks`
|
||||
- `mTLS Sektion`
|
||||
- `Superuser löschen`
|
||||
|
||||
- `Server-Sektion` (lesen) zeigt die Serverübersicht.
|
||||
- `Server bearbeiten` (lesen) zeigt Server-Details inkl. Portainer-Status; Felder bleiben schreibgeschützt. Vollzugriff erlaubt Bearbeiten/Speichern.
|
||||
- `Server löschen` löscht Server (nur bei Vollzugriff).
|
||||
- `SSH/Update-Skript` und `Update durchführen` hängen am Unterpunkt Portainer/SSH und erfordern Vollzugriff auf Server-Edit.
|
||||
- `Agent` (lesen/voll) steuert die Agent-Sektion im Server-Detail.
|
||||
|
||||
## Maintenance-Mode
|
||||
1. Schalter **Wartungsmodus aktivieren** umlegen.
|
||||
2. Jede Aktivierung/Deaktivierung wird in den Logs protokolliert.
|
||||
|
||||
## Serververwaltung & API-Key
|
||||
- Liste zeigt alle eingetragenen Portainer-Server.
|
||||
- **Server hinzufügen**/**bearbeiten**/**löschen** nur mit `Server bearbeiten` (Vollzugriff); löschen zusätzlich `Server löschen`.
|
||||
- Im Server-Detail:
|
||||
- **Server-URL/Name** und **Portainer API-Key**: editierbar nur mit `Server bearbeiten` Vollzugriff; bei Leserecht sichtbar, aber gesperrt.
|
||||
- **Self-Stack-ID**: Stack-ID von StackPulse selbst; blockiert Redeploys dieses Stacks.
|
||||
- **Status prüfen**: Live-Check gegen Portainer (Version, Edition, Update vorhanden). Sichtbar ab `Server bearbeiten` (lesen).
|
||||
|
||||
## Agent-Integration (Portainer CE / Edge)
|
||||
Für die Community Edition von Stackpulse kannst du je Server einen StackPulse Agent hinterlegen. Er liefert Stack- und Portainer-Metadaten, wenn die Business-API fehlt. Hierzu muss auch der Agent auf dem Server der Portainer Instanz laufen. Der Agent wird für die Business Edition nicht benötigt.
|
||||
|
||||
1. Im Server-Detail **Agent konfigurieren** öffnen.
|
||||
2. **Agent URL** eintragen, der **Agent Token** wird automatisch generiert und kann nicht geändert werden (Token = `AGENT_TOKEN` des Agents).
|
||||
StackPulse prüft Erreichbarkeit und zeigt Online-/Fehlerstatus. Sichtbar mit `Agent` lesen, Bearbeitung mit `Agent` voll.
|
||||
3. mTLS-Zertifikate verwalten:
|
||||
- **Bootstrap-Token erzeugen** (Einmal-Token) und im Agent als `AGENT_BOOTSTRAP_TOKEN` setzen.
|
||||
- Agent holt Zertifikate automatisch ab; Status im Zertifikate-Abschnitt.
|
||||
- **Zertifikate anzeigen** (PEM) (nur bei entsprechenden Rechten)
|
||||
4. Portainer-Version kann gelesen werden, wenn der Agent online ist.
|
||||
|
||||
## SSH-Konfiguration
|
||||
Erfordert `SSH/Update-Skript` (sichtbar/bearbeitbar nur bei Vollzugriff auf Server-Edit).
|
||||
1. Host, Port, Benutzername und Passwort sowie eventuell notwendige Extra-Argumente eintragen.
|
||||
2. Passwort wird verschlüsselt gespeichert.
|
||||
3. **Verbindung testen** prüft Erreichbarkeit und meldet Ergebnis direkt.
|
||||
|
||||
## Update-Skript & Portainer-Update
|
||||
- Standard-Skript ist sichtbar und kann überschrieben werden (Recht `SSH/Update-Skript` + Server-Edit Vollzugriff). **Zurücksetzen** stellt den Standard wieder her.
|
||||
- Start eines Updates (`Update durchführen` + Server-Edit Vollzugriff):
|
||||
1) Wartungsmodus wird automatisch aktiviert.
|
||||
2) SSH-Konfiguration und Skript prüfen.
|
||||
3) **Update starten** klicken. Der Fortschritt und die Logausgaben erscheinen im Fenster.
|
||||
4) Während ein Update läuft, sind Änderungen am System gesperrt.
|
||||
|
||||
## Fehlerbehandlung
|
||||
- SSH-/Update-Fehler werden mit Klartextausgabe angezeigt. Bei Bedarf Host/Port/User, Passwort oder Skript anpassen.
|
||||
- Agent offline: URL/Token prüfen, Bootstrap-Token ggf. neu erzeugen und den Agent neu starten.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Setup-Assistent
|
||||
|
||||
Beim ersten Start erscheint automatisch der Setup-Bereich und führt dich durch alle Schritte, bis die Einrichtung abgeschlossen ist.
|
||||
|
||||
## Voraussetzungen
|
||||
- Portainer Business Edition mit gültigem API-Key
|
||||
- Superuser-Zugangsdaten (entweder schon per Umgebungsvariablen gesetzt oder über diesen Assistenten festlegen)
|
||||
|
||||
## Schritt 1: Portainer-Server anlegen
|
||||
1. Formularfelder **Server-URL** und optional **Name** ausfüllen.
|
||||
- URL inkl. Protokoll (z. B. `https://portainer.example.com`).
|
||||
- Bezeichnung dient ausschließlich als Anzeige im Dashboard.
|
||||
2. Auf **Verbindung testen** klicken.
|
||||
- Erfolgreich: grüner Hinweis, die Endpoints werden erkannt.
|
||||
- Fehlgeschlagen: Fehlermeldung prüfen (häufig Zertifikats-/Firewall-Themen).
|
||||
3. Mit **Speichern & Weiter** bestätigst du den Servereintrag. StackPulse legt ihn verschlüsselt in SQLite ab.
|
||||
|
||||
## Schritt 2: API-Key hinterlegen
|
||||
1. API-Key im Feld **Portainer API Key** einfügen.
|
||||
2. Optional kannst du über **Stacks laden** kontrollieren, ob Portainer Stacks zurückliefert.
|
||||
3. Mit **Speichern** wird der Schlüssel verschlüsselt gespeichert. Du kannst ihn später im Maintenance-Bereich austauschen.
|
||||
|
||||
## Schritt 3: Superuser registrieren
|
||||
1. Falls du `SUPERUSER_*` Variablen gesetzt hast, zeigt StackPulse an, dass bereits ein Superuser existiert – du kannst direkt weiter.
|
||||
2. Ansonsten Benutzername, E-Mail und Passwort vergeben. Das Passwort dient nur für diesen Account; weitere Benutzer folgen später.
|
||||
3. Auf **Superuser anlegen** klicken. Nach Erfolg wird der Login-Button eingeblendet.
|
||||
|
||||
## Abschluss & Login
|
||||
- Sobald alle drei Schritte abgeschlossen sind, erscheint die Schaltfläche **Zum Login**.
|
||||
- Melde dich mit dem Superuser an. StackPulse legt im Hintergrund initiale Berechtigungen, Default-Gruppen und Sicherheitspassphrasen an.
|
||||
|
||||
## Setup wiederholen oder ändern
|
||||
- Über das Dashboard → **Wartung** → Abschnitt *Serververwaltung* kannst du zusätzliche Portainer-Server hinzufügen oder entfernen.
|
||||
- Superuser lässt sich im Bereich **Benutzer** löschen, sofern du das Recht `Superuser löschen` besitzt.
|
||||
- Der Self-Stack (StackPulse-eigener Stack) kann in der Wartung gepflegt werden, damit er nicht versehentlich redeployed wird.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Stacks-Seite
|
||||
|
||||
Die Stacks-Ansicht ist der Startpunkt nach dem Login und liefert einen Überblick über alle Stacks der angebundenen Portainer-Instanz.
|
||||
|
||||
**Relevante Rechte**
|
||||
- `Redeploy einzeln`
|
||||
- `Redeploy Auswahl`
|
||||
- `Redeploy Alle`
|
||||
|
||||
## Aufbau
|
||||
- **Header** mit Suchfeld, Filterchips für Status sowie Buttons für Redeploy-Aktionen.
|
||||
- **Tabelle** mit Name, Endpoint, Status, letzte Aktion, Redeploy-Status und verfügbaren Buttons.
|
||||
- **Live-Status**: Über Socket.IO wird jede Veränderung (queued/started/success/error) sofort eingeblendet.
|
||||
|
||||
## Filter & Suche
|
||||
1. Verwende das Suchfeld oben rechts, um nach Namen oder IDs zu filtern.
|
||||
2. Statuschips (z. B. „running“, „warning“) toggeln per Klick und lassen sich kombinieren.
|
||||
3. Über die Spaltenköpfe kannst du sortieren; ein erneuter Klick kehrt die Reihenfolge um.
|
||||
|
||||
## Redeploy-Aktionen
|
||||
| Aktion | Voraussetzung | Vorgehen |
|
||||
|--------|---------------|----------|
|
||||
| **Einzelner Stack** | Recht `Redeploy einzeln` | Button **Redeploy** in der jeweiligen Zeile klicken und bestätigen. |
|
||||
| **Mehrere Stacks** | Recht `Redeploy Auswahl` | Checkboxen der gewünschten Stacks aktivieren, anschließend **Redeploy Auswahl**. |
|
||||
| **Alle Stacks** | Recht `Redeploy Alle` | Button **Redeploy alle** auslösen. Nutze vorher Filter, um sicherzugehen, dass alle Stacks korrekt angezeigt werden. |
|
||||
|
||||
Während eines Redeploys erscheint pro Stack ein Fortschrittsbadge. Du kannst das Panel schließen; beim nächsten Besuch wird der aktuelle Status aus dem Backend gelesen.
|
||||
|
||||
## Redeploy-Status verstehen
|
||||
- **Queued** – Auftrag ist vorgemerkt.
|
||||
- **Started** – Portainer verarbeitet Redeploy.
|
||||
- **Success** – Vorgang erfolgreich abgeschlossen.
|
||||
- **Error** – Fehlerdetails findest du in der Logs-Seite.
|
||||
- **Info** – Informative Hinweise, etwa wenn Portainer einen Stack überspringt.
|
||||
|
||||
## Selbstschutz
|
||||
- Falls du die Variable `SELF_STACK_ID` gesetzt hast, markiert StackPulse den eigenen Stack als „geschützt“. Redeploy-Buttons werden deaktiviert und mit einem Info-Tooltip versehen.
|
||||
|
||||
## Troubleshooting aus der Ansicht
|
||||
- Klicke auf das Warnsymbol in der Tabellenzeile, um Fehlerdetails einzublenden.
|
||||
- Nutze den Link „Zu Logs springen“, der direkt eine gefilterte Ansicht in der Log-Seite öffnet (Stack-ID ist vorausgewählt).
|
||||
@@ -0,0 +1,39 @@
|
||||
# Benutzerverwaltung
|
||||
|
||||
Die Benutzerseite ermöglicht das Anlegen, Bearbeiten und Deaktivieren von Konten. Du benötigst mindestens die Permission `users-access` (lesen) bzw. `users-edit`/`users-delete` für Änderungen.
|
||||
|
||||
## Benutzer anlegen
|
||||
1. Button **Benutzer anlegen** klicken.
|
||||
2. Formularfelder ausfüllen:
|
||||
- Benutzername (eindeutig, wird für Login genutzt)
|
||||
- Anzeigename (optional)
|
||||
- E-Mail-Adresse
|
||||
- Passwort (kann später geändert werden)
|
||||
- Gruppen auswählen (siehe Abschnitt „Benutzergruppen“)
|
||||
3. Optional: Benutzer sofort aktivieren/deaktivieren.
|
||||
4. Mit **Speichern** bestätigen. Der Benutzer erscheint in der Tabelle und erhält automatisch eine Sicherheitspassphrase.
|
||||
|
||||
## Benutzer bearbeiten
|
||||
- In der Tabelle auf eine Zeile klicken, um das Detailpanel zu öffnen.
|
||||
- Du kannst Anzeigename, E-Mail, Passwort und Gruppen ändern.
|
||||
- Änderungen an Gruppen erfordern `users-edit` sowie Gruppenrechte (`user-groups-access`).
|
||||
|
||||
## Konto deaktivieren/reaktivieren
|
||||
- Toggle **Aktiv** im Detailpanel.
|
||||
- Deaktivierte Konten können sich nicht mehr anmelden, bleiben aber in der Historie.
|
||||
|
||||
## Sicherheitspassphrase
|
||||
1. Button **Sicherheitspassphrase anzeigen** im Detailpanel.
|
||||
2. StackPulse zeigt einen QR-Code und den alphanumerischen Code. Dieser wird nur einmal angezeigt, bis du bestätigst, dass er heruntergeladen wurde.
|
||||
3. Über **Neu generieren** kannst du eine neue Phrase erstellen (Permission `users-security-phrase`). Informiere den Benutzer über die neue Phrase und markiere anschließend „heruntergeladen“.
|
||||
|
||||
## Passwort zurücksetzen
|
||||
- Admins können direkt im Detailpanel ein neues Passwort setzen.
|
||||
- Benutzer:innen selbst nutzen die Auth-Seite „Passwort vergessen“: Sie geben Username + Sicherheitsphrase ein und definieren anschließend ein neues Passwort.
|
||||
|
||||
## Suche & Filter
|
||||
- Nutze das Suchfeld, um nach Benutzername oder E-Mail zu suchen.
|
||||
- Filterchips für Status (aktiv/inaktiv) erleichtern das Auffinden gesperrter Accounts.
|
||||
|
||||
## Massenaktionen
|
||||
- Mehrfachauswahl (Checkboxen) erlaubt das gleichzeitige Aktivieren/Deaktivieren oder Löschen mehrerer Benutzer. Vorsicht: Löschen entfernt auch Audit-Verweise.
|
||||
@@ -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,43 +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 logo from "./assets/images/stackpulse.png";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Dashboard, Auth } from "@/layouts";
|
||||
import Setup from "@/pages/setup/setup";
|
||||
|
||||
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() {
|
||||
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.2</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="/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="/logs" element={<Logs />} />
|
||||
<Route path="*" element={<Stacks />} />
|
||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||
<Route path="/auth/*" element={<Auth />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,934 +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="space-y-4">
|
||||
<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">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span>Einträge pro Seite</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={handlePerPageChange}
|
||||
className="rounded-md border border-gray-700 bg-gray-900/70 px-3 py-1.5 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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="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>
|
||||
);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
export default function Stacks() {
|
||||
const [stacks, setStacks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selectedStackIds, setSelectedStackIds] = useState([]);
|
||||
|
||||
const mergeStackState = (previousStacks, incomingStacks) => {
|
||||
const prevMap = new Map(previousStacks.map((stack) => [stack.Id, stack]));
|
||||
const sortedIncoming = [...incomingStacks].sort((a, b) => a.Name.localeCompare(b.Name));
|
||||
|
||||
return sortedIncoming.map((stack) => {
|
||||
const previous = prevMap.get(stack.Id);
|
||||
return {
|
||||
...stack,
|
||||
redeploying: previous?.redeploying || stack.redeploying || false,
|
||||
redeployDisabled: stack.redeployDisabled ?? previous?.redeployDisabled ?? false
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io("/", {
|
||||
path: "/socket.io",
|
||||
transports: ["websocket"]
|
||||
});
|
||||
console.log("🔌 Socket connected");
|
||||
|
||||
socket.on("redeployStatus", async ({ stackId, status }) => {
|
||||
console.log(`🔄 Stack ${stackId} Redeploy Status: ${status ? "running" : "finished"}`);
|
||||
|
||||
setStacks(prev =>
|
||||
prev.map(stack => {
|
||||
if (stack.Id !== stackId) return stack;
|
||||
return {
|
||||
...stack,
|
||||
redeploying: status,
|
||||
updateStatus: status ? stack.updateStatus : '✅'
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (!status) {
|
||||
// Status nach Redeploy neu vom Server holen
|
||||
try {
|
||||
const res = await axios.get("/api/stacks");
|
||||
setStacks(prev => mergeStackState(prev, res.data));
|
||||
} catch (err) {
|
||||
console.error("Fehler beim Aktualisieren nach Redeploy:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => socket.disconnect();
|
||||
}, []);
|
||||
|
||||
const fetchStacks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get("/api/stacks");
|
||||
setStacks(prev => mergeStackState(prev, res.data));
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Abrufen der Stacks:", err);
|
||||
setError("Fehler beim Laden der Stacks");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStacks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStackIds(prev => {
|
||||
const filtered = prev.filter(id => {
|
||||
const match = stacks.find(stack => stack.Id === id);
|
||||
return match && match.updateStatus !== '✅' && !match.redeployDisabled;
|
||||
});
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
}, [stacks]);
|
||||
|
||||
const toggleStackSelection = (stackId, disabled) => {
|
||||
if (disabled) return;
|
||||
setSelectedStackIds(prev =>
|
||||
prev.includes(stackId)
|
||||
? prev.filter(id => id !== stackId)
|
||||
: [...prev, stackId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleRedeploy = async (stackId) => {
|
||||
const targetStack = stacks.find((stack) => stack.Id === stackId);
|
||||
if (targetStack?.redeployDisabled) return;
|
||||
|
||||
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
||||
setStacks((prev) =>
|
||||
prev.map((stack) =>
|
||||
stack.Id === stackId ? { ...stack, redeploying: true } : stack
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put(`/api/stacks/${stackId}/redeploy`);
|
||||
// Statusupdates kommen über Socket.IO
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Redeploy:", err);
|
||||
setStacks((prev) =>
|
||||
prev.map((stack) =>
|
||||
stack.Id === stackId ? { ...stack, redeploying: false } : stack
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedeployAll = async () => {
|
||||
const outdatedStacks = stacks.filter((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled);
|
||||
if (!outdatedStacks.length) return;
|
||||
|
||||
const outdatedIds = new Set(outdatedStacks.map((stack) => stack.Id));
|
||||
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
outdatedIds.has(stack.Id)
|
||||
? { ...stack, redeploying: true }
|
||||
: stack
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put("/api/stacks/redeploy-all");
|
||||
setSelectedStackIds((prev) => prev.filter((id) => !outdatedIds.has(id)));
|
||||
// Statusupdates kommen über Socket.IO
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Redeploy ALL:", err);
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
outdatedIds.has(stack.Id)
|
||||
? { ...stack, redeploying: false }
|
||||
: stack
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedeploySelection = async () => {
|
||||
if (!selectedStackIds.length) return;
|
||||
|
||||
const eligibleIds = selectedStackIds.filter((id) => {
|
||||
const stack = stacks.find((entry) => entry.Id === id);
|
||||
return stack && stack.updateStatus !== '✅' && !stack.redeployDisabled;
|
||||
});
|
||||
|
||||
if (!eligibleIds.length) {
|
||||
setSelectedStackIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const eligibleSet = new Set(eligibleIds);
|
||||
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
eligibleSet.has(stack.Id)
|
||||
? { ...stack, redeploying: true }
|
||||
: stack
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put("/api/stacks/redeploy-selection", { stackIds: eligibleIds });
|
||||
setSelectedStackIds((prev) => prev.filter((id) => !eligibleSet.has(id)));
|
||||
// Statusupdates kommen über Socket.IO
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Redeploy Auswahl:", err);
|
||||
setStacks(prev =>
|
||||
prev.map(stack =>
|
||||
eligibleSet.has(stack.Id)
|
||||
? { ...stack, redeploying: false }
|
||||
: stack
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelection = selectedStackIds.length > 0;
|
||||
const hasOutdatedStacks = stacks.some((stack) => stack.updateStatus !== '✅' && !stack.redeployDisabled);
|
||||
const bulkButtonLabel = hasSelection
|
||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||
: 'Redeploy All';
|
||||
|
||||
const bulkActionDisabled = hasSelection
|
||||
? selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
||||
const targetStack = stacks.find(stack => stack.Id === id);
|
||||
return !targetStack || targetStack.redeploying || targetStack.updateStatus === '✅' || targetStack.redeployDisabled;
|
||||
})
|
||||
: !hasOutdatedStacks || stacks.every(stack => stack.updateStatus !== '✅' || stack.redeploying || stack.redeployDisabled);
|
||||
|
||||
const handleBulkRedeploy = () => {
|
||||
if (hasSelection) {
|
||||
handleRedeploySelection();
|
||||
} else {
|
||||
handleRedeployAll();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-400">Lade Stacks...</p>;
|
||||
if (error) return <p className="text-red-400">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={handleBulkRedeploy}
|
||||
disabled={bulkActionDisabled}
|
||||
className={`px-5 py-2 rounded-lg font-medium transition ${bulkActionDisabled ? 'bg-purple-900 cursor-not-allowed text-gray-400' : 'bg-purple-500 hover:bg-purple-600'}`}
|
||||
>
|
||||
{bulkButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{stacks.map(stack => {
|
||||
const isRedeploying = stack.redeploying;
|
||||
const isSelected = selectedStackIds.includes(stack.Id);
|
||||
const isCurrent = stack.updateStatus === '✅';
|
||||
const isSelfStack = Boolean(stack.redeployDisabled);
|
||||
const isSelectable = !isRedeploying && !isCurrent && !isSelfStack;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stack.Id}
|
||||
className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition border
|
||||
${isSelected ? 'border-purple-500 ring-1 ring-purple-500/40' : 'border-transparent'}
|
||||
${isRedeploying ? 'bg-gray-700 cursor-wait' : 'bg-gray-800 hover:bg-gray-700'}
|
||||
${!isSelectable && !isRedeploying ? 'opacity-75' : ''}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleStackSelection(stack.Id, !isSelectable)}
|
||||
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!isSelectable ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
disabled={!isSelectable}
|
||||
/>
|
||||
<div className={`w-12 h-12 flex items-center justify-center rounded-full
|
||||
${stack.updateStatus === '✅' ? 'bg-green-500' :
|
||||
stack.updateStatus === '⚠️' ? 'bg-yellow-500' :
|
||||
'bg-red-500'}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-white">{stack.Name}</p>
|
||||
<p className="text-sm text-gray-400">ID: {stack.Id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 text-sm">
|
||||
{isRedeploying ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-orange-300">Redeploy</span>
|
||||
<span className="text-orange-200">läuft…</span>
|
||||
</>
|
||||
) : isSelfStack ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">System</span>
|
||||
<span className="text-gray-300">Redeploy deaktiviert</span>
|
||||
</>
|
||||
) : isCurrent ? (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">Status</span>
|
||||
<span className="text-green-300">Aktuell</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wide text-gray-400">Status</span>
|
||||
<span className="text-amber-300">Veraltet</span>
|
||||
<button
|
||||
onClick={() => handleRedeploy(stack.Id)}
|
||||
disabled={isRedeploying}
|
||||
className="px-5 py-2 rounded-lg font-medium transition bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
Redeploy
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stacks.length === 0 && <p className="text-gray-400">Keine Stacks gefunden.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1,252 @@
|
||||
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;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const normalizeServerPermissions = (raw) => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
return Object.entries(raw).reduce((acc, [serverId, value]) => {
|
||||
const numericId = Number(serverId);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
return acc;
|
||||
}
|
||||
const permissionMap = value?.permissions && typeof value.permissions === "object"
|
||||
? value.permissions
|
||||
: value;
|
||||
acc[numericId] = {
|
||||
permissions: normalizePermissions(permissionMap),
|
||||
groupId: Number.isFinite(Number(value?.groupId)) ? Number(value.groupId) : null,
|
||||
useGlobalGroup: Boolean(value?.useGlobalGroup)
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const normalizeServerAssignments = (raw) => {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.map((entry) => {
|
||||
const serverId = Number(entry?.serverId ?? entry?.server_id ?? entry?.id);
|
||||
if (!Number.isFinite(serverId)) return null;
|
||||
return {
|
||||
serverId,
|
||||
groupId: Number.isFinite(Number(entry?.groupId ?? entry?.group_id)) ? Number(entry?.groupId ?? entry?.group_id) : null,
|
||||
useGlobalGroup: Boolean(entry?.useGlobalGroup ?? entry?.use_global_group),
|
||||
serverName: entry?.serverName || entry?.server_name || null,
|
||||
serverUrl: entry?.serverUrl || entry?.server_url || null
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [state, setState] = useState({
|
||||
user: null,
|
||||
permissions: {},
|
||||
serverPermissions: {},
|
||||
serverAssignments: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastErrorCode: null,
|
||||
initialized: false,
|
||||
});
|
||||
|
||||
const setSession = useCallback((payload) => {
|
||||
const user = payload?.user ?? null;
|
||||
const permissions = normalizePermissions(payload?.permissions);
|
||||
const serverPermissions = normalizeServerPermissions(payload?.serverPermissions);
|
||||
const serverAssignments = normalizeServerAssignments(payload?.serverAssignments ?? user?.serverAssignments);
|
||||
setState({
|
||||
user,
|
||||
permissions,
|
||||
serverPermissions,
|
||||
serverAssignments,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastErrorCode: null,
|
||||
initialized: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
user: null,
|
||||
permissions: {},
|
||||
serverPermissions: {},
|
||||
serverAssignments: [],
|
||||
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,
|
||||
serverPermissions: {},
|
||||
serverAssignments: []
|
||||
});
|
||||
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: {},
|
||||
serverPermissions: {},
|
||||
serverAssignments: [],
|
||||
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 hasServerPermission = useCallback(
|
||||
(serverId, permissionKey, requiredLevel = "full") => {
|
||||
if (!permissionKey) {
|
||||
return true;
|
||||
}
|
||||
if (state.user?.isSuperuser) {
|
||||
return true;
|
||||
}
|
||||
const numericServerId = Number(serverId);
|
||||
let permissionMap = null;
|
||||
if (Number.isFinite(numericServerId)) {
|
||||
const entry = state.serverPermissions?.[numericServerId] || state.serverPermissions?.[String(numericServerId)];
|
||||
if (entry?.permissions && typeof entry.permissions === "object") {
|
||||
permissionMap = entry.permissions;
|
||||
}
|
||||
}
|
||||
const basePermissions = permissionMap || state.permissions || {};
|
||||
const current = basePermissions[permissionKey] ?? "none";
|
||||
const currentPriority = LEVEL_PRIORITY[current] ?? 0;
|
||||
const requiredPriority = LEVEL_PRIORITY[requiredLevel] ?? LEVEL_PRIORITY.full;
|
||||
return currentPriority >= requiredPriority;
|
||||
},
|
||||
[state.serverPermissions, state.permissions, state.user]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user: state.user,
|
||||
permissions: state.permissions,
|
||||
serverPermissions: state.serverPermissions,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
lastErrorCode: state.lastErrorCode,
|
||||
initialized: state.initialized,
|
||||
isAuthenticated: Boolean(state.user),
|
||||
refreshSession,
|
||||
setSession,
|
||||
clearSession,
|
||||
logout,
|
||||
hasPermission,
|
||||
hasServerPermission,
|
||||
serverAssignments: state.serverAssignments,
|
||||
}),
|
||||
[state, refreshSession, setSession, clearSession, logout, hasPermission, hasServerPermission]
|
||||
);
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,223 @@
|
||||
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);
|
||||
|
||||
const INITIAL_STATE = {
|
||||
maintenance: null,
|
||||
update: null,
|
||||
script: null,
|
||||
ssh: null,
|
||||
loading: true,
|
||||
error: "",
|
||||
lastUpdated: null
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const applyState = useCallback((partial) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
...partial
|
||||
}));
|
||||
}, []);
|
||||
|
||||
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");
|
||||
const data = response.data ?? {};
|
||||
applyState({
|
||||
maintenance: data.maintenance ?? null,
|
||||
update: data.update ?? null,
|
||||
script: data.script ?? null,
|
||||
ssh: data.ssh ?? null,
|
||||
loading: false,
|
||||
error: "",
|
||||
lastUpdated: new Date()
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
|
||||
applyState({ loading: false, error: message });
|
||||
}
|
||||
}, [applyState, canAccessMaintenance]);
|
||||
|
||||
const refreshUpdateStatus = useCallback(async () => {
|
||||
if (!canAccessMaintenance) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await axios.get("/api/maintenance/update-status");
|
||||
const data = response.data ?? {};
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
maintenance: data.maintenance ?? prev.maintenance,
|
||||
update: data.update ?? prev.update,
|
||||
error: ""
|
||||
}));
|
||||
} catch (err) {
|
||||
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();
|
||||
}, [applyState, fetchConfig, canAccessMaintenance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAccessMaintenance) {
|
||||
return undefined;
|
||||
}
|
||||
if (state.update?.running) {
|
||||
refreshUpdateStatus();
|
||||
const intervalId = setInterval(() => {
|
||||
refreshUpdateStatus();
|
||||
}, 5000);
|
||||
pollingRef.current = intervalId;
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
return undefined;
|
||||
}, [canAccessMaintenance, state.update?.running, refreshUpdateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentlyRunning = Boolean(state.update?.running);
|
||||
if (canAccessMaintenance && !currentlyRunning && wasRunningRef.current) {
|
||||
fetchConfig();
|
||||
}
|
||||
wasRunningRef.current = currentlyRunning;
|
||||
}, [canAccessMaintenance, state.update?.running, fetchConfig]);
|
||||
|
||||
const triggerUpdate = useCallback(async (payload = {}) => {
|
||||
await axios.post("/api/maintenance/portainer-update", payload);
|
||||
await refreshUpdateStatus();
|
||||
}, [refreshUpdateStatus]);
|
||||
|
||||
const saveScript = useCallback(async (script) => {
|
||||
await axios.put("/api/maintenance/update-script", { script });
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const resetScript = useCallback(async () => {
|
||||
await axios.delete("/api/maintenance/update-script");
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const saveSshConfig = useCallback(async (config) => {
|
||||
await axios.put("/api/maintenance/ssh-config", config);
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const deleteSshConfig = useCallback(async () => {
|
||||
await axios.delete("/api/maintenance/ssh-config");
|
||||
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,
|
||||
script: state.script,
|
||||
ssh: state.ssh,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
lastUpdated: state.lastUpdated,
|
||||
refreshConfig: fetchConfig,
|
||||
refreshUpdateStatus,
|
||||
setMaintenanceMode,
|
||||
triggerUpdate,
|
||||
saveScript,
|
||||
resetScript,
|
||||
saveSshConfig,
|
||||
deleteSshConfig,
|
||||
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}>
|
||||
{children}
|
||||
</MaintenanceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaintenance() {
|
||||
const context = useContext(MaintenanceContext);
|
||||
if (!context) {
|
||||
throw new Error('useMaintenance must be used within a MaintenanceProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
const VARIANT_STYLES = {
|
||||
success: {
|
||||
container: "border-mossGreen-500/80 bg-mossGreen-900/90 text-white",
|
||||
accent: "bg-mossGreen-400"
|
||||
},
|
||||
warning: {
|
||||
container: "border-warmAmberGlow-500/80 bg-warmAmberGlow-900/90 text-white",
|
||||
accent: "bg-warmAmberGlow-400"
|
||||
},
|
||||
error: {
|
||||
container: "border-sunsetCoral-500/80 bg-sunsetCoral-900/90 text-white",
|
||||
accent: "bg-sunsetCoral-400"
|
||||
},
|
||||
info: {
|
||||
container: "border-arcticBlue-500 bg-arcticBlue-900/90 text-white",
|
||||
accent: "bg-arcticBlue-400"
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION = 6000;
|
||||
|
||||
export default function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const timeoutsRef = useRef(new Map());
|
||||
|
||||
const dismissToast = useCallback((id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
|
||||
const handle = timeoutsRef.current.get(id);
|
||||
if (handle && typeof window !== "undefined") {
|
||||
window.clearTimeout(handle);
|
||||
}
|
||||
timeoutsRef.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => {
|
||||
const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info";
|
||||
|
||||
setToasts((prev) => {
|
||||
const withoutExisting = prev.filter((toast) => toast.id !== toastId);
|
||||
return [
|
||||
...withoutExisting,
|
||||
{
|
||||
id: toastId,
|
||||
title,
|
||||
description,
|
||||
variant: normalizedVariant
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
if (duration !== null && duration !== Infinity && typeof window !== "undefined") {
|
||||
const timeoutHandle = window.setTimeout(() => {
|
||||
dismissToast(toastId);
|
||||
}, duration);
|
||||
|
||||
const existingHandle = timeoutsRef.current.get(toastId);
|
||||
if (existingHandle) {
|
||||
window.clearTimeout(existingHandle);
|
||||
}
|
||||
timeoutsRef.current.set(toastId, timeoutHandle);
|
||||
}
|
||||
|
||||
return toastId;
|
||||
}, [dismissToast]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window === "undefined") return;
|
||||
timeoutsRef.current.forEach((handle) => window.clearTimeout(handle));
|
||||
timeoutsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
|
||||
{toasts.map((toast) => {
|
||||
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
|
||||
<div className="flex-1">
|
||||
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
|
||||
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -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,235 @@
|
||||
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 { ServerDetail } from "@/pages/dashboard/serverDetail.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>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="maintenance/servers/:serverId"
|
||||
element={
|
||||
<PermissionGate permission="maintenance-server-manage" requiredLevel="read">
|
||||
<ServerDetail />
|
||||
</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,13 +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 './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>
|
||||
<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,9 @@
|
||||
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";
|
||||
export * from "@/pages/dashboard/serverDetail";
|
||||
@@ -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,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,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
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;
|
||||