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
|
||||||
backend/data/stackpulse.db-shm
|
backend/data/stackpulse.db-shm
|
||||||
backend/data/stackpulse.db-wal
|
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
|
FROM node:20-alpine AS runtime
|
||||||
|
|
||||||
|
#Install SSH-Client
|
||||||
|
RUN apk add --no-cache openssh-client
|
||||||
|
|
||||||
# Arbeitsverzeichnis
|
# Arbeitsverzeichnis
|
||||||
WORKDIR /app/backend
|
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** 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)**.
|
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
||||||
|
|
||||||
Ziel:
|
Ziel:
|
||||||
@@ -13,7 +16,7 @@ Ziel:
|
|||||||
## 🚀 Features & Roadmap
|
## 🚀 Features & Roadmap
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>✅ v0.1.0 – Initial Release</summary>
|
<summary>✅ v0.1 – Initial Release</summary>
|
||||||
|
|
||||||
- Projektstruktur mit Frontend & Backend
|
- Projektstruktur mit Frontend & Backend
|
||||||
- Lokales Startskript (`scripts/start-dev.sh`)
|
- Lokales Startskript (`scripts/start-dev.sh`)
|
||||||
@@ -24,8 +27,8 @@ Ziel:
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details open>
|
<details>
|
||||||
<summary>🟡 v0.2.0 – In Entwicklung</summary>
|
<summary>✅ v0.2 – Release</summary>
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- [x] Anbindung einer SQLite-Datenbank
|
- [x] Anbindung einer SQLite-Datenbank
|
||||||
@@ -45,15 +48,85 @@ Ziel:
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>🔮 Geplante Features (v0.3+)</summary>
|
<summary>✅ v0.3 – Release</summary>
|
||||||
|
|
||||||
- Notifications (z. B. via Webhooks oder Mail)
|
### Backend
|
||||||
- Authentifizierung & Benutzerverwaltung
|
- [x] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
|
||||||
- Monitoring (Status, CPU/RAM)
|
- [x] Erweiterung der Logs
|
||||||
- Verbesserte UI/UX
|
- [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>
|
||||||
|
|
||||||
|
<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
|
```bash
|
||||||
stackpulse/
|
stackpulse/
|
||||||
├── backend/ # Node.js Backend mit Express
|
├── backend/ # Node.js Backend mit Express
|
||||||
|
│ └── data # SQlite Datenbank
|
||||||
|
│ └── db # Datenbank-Integration
|
||||||
├── frontend/ # React Frontend mit Tailwind
|
├── frontend/ # React Frontend mit Tailwind
|
||||||
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
|
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
|
||||||
│ └── start-dev.sh #Skript für den lokalen Start
|
│ └── start-dev.sh #Skript für den lokalen Start
|
||||||
@@ -88,21 +163,29 @@ stackpulse/
|
|||||||
|
|
||||||
### Mit Compose starten
|
### Mit Compose starten
|
||||||
```bash
|
```bash
|
||||||
version: "2.4"
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
stackpulse:
|
app:
|
||||||
container_name: stackpulse
|
build:
|
||||||
image: ghcr.io/mboehmlaender/stackpulse
|
context: .
|
||||||
ports:
|
dockerfile: Dockerfile
|
||||||
- '4001:4001'
|
ports:
|
||||||
environment:
|
- "4001:4001"
|
||||||
- PORTAINER_URL=Deine_Portainer_Adresse/
|
volumes:
|
||||||
- PORTAINER_API_KEY=Dein_Portainer_API_Key
|
- stackpulse_data:/app/backend/data
|
||||||
- PORTAINER_ENDPOINT_ID=Deine_Portainer_Endpoint_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:
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Voraussetzungen
|
## 📋 Voraussetzungen
|
||||||
|
|
||||||
- Node.js >= 20
|
- 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_URL=Your_Portainer_Server_Adress (optional)
|
||||||
PORTAINER_API_KEY=Your_Portainer_API_Key
|
PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
|
||||||
PORTAINER_ENDPOINT_ID=Your_Portainer_Endpoint_ID
|
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
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "🔧 Starte Migration (idempotent)..."
|
echo "🔧 Überprüfe Datenbankschema..."
|
||||||
node db/migrate.js
|
node db/ensure.js
|
||||||
|
|
||||||
echo "✅ Migration abgeschlossen. Starte Anwendung..."
|
echo "✅ Schema-Abgleich abgeschlossen. Starte Anwendung..."
|
||||||
exec "$@"
|
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",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@scure/bip39": "^2.0.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"better-sqlite3": "^9.6.0",
|
"better-sqlite3": "^9.6.0",
|
||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.7",
|
||||||
@@ -59,6 +60,17 @@
|
|||||||
"url": "https://opencollective.com/js-sdsl"
|
"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": {
|
"node_modules/@protobufjs/aspromise": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
"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": {
|
"node_modules/@socket.io/component-emitter": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"migrate": "node db/migrate.js"
|
"migrate": "node db/ensure.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.7",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express": "^4.18.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>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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>
|
<!-- Nepcha Analytics (nepcha.com) -->
|
||||||
<link rel="stylesheet" href="/assets/index-0842cd3a.css">
|
<!-- 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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"
|
- "4001:4001"
|
||||||
volumes:
|
volumes:
|
||||||
- stackpulse_data:/app/backend/data
|
- 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
|
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:
|
volumes:
|
||||||
stackpulse_data:
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>StackPulse</title>
|
<title>Material Tailwind Dashboard React | By Creative Tim</title>
|
||||||
<link rel="stylesheet" href="/src/index.css" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,23 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpulse-frontend",
|
"name": "material-tailwind-dashboard-react",
|
||||||
"version": "0.1.0",
|
"private": true,
|
||||||
|
"version": "2.1.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.5",
|
"axios": "1.7.5",
|
||||||
"react": "^18.2.0",
|
"socket.io-client": "4.8.1",
|
||||||
"react-dom": "^18.2.0",
|
"@heroicons/react": "2.0.18",
|
||||||
"react-router-dom": "^6.26.2",
|
"@material-tailwind/react": "2.1.4",
|
||||||
"socket.io-client": "^4.8.1"
|
"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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@types/react": "18.2.31",
|
||||||
"autoprefixer": "^10.4.21",
|
"@types/react-dom": "18.2.14",
|
||||||
"postcss": "^8.5.6",
|
"@vitejs/plugin-react": "4.1.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"autoprefixer": "10.4.16",
|
||||||
"vite": "^4.4.9"
|
"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 { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { NavLink, Route, Routes } from "react-router-dom";
|
import { Dashboard, Auth } from "@/layouts";
|
||||||
import Stacks from "./Stacks.jsx";
|
import Setup from "@/pages/setup/setup";
|
||||||
import Logs from "./Logs.jsx";
|
|
||||||
import logo from "./assets/images/stackpulse.png";
|
|
||||||
|
|
||||||
const navLinkBase =
|
function App() {
|
||||||
"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() {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white">
|
<Routes>
|
||||||
<header className="bg-gray-800 shadow-md">
|
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
<Route path="/auth/*" element={<Auth />} />
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
<Route path="/setup" element={<Setup />} />
|
||||||
<div className="flex flex-col items-end gap-1">
|
<Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
|
||||||
<span className="text-xs text-gray-500">v0.2</span>
|
</Routes>
|
||||||
<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 />} />
|
|
||||||
</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 React from "react";
|
||||||
|
import axios from "axios";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App.jsx";
|
import { ThemeProvider } from "@material-tailwind/react";
|
||||||
import './index.css'; // Tailwind CSS
|
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(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<MaintenanceProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<PageProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<MaterialTailwindControllerProvider>
|
||||||
|
<App />
|
||||||
|
</MaterialTailwindControllerProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</PageProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</MaintenanceProvider>
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</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;
|
||||||