Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a9dbbba9f | |||
| 81b7613c73 | |||
| fc778da204 | |||
| d88edcd6ef | |||
| 71ab82b592 | |||
| 75c852625c | |||
| 3140a024e6 | |||
| ae3a3f93e8 | |||
| 36a6e2b66c | |||
| 4eea4b23bd | |||
| e96cd63f33 | |||
| d65585b821 | |||
| a76567969a | |||
| f0b1ca4b68 | |||
| fa19dbe504 | |||
| 29d40c6f10 | |||
| 9133928401 | |||
| 5e40b3105c | |||
| 354e2844d1 | |||
| 597d7ffb6f | |||
| 7072a21ed8 | |||
| 754b023435 | |||
| ac57b932c2 | |||
| e607310689 | |||
| 1f8906bc3d | |||
| d6ddf12c04 | |||
| 994263dc45 | |||
| 23d46e7c40 | |||
| d8e0bdc814 | |||
| f45a2525e6 | |||
| 5852fc6616 | |||
| aaf9afdc9f | |||
| 98a6df5081 | |||
| 2c3379026b | |||
| 84be8e5828 | |||
| 1a5de83d50 | |||
| 476b09499a | |||
| 974462e983 | |||
| 749f04c2c4 | |||
| 7de4e85a38 | |||
| 4266adc853 | |||
| f7be617e1d | |||
| 4dd2ba9d9d | |||
| f8209c3712 | |||
| d776b80fe0 | |||
| dd89d8388e | |||
| f5ec13701b | |||
| bbdbf0176e | |||
| 2fc90639f7 | |||
| 5cf3d8fe70 | |||
| 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 | |||
| d349d31d02 | |||
| 4241bb3114 | |||
| 08c37245e1 | |||
| ef33a0d669 | |||
| ec2d998bc5 | |||
| 7cb1201b51 | |||
| 8fa5ffc49c | |||
| e10d0dec1c | |||
| 5483ee9092 | |||
| 02fe83d65c | |||
| d99ffc1488 | |||
| 2b051d5085 | |||
| f46ba1c820 | |||
| f14c3aa6dd | |||
| 044164a440 | |||
| c37b046e39 | |||
| cc7d80e569 | |||
| deb05814e4 | |||
| 2b284195fb | |||
| 24c98aff3e | |||
| b33e9e587b | |||
| b1fdd074ec | |||
| 3a961270d7 | |||
| 0a65f3e60f | |||
| 38b96da4e7 | |||
| 241a509a15 | |||
| 520e36fe85 | |||
| 1f248e3920 | |||
| ba0905e2b9 | |||
| 876acfa763 | |||
| ccd7fffe5c | |||
| ee7ccd0a39 | |||
| 6687a86802 | |||
| c7d63a28c4 | |||
| d91655aee9 | |||
| 8001a2be37 | |||
| 7f241d352d | |||
| 32af9d2584 | |||
| 55a83267a6 | |||
| dc56f0cbeb | |||
| 66d975ae75 | |||
| 571b185b9b | |||
| 8c59fc5445 | |||
| 6bf4f7aab4 | |||
| cd7933ebc7 | |||
| 3b0bd3c632 | |||
| 281aeb40a6 | |||
| 429721d9da | |||
| 47f632f8fc | |||
| 2e7ebfe06d | |||
| 03b3766a22 | |||
| d1adcb1188 | |||
| e38269e23e | |||
| 81f0e7eaf0 | |||
| 7980837eaf | |||
| 31b506ceb8 | |||
| 44e3754fcb | |||
| 73d225cc79 | |||
| a1e20f188e | |||
| 421e6e47dd | |||
| 79e420d3e3 | |||
| ee3b907cd6 | |||
| 960832af6b | |||
| 3489ea55a2 | |||
| ca148247cb | |||
| bff6521658 | |||
| ef8920cb26 | |||
| 279ad3ffd1 |
+1
-1
@@ -11,4 +11,4 @@ frontend/dist
|
|||||||
backend/.env
|
backend/.env
|
||||||
backend/node_modules
|
backend/node_modules
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
|
backend/data/*.db*
|
||||||
|
|||||||
+24
-1
@@ -11,4 +11,27 @@ backend/node_modules
|
|||||||
backend/.env
|
backend/.env
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
scripts/docker-release.sh
|
devscripts
|
||||||
|
backend/data/stackpulse.db
|
||||||
|
backend/data/stackpulse.db-shm
|
||||||
|
backend/data/stackpulse.db-wal
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
+11
@@ -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
|
||||||
|
|
||||||
@@ -31,10 +34,17 @@ RUN npm ci --only=production
|
|||||||
|
|
||||||
# Backend-Code kopieren
|
# Backend-Code kopieren
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
|
COPY backend/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
|
||||||
# public leeren
|
# public leeren
|
||||||
RUN rm -rf ./public/*
|
RUN rm -rf ./public/*
|
||||||
|
|
||||||
|
# frische Datenbank vorbereiten
|
||||||
|
RUN rm -rf ./data && mkdir -p ./data && chown node:node ./data
|
||||||
|
|
||||||
|
# EntryPoint vorbereiten
|
||||||
|
RUN chmod +x ./docker-entrypoint.sh && chown node:node ./docker-entrypoint.sh
|
||||||
|
|
||||||
# Inhalt von dist inklusive Unterordner direkt nach public kopieren
|
# Inhalt von dist inklusive Unterordner direkt nach public kopieren
|
||||||
COPY --from=frontend-build /app/frontend/dist/. ./public/
|
COPY --from=frontend-build /app/frontend/dist/. ./public/
|
||||||
|
|
||||||
@@ -50,4 +60,5 @@ EXPOSE 5173
|
|||||||
USER node
|
USER node
|
||||||
|
|
||||||
# Container startet das Backend (liefert statisches Frontend)
|
# Container startet das Backend (liefert statisches Frontend)
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
Aktuell funktioniert StackPulse nur mit der Business-Edition von Portainer. Die Communitiy-Edition wird in einem späteren Release implementiert!
|
||||||
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
||||||
|
|
||||||
Ziel:
|
Ziel:
|
||||||
@@ -10,14 +13,121 @@ Ziel:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Features (0.1 Roadmap)
|
## 🚀 Features & Roadmap
|
||||||
|
|
||||||
- [x] Projektstruktur mit Frontend & Backend
|
<details>
|
||||||
- [x] Lokales Startskript (`scripts/start-dev.sh`)
|
<summary>✅ v0.1 – Initial Release</summary>
|
||||||
- [x] Frontend zeigt Stacks an (über Backend)
|
|
||||||
- [x] API-Verbindung zu Portainer
|
- Projektstruktur mit Frontend & Backend
|
||||||
- [x] Stack Redeploy
|
- Lokales Startskript (`scripts/start-dev.sh`)
|
||||||
- [x] Docker Image im ghcr zur Verfügung stellen
|
- Frontend zeigt Stacks an (über Backend)
|
||||||
|
- API-Verbindung zu Portainer
|
||||||
|
- Stack Redeploy
|
||||||
|
- Bereitstellung eines Docker Images über GHCR
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v0.2 – Release</summary>
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [x] Anbindung einer SQLite-Datenbank
|
||||||
|
- [x] Logging der Redeploy-Aktionen in SQLite speichern
|
||||||
|
- [x] API-Endpunkte für Log-Abfragen
|
||||||
|
- [x] Funktionen für Pagination, Löschen und Export
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [x] Anzeige der Logs (inkl. Statusfarben)
|
||||||
|
- [x] UI-Komponenten für Log-Details
|
||||||
|
- [x] Filterfunktionen für die Logs
|
||||||
|
- [x] Pagination, Lösch- und Exportanzeigen
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- [x] Selektive Auswahl: einzelne Stacks oder Services neu deployen
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v0.3 – Release</summary>
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [x] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
|
||||||
|
- [x] Erweiterung der Logs
|
||||||
|
- [x] API für Filter & Suche (Stacks nach Name/Status abrufen)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [x] Filter: Stacks nach Name oder Status durchsuchen
|
||||||
|
- [x] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast)
|
||||||
|
- [x] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- [x] Frontend-Filter für schnellere Navigation bei vielen Stacks
|
||||||
|
- [x] Echtzeit-Feedback im UI (Notifications)
|
||||||
|
- [x] Datenkonsistenz sichern: keine doppelten Stack-IDs mehr
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>✅ v0.4 – Release</summary>
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- keine Änderungen
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [x] GUI komplett überarbeitet
|
||||||
|
- [x] Umstellung auf Material Tailwind
|
||||||
|
- [x] Dashboard von Creative Tim als neues Frontend
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- [x] Neue, übersichtliche Oberfläche im Material Design
|
||||||
|
- [x] leicht erweiterbar durch vorgefertigte Komponenten
|
||||||
|
- [x] kleine Bugfixes
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>✅ v0.5 – Release</summary>
|
||||||
|
|
||||||
|
### 🛠️ Backend
|
||||||
|
- [x] Neue API-Endpunkte für Logging, Benutzer- und Gruppenverwaltung sowie Superuser-Registrierung
|
||||||
|
- [x] Authentifizierung und Log-In
|
||||||
|
- [x] Erweiterte Protokollierung und Datenbank-Migration
|
||||||
|
- [x] Präzisere Steuerung der globalen Benutzerrechte
|
||||||
|
|
||||||
|
### 💻 Frontend
|
||||||
|
- [x] Neue Log-In-Seite
|
||||||
|
- [x] Benutzer- und Gruppenverwaltung mit granularen Rechteeinstellungen
|
||||||
|
- [x] Erweiterte Log-Ausgabe mit Filtern und Suchoptionen
|
||||||
|
- [x] Konsolidierte und vereinheitlichte Ansichten im Dashboard
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
- [x] Vollständiges Authentifizierungs- und Berechtigungssystem
|
||||||
|
- [x] Erweiterte Logs in allen Bereichen
|
||||||
|
- [x] Speicherung von Server- und Superuser-Einstellungen in der Datenbank
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
|
||||||
|
<summary>🟡 v0.6 – In Entwicklung</summary>
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>🔮 Geplante Features (v0.6+)</summary>
|
||||||
|
|
||||||
|
- Notifications (z. B. via Webhooks oder Mail)
|
||||||
|
- Monitoring (Status, CPU/RAM)
|
||||||
|
- Verbesserte UI/UX
|
||||||
|
- Erweiterte Filterungen udn Sortierungen
|
||||||
|
- Multi-Server Verwaltung
|
||||||
|
- Integration Community Edition
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,13 +136,11 @@ 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)
|
||||||
│ ├── git-push.sh #nicht relevant
|
│ └── start-dev.sh #Skript für den lokalen Start
|
||||||
│ └── merge-dev-to-master.sh #nicht relevant
|
|
||||||
│ └── merge-feature-to-dev.sh #nicht relevant
|
|
||||||
│ └── start-dev.sh #Skript für den lokalen Start
|
|
||||||
│ └── switch-branch.sh #nicht relevant
|
|
||||||
├── Dockerfile # Multi-Stage Build für Frontend + Backend
|
├── Dockerfile # Multi-Stage Build für Frontend + Backend
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -56,21 +164,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
|
||||||
|
|||||||
Binary file not shown.
|
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 @@
|
|||||||
|
stackpulse.db
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
-- Stackpulse database schema blueprint
|
||||||
|
-- This document captures the expected structure of all tables, indexes and core seed requirements.
|
||||||
|
|
||||||
|
CREATE TABLE event_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
event_type TEXT,
|
||||||
|
action TEXT,
|
||||||
|
status TEXT,
|
||||||
|
severity TEXT,
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id TEXT,
|
||||||
|
entity_name TEXT,
|
||||||
|
actor_type TEXT,
|
||||||
|
actor_id TEXT,
|
||||||
|
actor_name TEXT,
|
||||||
|
source TEXT,
|
||||||
|
context_type TEXT,
|
||||||
|
context_id TEXT,
|
||||||
|
context_label TEXT,
|
||||||
|
message TEXT,
|
||||||
|
metadata TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_event_logs_timestamp ON event_logs (timestamp DESC);
|
||||||
|
CREATE INDEX idx_event_logs_category ON event_logs (category);
|
||||||
|
CREATE INDEX idx_event_logs_event_type ON event_logs (event_type);
|
||||||
|
CREATE INDEX idx_event_logs_entity ON event_logs (entity_type, entity_id);
|
||||||
|
CREATE INDEX idx_event_logs_context ON event_logs (context_type, context_id);
|
||||||
|
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
password_salt TEXT,
|
||||||
|
avatar_color TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_login DATETIME,
|
||||||
|
security_phrase_content TEXT,
|
||||||
|
security_phrase_iv TEXT,
|
||||||
|
security_phrase_tag TEXT,
|
||||||
|
security_phrase_downloaded_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
avatar_color TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_group_memberships (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, group_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE permissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_group_permissions (
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
permission_id INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (group_id, permission_id),
|
||||||
|
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE permission_sections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_navigation_flag INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE permission_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
section_id INTEGER NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(section_id, key),
|
||||||
|
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE permission_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
section_id INTEGER NOT NULL,
|
||||||
|
group_id INTEGER,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
default_level TEXT NOT NULL,
|
||||||
|
available_levels TEXT NOT NULL,
|
||||||
|
is_required INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE permission_dependencies (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
permission_id INTEGER NOT NULL,
|
||||||
|
depends_on_permission_id INTEGER NOT NULL,
|
||||||
|
required_level TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (depends_on_permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(permission_id, depends_on_permission_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_permission_dependencies_required
|
||||||
|
ON permission_dependencies (permission_id, depends_on_permission_id);
|
||||||
|
|
||||||
|
CREATE TABLE group_permission_values (
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
permission_id INTEGER NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
effective_level TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (group_id, permission_id),
|
||||||
|
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_server_permission_overrides (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
permission_id INTEGER NOT NULL,
|
||||||
|
change_type TEXT NOT NULL CHECK (change_type IN ('ADD', 'REMOVE')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, server_id, permission_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_user_server_permission_overrides_user_server
|
||||||
|
ON user_server_permission_overrides (user_id, server_id);
|
||||||
|
|
||||||
|
CREATE TABLE server_api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL UNIQUE,
|
||||||
|
key_cipher TEXT NOT NULL,
|
||||||
|
key_iv TEXT NOT NULL,
|
||||||
|
key_tag TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_settings (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
setting_key TEXT NOT NULL,
|
||||||
|
setting_value TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, setting_key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Core seed expectations (keys only – maintained via schemaEnsure)
|
||||||
|
-- permission_sections: stacks, logs, users, user-groups, maintenance
|
||||||
|
-- permission_items: includes users-security-phrase (available_levels ["full","none"])
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ensureDatabaseSchema } from './schemaEnsure.js';
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
ensureDatabaseSchema();
|
||||||
|
console.log('✅ Datenbankschema abgeglichen.');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const dataDir = path.join(__dirname, '..', 'data');
|
||||||
|
const dbFile = path.join(dataDir, 'stackpulse.db');
|
||||||
|
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(dbFile);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
export { db, dbFile };
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { db } from './index.js';
|
||||||
|
|
||||||
|
const BLUEPRINT_FILENAME = 'dbs';
|
||||||
|
const PERMISSION_BLUEPRINT = [
|
||||||
|
{
|
||||||
|
key: 'stacks',
|
||||||
|
title: 'Stacks',
|
||||||
|
sortOrder: 0,
|
||||||
|
hasNavigation: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'stacks-redeploy-single',
|
||||||
|
label: 'Redeploy einzeln',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stacks-redeploy-selection',
|
||||||
|
label: 'Redeploy Auswahl',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stacks-redeploy-all',
|
||||||
|
label: 'Redeploy Alle',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs',
|
||||||
|
title: 'Logs',
|
||||||
|
sortOrder: 1,
|
||||||
|
hasNavigation: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'logs-access',
|
||||||
|
label: 'Bereich & Navigation',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 1,
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs-export',
|
||||||
|
label: 'Logs Exportieren',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs-delete',
|
||||||
|
label: 'Logs löschen',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
title: 'Benutzer',
|
||||||
|
sortOrder: 2,
|
||||||
|
hasNavigation: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'users-access',
|
||||||
|
label: 'Bereich & Navigation',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 1,
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users-edit',
|
||||||
|
label: 'Benutzer bearbeiten',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'read',
|
||||||
|
levels: ['full', 'read'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users-delete',
|
||||||
|
label: 'Benutzer löschen',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users-security-phrase',
|
||||||
|
label: 'Sicherheitsschlüssel',
|
||||||
|
sortOrder: 3,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user-groups',
|
||||||
|
title: 'Benutzergruppen',
|
||||||
|
sortOrder: 3,
|
||||||
|
hasNavigation: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'user-groups-access',
|
||||||
|
label: 'Bereich & Navigation',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 1,
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user-groups-edit',
|
||||||
|
label: 'Benutzergruppen bearbeiten',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'read',
|
||||||
|
levels: ['full', 'read'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user-groups-delete',
|
||||||
|
label: 'Benutzergruppen löschen',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance',
|
||||||
|
title: 'Wartung',
|
||||||
|
sortOrder: 4,
|
||||||
|
hasNavigation: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-access',
|
||||||
|
label: 'Bereich & Navigation',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 1,
|
||||||
|
dependencies: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-server-group',
|
||||||
|
title: 'Server',
|
||||||
|
sortOrder: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-server-manage',
|
||||||
|
label: 'Server-Sektion',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'read', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-server-delete',
|
||||||
|
label: 'Server löschen',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||||
|
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-superuser-group',
|
||||||
|
title: 'Superuser',
|
||||||
|
sortOrder: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-superuser-delete',
|
||||||
|
label: 'Superuser löschen',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-portainer-group',
|
||||||
|
title: 'Portainer',
|
||||||
|
sortOrder: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-portainer',
|
||||||
|
label: 'Portainer-Sektion',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-ssh-update',
|
||||||
|
label: 'SSH/Update-Skript',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'read', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||||
|
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-update',
|
||||||
|
label: 'Update durchführen',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
||||||
|
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-duplicates-group',
|
||||||
|
title: 'Doppelte Stacks',
|
||||||
|
sortOrder: 3,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-duplicates',
|
||||||
|
label: 'Doppelte Stacks',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'read', 'none'],
|
||||||
|
dependencies: [
|
||||||
|
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const TABLE_REGEX = /^CREATE TABLE\s+([^\s(]+)\s*\(/i;
|
||||||
|
const INDEX_REGEX = /^CREATE INDEX\s+([^\s(]+)\s+ON\s+([^\s(]+)\s*\(/i;
|
||||||
|
const COLUMN_SKIP_PREFIXES = ['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK', 'CONSTRAINT'];
|
||||||
|
|
||||||
|
const normalizeIdentifier = (value) => value.replace(/`|"/g, '').trim();
|
||||||
|
|
||||||
|
const loadBlueprintStatements = () => {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const filePath = path.join(__dirname, BLUEPRINT_FILENAME);
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const cleanContent = content
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !line.startsWith('--'))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return cleanContent
|
||||||
|
.split(';')
|
||||||
|
.map((statement) => statement.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTableDefinitions = (statements) => {
|
||||||
|
const tables = new Map();
|
||||||
|
|
||||||
|
statements.forEach((statement) => {
|
||||||
|
const match = statement.match(TABLE_REGEX);
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableName = normalizeIdentifier(match[1]);
|
||||||
|
const body = statement.slice(statement.indexOf('(') + 1, statement.lastIndexOf(')'));
|
||||||
|
const lines = body
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const columns = [];
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const cleaned = line.replace(/,$/, '');
|
||||||
|
const upper = cleaned.toUpperCase();
|
||||||
|
if (COLUMN_SKIP_PREFIXES.some((prefix) => upper.startsWith(prefix))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [rawName, ...restParts] = cleaned.split(/\s+/);
|
||||||
|
if (!rawName || restParts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columnName = normalizeIdentifier(rawName);
|
||||||
|
const definition = restParts.join(' ');
|
||||||
|
columns.push({ name: columnName, definition });
|
||||||
|
});
|
||||||
|
|
||||||
|
tables.set(tableName, {
|
||||||
|
createStatement: statement,
|
||||||
|
columns
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return tables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseIndexStatements = (statements) => {
|
||||||
|
const indexes = [];
|
||||||
|
statements.forEach((statement) => {
|
||||||
|
const match = statement.match(INDEX_REGEX);
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const indexName = normalizeIdentifier(match[1]);
|
||||||
|
indexes.push({
|
||||||
|
name: indexName,
|
||||||
|
statement
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return indexes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableExists = (tableName) => {
|
||||||
|
const result = db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`)
|
||||||
|
.get(tableName);
|
||||||
|
return Boolean(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropDeprecatedArtifacts = () => {
|
||||||
|
const deprecatedTables = ['user_endpoint_permission_overrides', 'endpoints'];
|
||||||
|
deprecatedTables.forEach((table) => {
|
||||||
|
if (tableExists(table)) {
|
||||||
|
db.exec(`DROP TABLE IF EXISTS ${table}`);
|
||||||
|
console.log(`ℹ️ Veraltete Tabelle ${table} entfernt`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExistingColumns = (tableName) => {
|
||||||
|
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||||
|
return new Set(pragma.map((column) => column.name));
|
||||||
|
};
|
||||||
|
|
||||||
|
const indexExists = (indexName) => {
|
||||||
|
const result = db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1`)
|
||||||
|
.get(indexName);
|
||||||
|
return Boolean(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureTablesAndColumns = (tables) => {
|
||||||
|
tables.forEach(({ createStatement, columns }, tableName) => {
|
||||||
|
if (!tableExists(tableName)) {
|
||||||
|
const statementWithGuard = createStatement.replace(/^CREATE TABLE/i, 'CREATE TABLE IF NOT EXISTS');
|
||||||
|
db.exec(`${statementWithGuard};`);
|
||||||
|
console.log(`ℹ️ Tabelle ${tableName} angelegt`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingColumns = getExistingColumns(tableName);
|
||||||
|
const missingColumns = columns.filter((column) => !existingColumns.has(column.name));
|
||||||
|
missingColumns.forEach((column) => {
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.definition};`);
|
||||||
|
console.log(`ℹ️ Column ${column.name} added to ${tableName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Column ${column.name} could not be added to ${tableName}: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureIndexes = (indexes) => {
|
||||||
|
indexes.forEach(({ name, statement }) => {
|
||||||
|
if (indexExists(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statementWithGuard = statement.replace(/^CREATE INDEX/i, 'CREATE INDEX IF NOT EXISTS');
|
||||||
|
db.exec(`${statementWithGuard};`);
|
||||||
|
console.log(`ℹ️ Index ${name} angelegt`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePermissionSeeds = () => {
|
||||||
|
const selectSection = db.prepare(`
|
||||||
|
SELECT id, title, sort_order, has_navigation_flag
|
||||||
|
FROM permission_sections
|
||||||
|
WHERE key = ?
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const insertSection = db.prepare(`
|
||||||
|
INSERT INTO permission_sections (key, title, sort_order, has_navigation_flag, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`);
|
||||||
|
const updateSection = db.prepare(`
|
||||||
|
UPDATE permission_sections
|
||||||
|
SET title = ?, sort_order = ?, has_navigation_flag = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectGroup = db.prepare(`
|
||||||
|
SELECT id, title, sort_order
|
||||||
|
FROM permission_groups
|
||||||
|
WHERE section_id = ? AND key = ?
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const insertGroup = db.prepare(`
|
||||||
|
INSERT INTO permission_groups (section_id, key, title, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`);
|
||||||
|
const updateGroup = db.prepare(`
|
||||||
|
UPDATE permission_groups
|
||||||
|
SET title = ?, sort_order = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectItem = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
section_id,
|
||||||
|
group_id,
|
||||||
|
label,
|
||||||
|
sort_order,
|
||||||
|
default_level,
|
||||||
|
available_levels,
|
||||||
|
is_required
|
||||||
|
FROM permission_items
|
||||||
|
WHERE key = ?
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const insertItem = db.prepare(`
|
||||||
|
INSERT INTO permission_items (
|
||||||
|
section_id,
|
||||||
|
group_id,
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
sort_order,
|
||||||
|
default_level,
|
||||||
|
available_levels,
|
||||||
|
is_required,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const updateItem = db.prepare(`
|
||||||
|
UPDATE permission_items
|
||||||
|
SET
|
||||||
|
section_id = ?,
|
||||||
|
group_id = ?,
|
||||||
|
label = ?,
|
||||||
|
sort_order = ?,
|
||||||
|
default_level = ?,
|
||||||
|
available_levels = ?,
|
||||||
|
is_required = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectDependency = db.prepare(`
|
||||||
|
SELECT id, required_level
|
||||||
|
FROM permission_dependencies
|
||||||
|
WHERE permission_id = ? AND depends_on_permission_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const insertDependency = db.prepare(`
|
||||||
|
INSERT INTO permission_dependencies (
|
||||||
|
permission_id,
|
||||||
|
depends_on_permission_id,
|
||||||
|
required_level,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const updateDependency = db.prepare(`
|
||||||
|
UPDATE permission_dependencies
|
||||||
|
SET required_level = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const itemKeyToId = new Map();
|
||||||
|
|
||||||
|
PERMISSION_BLUEPRINT.forEach((section, sectionIndex) => {
|
||||||
|
const existingSection = selectSection.get(section.key);
|
||||||
|
let sectionId;
|
||||||
|
if (!existingSection) {
|
||||||
|
const result = insertSection.run(
|
||||||
|
section.key,
|
||||||
|
section.title,
|
||||||
|
section.sortOrder ?? sectionIndex,
|
||||||
|
section.hasNavigation ? 1 : 0
|
||||||
|
);
|
||||||
|
sectionId = Number(result.lastInsertRowid);
|
||||||
|
console.log(`ℹ️ Berechtigungs-Sektion ${section.key} angelegt`);
|
||||||
|
} else {
|
||||||
|
updateSection.run(
|
||||||
|
section.title,
|
||||||
|
section.sortOrder ?? sectionIndex,
|
||||||
|
section.hasNavigation ? 1 : 0,
|
||||||
|
existingSection.id
|
||||||
|
);
|
||||||
|
sectionId = existingSection.id;
|
||||||
|
if (
|
||||||
|
existingSection.title !== section.title ||
|
||||||
|
(existingSection.sort_order ?? sectionIndex) !== (section.sortOrder ?? sectionIndex) ||
|
||||||
|
Number(existingSection.has_navigation_flag) !== (section.hasNavigation ? 1 : 0)
|
||||||
|
) {
|
||||||
|
console.log(`ℹ️ Berechtigungs-Sektion ${section.key} aktualisiert`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||||
|
sectionItems.forEach((item, itemIdx) => {
|
||||||
|
const existingItem = selectItem.get(item.key);
|
||||||
|
const sortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
|
||||||
|
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
||||||
|
? item.levels
|
||||||
|
: ['full', 'read', 'none'];
|
||||||
|
const availableLevels = JSON.stringify(levelOptions);
|
||||||
|
const isRequired = item.isRequired ? 1 : 0;
|
||||||
|
if (!existingItem) {
|
||||||
|
const result = insertItem.run(
|
||||||
|
sectionId,
|
||||||
|
null,
|
||||||
|
item.key,
|
||||||
|
item.label,
|
||||||
|
sortOrder,
|
||||||
|
item.defaultLevel || 'none',
|
||||||
|
availableLevels,
|
||||||
|
isRequired
|
||||||
|
);
|
||||||
|
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
||||||
|
console.log(`ℹ️ Berechtigung ${item.key} angelegt`);
|
||||||
|
} else {
|
||||||
|
const hasChanges =
|
||||||
|
existingItem.section_id !== sectionId ||
|
||||||
|
existingItem.group_id !== null ||
|
||||||
|
existingItem.label !== item.label ||
|
||||||
|
(existingItem.sort_order ?? sortOrder) !== sortOrder ||
|
||||||
|
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
||||||
|
existingItem.available_levels !== availableLevels ||
|
||||||
|
Number(existingItem.is_required) !== isRequired;
|
||||||
|
updateItem.run(
|
||||||
|
sectionId,
|
||||||
|
null,
|
||||||
|
item.label,
|
||||||
|
sortOrder,
|
||||||
|
item.defaultLevel || 'none',
|
||||||
|
availableLevels,
|
||||||
|
isRequired,
|
||||||
|
existingItem.id
|
||||||
|
);
|
||||||
|
itemKeyToId.set(item.key, existingItem.id);
|
||||||
|
if (hasChanges) {
|
||||||
|
console.log(`ℹ️ Berechtigung ${item.key} aktualisiert`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||||
|
sectionGroups.forEach((group, groupIdx) => {
|
||||||
|
const existingGroup = selectGroup.get(sectionId, group.key);
|
||||||
|
const sortOrder = typeof group.sortOrder === 'number' ? group.sortOrder : groupIdx;
|
||||||
|
let groupId;
|
||||||
|
if (!existingGroup) {
|
||||||
|
const result = insertGroup.run(sectionId, group.key, group.title, sortOrder);
|
||||||
|
groupId = Number(result.lastInsertRowid);
|
||||||
|
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} angelegt`);
|
||||||
|
} else {
|
||||||
|
const hasGroupChanges =
|
||||||
|
existingGroup.title !== group.title ||
|
||||||
|
(existingGroup.sort_order ?? sortOrder) !== sortOrder;
|
||||||
|
updateGroup.run(group.title, sortOrder, existingGroup.id);
|
||||||
|
groupId = existingGroup.id;
|
||||||
|
if (hasGroupChanges) {
|
||||||
|
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} aktualisiert`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||||
|
groupItems.forEach((item, itemIdx) => {
|
||||||
|
const existingItem = selectItem.get(item.key);
|
||||||
|
const itemSortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
|
||||||
|
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
||||||
|
? item.levels
|
||||||
|
: ['full', 'read', 'none'];
|
||||||
|
const availableLevels = JSON.stringify(levelOptions);
|
||||||
|
const isRequired = item.isRequired ? 1 : 0;
|
||||||
|
if (!existingItem) {
|
||||||
|
const result = insertItem.run(
|
||||||
|
sectionId,
|
||||||
|
groupId,
|
||||||
|
item.key,
|
||||||
|
item.label,
|
||||||
|
itemSortOrder,
|
||||||
|
item.defaultLevel || 'none',
|
||||||
|
availableLevels,
|
||||||
|
isRequired
|
||||||
|
);
|
||||||
|
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
||||||
|
console.log(`ℹ️ Berechtigung ${item.key} in Gruppe ${group.key} angelegt`);
|
||||||
|
} else {
|
||||||
|
const hasChanges =
|
||||||
|
existingItem.section_id !== sectionId ||
|
||||||
|
existingItem.group_id !== groupId ||
|
||||||
|
existingItem.label !== item.label ||
|
||||||
|
(existingItem.sort_order ?? itemSortOrder) !== itemSortOrder ||
|
||||||
|
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
||||||
|
existingItem.available_levels !== availableLevels ||
|
||||||
|
Number(existingItem.is_required) !== isRequired;
|
||||||
|
updateItem.run(
|
||||||
|
sectionId,
|
||||||
|
groupId,
|
||||||
|
item.label,
|
||||||
|
itemSortOrder,
|
||||||
|
item.defaultLevel || 'none',
|
||||||
|
availableLevels,
|
||||||
|
isRequired,
|
||||||
|
existingItem.id
|
||||||
|
);
|
||||||
|
itemKeyToId.set(item.key, existingItem.id);
|
||||||
|
if (hasChanges) {
|
||||||
|
console.log(`ℹ️ Berechtigung ${item.key} in Gruppe ${group.key} aktualisiert`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
PERMISSION_BLUEPRINT.forEach((section) => {
|
||||||
|
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||||
|
sectionItems.forEach((item) => {
|
||||||
|
const permissionId = itemKeyToId.get(item.key);
|
||||||
|
if (!permissionId) return;
|
||||||
|
(item.dependencies || []).forEach((dependency) => {
|
||||||
|
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
||||||
|
if (!dependsId) return;
|
||||||
|
const existing = selectDependency.get(permissionId, dependsId);
|
||||||
|
const requiredLevel = dependency.requiredLevel ?? null;
|
||||||
|
if (!existing) {
|
||||||
|
insertDependency.run(permissionId, dependsId, requiredLevel);
|
||||||
|
console.log(
|
||||||
|
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
|
||||||
|
);
|
||||||
|
} else if (existing.required_level !== requiredLevel) {
|
||||||
|
updateDependency.run(requiredLevel, existing.id);
|
||||||
|
console.log(
|
||||||
|
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||||
|
sectionGroups.forEach((group) => {
|
||||||
|
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||||
|
groupItems.forEach((item) => {
|
||||||
|
const permissionId = itemKeyToId.get(item.key);
|
||||||
|
if (!permissionId) return;
|
||||||
|
(item.dependencies || []).forEach((dependency) => {
|
||||||
|
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
||||||
|
if (!dependsId) return;
|
||||||
|
const requiredLevel = dependency.requiredLevel ?? null;
|
||||||
|
const existing = selectDependency.get(permissionId, dependsId);
|
||||||
|
if (!existing) {
|
||||||
|
insertDependency.run(permissionId, dependsId, requiredLevel);
|
||||||
|
console.log(
|
||||||
|
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
|
||||||
|
);
|
||||||
|
} else if (existing.required_level !== requiredLevel) {
|
||||||
|
updateDependency.run(requiredLevel, existing.id);
|
||||||
|
console.log(
|
||||||
|
`ℹ️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureDatabaseSchema = () => {
|
||||||
|
try {
|
||||||
|
const statements = loadBlueprintStatements();
|
||||||
|
const tableDefinitions = parseTableDefinitions(statements);
|
||||||
|
const indexDefinitions = parseIndexStatements(statements);
|
||||||
|
|
||||||
|
dropDeprecatedArtifacts();
|
||||||
|
ensureTablesAndColumns(tableDefinitions);
|
||||||
|
ensureIndexes(indexDefinitions);
|
||||||
|
ensurePermissionSeeds();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔧 Überprüfe Datenbankschema..."
|
||||||
|
node db/ensure.js
|
||||||
|
|
||||||
|
echo "✅ Schema-Abgleich abgeschlossen. Starte Anwendung..."
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import {
|
||||||
|
normalizeAvatarColor,
|
||||||
|
DEFAULT_AVATAR_COLOR,
|
||||||
|
SUPERUSER_GROUP_NAME,
|
||||||
|
pickRandomAvatarColor
|
||||||
|
} from '../auth/superuser.js';
|
||||||
|
import { logEvent } from '../logging/eventLogs.js';
|
||||||
|
|
||||||
|
const selectGroupsWithMembers = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.name,
|
||||||
|
g.description,
|
||||||
|
g.avatar_color,
|
||||||
|
g.created_at,
|
||||||
|
g.updated_at,
|
||||||
|
COUNT(DISTINCT u.id) AS member_count,
|
||||||
|
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
|
||||||
|
FROM user_groups g
|
||||||
|
LEFT JOIN user_group_memberships m ON m.group_id = g.id
|
||||||
|
LEFT JOIN users u ON u.id = m.user_id
|
||||||
|
GROUP BY g.id
|
||||||
|
ORDER BY g.name COLLATE NOCASE
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectGroupWithMembersById = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.name,
|
||||||
|
g.description,
|
||||||
|
g.avatar_color,
|
||||||
|
g.created_at,
|
||||||
|
g.updated_at,
|
||||||
|
COUNT(DISTINCT u.id) AS member_count,
|
||||||
|
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
|
||||||
|
FROM user_groups g
|
||||||
|
LEFT JOIN user_group_memberships m ON m.group_id = g.id
|
||||||
|
LEFT JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE g.id = ?
|
||||||
|
GROUP BY g.id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectGroupByIdBasic = db.prepare(`
|
||||||
|
SELECT id, name, description, avatar_color
|
||||||
|
FROM user_groups
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectGroupIdByName = db.prepare(`
|
||||||
|
SELECT id
|
||||||
|
FROM user_groups
|
||||||
|
WHERE lower(name) = lower(?)
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertGroupStatement = db.prepare(`
|
||||||
|
INSERT INTO user_groups (name, description, avatar_color, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateGroupStatement = db.prepare(`
|
||||||
|
UPDATE user_groups
|
||||||
|
SET name = ?,
|
||||||
|
description = ?,
|
||||||
|
avatar_color = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const countGroupMembersStatement = db.prepare(`
|
||||||
|
SELECT COUNT(*) AS member_count
|
||||||
|
FROM user_group_memberships
|
||||||
|
WHERE group_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteGroupStatement = db.prepare(`
|
||||||
|
DELETE FROM user_groups
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const parseMembers = (rawPairs) => {
|
||||||
|
if (!rawPairs) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawPairs
|
||||||
|
.split('|||')
|
||||||
|
.map((entry) => {
|
||||||
|
const [idPart, usernamePart] = entry.split('::');
|
||||||
|
const numericId = Number(idPart);
|
||||||
|
return {
|
||||||
|
id: Number.isFinite(numericId) ? numericId : idPart,
|
||||||
|
username: (usernamePart || '').trim()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((member) => member.username);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeGroup = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description || '',
|
||||||
|
avatarColor: row.avatar_color || null,
|
||||||
|
createdAt: row.created_at || null,
|
||||||
|
updatedAt: row.updated_at || null,
|
||||||
|
memberCount: Number(row.member_count) || 0,
|
||||||
|
members: parseMembers(row.member_pairs)
|
||||||
|
});
|
||||||
|
|
||||||
|
export function listGroups() {
|
||||||
|
const rows = selectGroupsWithMembers.all();
|
||||||
|
return rows.map(sanitizeGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupById(groupId) {
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const row = selectGroupWithMembersById.get(numericId);
|
||||||
|
return row ? sanitizeGroup(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGroup({ name, description }) {
|
||||||
|
const normalizedName = typeof name === 'string' ? name.trim() : '';
|
||||||
|
if (!normalizedName) {
|
||||||
|
const error = new Error('GROUP_NAME_REQUIRED');
|
||||||
|
error.code = 'GROUP_NAME_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = selectGroupIdByName.get(normalizedName);
|
||||||
|
if (existing) {
|
||||||
|
const error = new Error('GROUP_NAME_TAKEN');
|
||||||
|
error.code = 'GROUP_NAME_TAKEN';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDescription = typeof description === 'string' ? description.trim() : null;
|
||||||
|
const avatarColor = pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
||||||
|
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null, avatarColor);
|
||||||
|
const groupId = Number(insertResult.lastInsertRowid);
|
||||||
|
const row = selectGroupWithMembersById.get(groupId);
|
||||||
|
const group = row ? sanitizeGroup(row) : null;
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzergruppe',
|
||||||
|
eventType: 'gruppe-angelegt',
|
||||||
|
action: 'anlegen',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'gruppe',
|
||||||
|
entityId: String(groupId),
|
||||||
|
entityName: group?.name ?? normalizedName,
|
||||||
|
message: `Benutzergruppe "${normalizedName}" angelegt`,
|
||||||
|
metadata: {
|
||||||
|
description: group?.description ?? normalizedDescription ?? null,
|
||||||
|
avatarColor
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGroupDetails(groupId, { name, description, avatarColor } = {}) {
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
const error = new Error('INVALID_GROUP_ID');
|
||||||
|
error.code = 'INVALID_GROUP_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRow = selectGroupByIdBasic.get(numericId);
|
||||||
|
if (!existingRow) {
|
||||||
|
const error = new Error('GROUP_NOT_FOUND');
|
||||||
|
error.code = 'GROUP_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = typeof name === 'string' ? name.trim() : existingRow.name || '';
|
||||||
|
if (!normalizedName) {
|
||||||
|
const error = new Error('GROUP_NAME_REQUIRED');
|
||||||
|
error.code = 'GROUP_NAME_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameRow = selectGroupIdByName.get(normalizedName);
|
||||||
|
if (nameRow && Number(nameRow.id) !== numericId) {
|
||||||
|
const error = new Error('GROUP_NAME_TAKEN');
|
||||||
|
error.code = 'GROUP_NAME_TAKEN';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedDescription;
|
||||||
|
if (description === undefined) {
|
||||||
|
normalizedDescription = existingRow.description ?? null;
|
||||||
|
} else if (description === null) {
|
||||||
|
normalizedDescription = null;
|
||||||
|
} else {
|
||||||
|
const trimmedDescription = typeof description === 'string' ? description.trim() : '';
|
||||||
|
normalizedDescription = trimmedDescription ? trimmedDescription : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||||
|
|
||||||
|
if (isSuperuserGroup) {
|
||||||
|
if (name !== undefined) {
|
||||||
|
const incomingName = typeof name === 'string' ? name.trim() : '';
|
||||||
|
if (incomingName && incomingName !== existingRow.name) {
|
||||||
|
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||||
|
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description !== undefined) {
|
||||||
|
const existingDescriptionComparable = existingRow.description === null || existingRow.description === undefined
|
||||||
|
? ''
|
||||||
|
: String(existingRow.description).trim();
|
||||||
|
const incomingDescriptionComparable = description === null
|
||||||
|
? ''
|
||||||
|
: typeof description === 'string'
|
||||||
|
? description.trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (incomingDescriptionComparable !== existingDescriptionComparable) {
|
||||||
|
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||||
|
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce original values for protected fields
|
||||||
|
normalizedDescription = existingRow.description ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorToPersist = existingRow.avatar_color || null;
|
||||||
|
if (avatarColor !== undefined) {
|
||||||
|
const candidate = String(avatarColor || '').trim();
|
||||||
|
if (!candidate) {
|
||||||
|
colorToPersist = DEFAULT_AVATAR_COLOR;
|
||||||
|
} else {
|
||||||
|
const normalized = normalizeAvatarColor(candidate);
|
||||||
|
if (!normalized) {
|
||||||
|
const error = new Error('INVALID_AVATAR_COLOR');
|
||||||
|
error.code = 'INVALID_AVATAR_COLOR';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
colorToPersist = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGroupStatement.run(
|
||||||
|
isSuperuserGroup ? existingRow.name : normalizedName,
|
||||||
|
normalizedDescription,
|
||||||
|
colorToPersist,
|
||||||
|
numericId
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedGroup = getGroupById(numericId);
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzergruppe',
|
||||||
|
eventType: 'gruppe-aktualisiert',
|
||||||
|
action: 'aktualisieren',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'gruppe',
|
||||||
|
entityId: String(numericId),
|
||||||
|
entityName: updatedGroup?.name ?? normalizedName,
|
||||||
|
message: `Benutzergruppe "${updatedGroup?.name ?? normalizedName}" aktualisiert`,
|
||||||
|
metadata: {
|
||||||
|
previous: {
|
||||||
|
name: existingRow.name,
|
||||||
|
description: existingRow.description ?? null,
|
||||||
|
avatarColor: existingRow.avatar_color ?? null
|
||||||
|
},
|
||||||
|
current: updatedGroup
|
||||||
|
? {
|
||||||
|
name: updatedGroup.name,
|
||||||
|
description: updatedGroup.description ?? null,
|
||||||
|
avatarColor: updatedGroup.avatarColor ?? null,
|
||||||
|
memberCount: updatedGroup.memberCount ?? 0
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: normalizedName,
|
||||||
|
description: normalizedDescription ?? null,
|
||||||
|
avatarColor: colorToPersist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGroup(groupId) {
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
const error = new Error('INVALID_GROUP_ID');
|
||||||
|
error.code = 'INVALID_GROUP_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRow = selectGroupByIdBasic.get(numericId);
|
||||||
|
if (!existingRow) {
|
||||||
|
const error = new Error('GROUP_NOT_FOUND');
|
||||||
|
error.code = 'GROUP_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||||
|
if (isSuperuserGroup) {
|
||||||
|
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||||
|
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { member_count: memberCount } = countGroupMembersStatement.get(numericId);
|
||||||
|
if (Number(memberCount) > 0) {
|
||||||
|
const error = new Error('GROUP_HAS_MEMBERS');
|
||||||
|
error.code = 'GROUP_HAS_MEMBERS';
|
||||||
|
error.memberCount = Number(memberCount);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGroupStatement.run(numericId);
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzergruppe',
|
||||||
|
eventType: 'gruppe-gelöscht',
|
||||||
|
action: 'löschen',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'gruppe',
|
||||||
|
entityId: String(numericId),
|
||||||
|
entityName: existingRow.name ?? `ID ${numericId}`,
|
||||||
|
message: `Benutzergruppe "${existingRow.name ?? numericId}" gelöscht`,
|
||||||
|
metadata: {
|
||||||
|
description: existingRow.description ?? null,
|
||||||
|
avatarColor: existingRow.avatar_color ?? null,
|
||||||
|
memberCountBeforeDelete: Number(memberCount)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+3534
-69
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+251
@@ -8,7 +8,9 @@
|
|||||||
"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",
|
||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.7",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express": "^4.18.0",
|
"express": "^4.18.0",
|
||||||
@@ -58,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",
|
||||||
@@ -112,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",
|
||||||
@@ -230,6 +263,24 @@
|
|||||||
"tweetnacl": "^0.14.3"
|
"tweetnacl": "^0.14.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/better-sqlite3": {
|
||||||
|
"version": "9.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
||||||
|
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"prebuild-install": "^7.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bindings": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"file-uri-to-path": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bl": {
|
"node_modules/bl": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||||
@@ -441,6 +492,28 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decompress-response": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-response": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deep-extend": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -466,6 +539,14 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/docker-modem": {
|
"node_modules/docker-modem": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
|
||||||
@@ -686,6 +767,14 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expand-template": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.21.2",
|
"version": "4.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
@@ -731,6 +820,11 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-uri-to-path": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
|
||||||
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||||
@@ -854,6 +948,11 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/github-from-package": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -951,6 +1050,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -1039,6 +1143,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mimic-response": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mkdirp-classic": {
|
"node_modules/mkdirp-classic": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
@@ -1055,6 +1178,11 @@
|
|||||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/napi-build-utils": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -1063,6 +1191,17 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abi": {
|
||||||
|
"version": "3.77.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz",
|
||||||
|
"integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.3.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -1114,6 +1253,31 @@
|
|||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/prebuild-install": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.0",
|
||||||
|
"expand-template": "^2.0.3",
|
||||||
|
"github-from-package": "0.0.0",
|
||||||
|
"minimist": "^1.2.3",
|
||||||
|
"mkdirp-classic": "^0.5.3",
|
||||||
|
"napi-build-utils": "^2.0.0",
|
||||||
|
"node-abi": "^3.3.0",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"rc": "^1.2.7",
|
||||||
|
"simple-get": "^4.0.0",
|
||||||
|
"tar-fs": "^2.0.0",
|
||||||
|
"tunnel-agent": "^0.6.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prebuild-install": "bin.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
"version": "7.5.4",
|
"version": "7.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||||
@@ -1199,6 +1363,20 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rc": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-extend": "^0.6.0",
|
||||||
|
"ini": "~1.3.0",
|
||||||
|
"minimist": "^1.2.0",
|
||||||
|
"strip-json-comments": "~2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rc": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
@@ -1244,6 +1422,17 @@
|
|||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||||
|
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "0.19.0",
|
"version": "0.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||||
@@ -1367,6 +1556,49 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-concat": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/simple-get": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"decompress-response": "^6.0.0",
|
||||||
|
"once": "^1.3.1",
|
||||||
|
"simple-concat": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/socket.io": {
|
"node_modules/socket.io": {
|
||||||
"version": "4.8.1",
|
"version": "4.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
||||||
@@ -1530,6 +1762,14 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-json-comments": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar-fs": {
|
"node_modules/tar-fs": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||||
@@ -1564,6 +1804,17 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tunnel-agent": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tweetnacl": {
|
"node_modules/tweetnacl": {
|
||||||
"version": "0.14.5",
|
"version": "0.14.5",
|
||||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||||
|
|||||||
+18
-1
@@ -1 +1,18 @@
|
|||||||
{"name":"backend","version":"1.0.0","type":"module","scripts":{"start":"node index.js"},"dependencies":{"axios":"^1.7.0","dockerode":"^4.0.7","dotenv":"^16.0.0","express":"^4.18.0","socket.io":"^4.8.1"}}
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"migrate": "node db/ensure.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"better-sqlite3": "^9.6.0",
|
||||||
|
"dockerode": "^4.0.7",
|
||||||
|
"dotenv": "^16.0.0",
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"@scure/bip39": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
|
||||||
|
const LEVEL_PRIORITY = {
|
||||||
|
none: 0,
|
||||||
|
read: 1,
|
||||||
|
full: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSections = db.prepare(`
|
||||||
|
SELECT id, key, title, sort_order, has_navigation_flag
|
||||||
|
FROM permission_sections
|
||||||
|
ORDER BY sort_order ASC, id ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectGroups = db.prepare(`
|
||||||
|
SELECT id, section_id, key, title, sort_order
|
||||||
|
FROM permission_groups
|
||||||
|
ORDER BY section_id ASC, sort_order ASC, id ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectItems = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
section_id,
|
||||||
|
group_id,
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
sort_order,
|
||||||
|
default_level,
|
||||||
|
available_levels,
|
||||||
|
is_required
|
||||||
|
FROM permission_items
|
||||||
|
ORDER BY section_id ASC, COALESCE(group_id, 0) ASC, sort_order ASC, id ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectDependencies = db.prepare(`
|
||||||
|
SELECT permission_id, depends_on_permission_id, required_level
|
||||||
|
FROM permission_dependencies
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectPermissionItemsForMap = db.prepare(`
|
||||||
|
SELECT id, key, available_levels, default_level
|
||||||
|
FROM permission_items
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectGroupPermissionValues = db.prepare(`
|
||||||
|
SELECT gp.permission_id, gp.level, gp.effective_level, pi.key
|
||||||
|
FROM group_permission_values gp
|
||||||
|
JOIN permission_items pi ON pi.id = gp.permission_id
|
||||||
|
WHERE gp.group_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteGroupPermissionValuesStmt = db.prepare(`
|
||||||
|
DELETE FROM group_permission_values
|
||||||
|
WHERE group_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertGroupPermissionValueStmt = db.prepare(`
|
||||||
|
INSERT INTO group_permission_values (group_id, permission_id, level, effective_level, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const parseAvailableLevels = (raw) => {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) {
|
||||||
|
return ['full', 'read', 'none'];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Konnte available_levels nicht parsen:', error.message);
|
||||||
|
}
|
||||||
|
return ['full', 'read', 'none'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelPriority = (level) => {
|
||||||
|
if (!level) return 0;
|
||||||
|
return LEVEL_PRIORITY[level] ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedPermissionItems = null;
|
||||||
|
|
||||||
|
const getPermissionItems = () => {
|
||||||
|
if (!cachedPermissionItems) {
|
||||||
|
cachedPermissionItems = selectPermissionItemsForMap.all();
|
||||||
|
}
|
||||||
|
return cachedPermissionItems;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPermissionItemsCache = () => {
|
||||||
|
cachedPermissionItems = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPermissionStructure() {
|
||||||
|
const sections = selectSections.all();
|
||||||
|
const groups = selectGroups.all();
|
||||||
|
const items = selectItems.all();
|
||||||
|
const dependencies = selectDependencies.all();
|
||||||
|
|
||||||
|
const itemIdToKey = new Map(items.map((item) => [item.id, item.key]));
|
||||||
|
|
||||||
|
const dependenciesByItemId = new Map();
|
||||||
|
dependencies.forEach((dependency) => {
|
||||||
|
const list = dependenciesByItemId.get(dependency.permission_id) || [];
|
||||||
|
list.push(dependency);
|
||||||
|
dependenciesByItemId.set(dependency.permission_id, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupsBySectionId = new Map();
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const list = groupsBySectionId.get(group.section_id) || [];
|
||||||
|
list.push(group);
|
||||||
|
groupsBySectionId.set(group.section_id, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemsBySectionId = new Map();
|
||||||
|
const itemsByGroupId = new Map();
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.group_id) {
|
||||||
|
const list = itemsByGroupId.get(item.group_id) || [];
|
||||||
|
list.push(item);
|
||||||
|
itemsByGroupId.set(item.group_id, list);
|
||||||
|
} else {
|
||||||
|
const list = itemsBySectionId.get(item.section_id) || [];
|
||||||
|
list.push(item);
|
||||||
|
itemsBySectionId.set(item.section_id, list);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatItem = (item) => {
|
||||||
|
const availableLevels = parseAvailableLevels(item.available_levels);
|
||||||
|
|
||||||
|
const dependencyEntries = dependenciesByItemId.get(item.id) || [];
|
||||||
|
const formattedDependencies = dependencyEntries
|
||||||
|
.map((dependency) => {
|
||||||
|
const dependsOnKey = itemIdToKey.get(dependency.depends_on_permission_id);
|
||||||
|
if (!dependsOnKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: dependsOnKey,
|
||||||
|
requiredLevel: dependency.required_level || null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
sortOrder: item.sort_order ?? 0,
|
||||||
|
defaultLevel: item.default_level || 'none',
|
||||||
|
availableLevels,
|
||||||
|
isRequired: Boolean(item.is_required),
|
||||||
|
dependencies: formattedDependencies
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedSections = sections.map((section) => {
|
||||||
|
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
|
||||||
|
const sectionGroups = (groupsBySectionId.get(section.id) || []).map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
title: group.title,
|
||||||
|
sortOrder: group.sort_order ?? 0,
|
||||||
|
items: (itemsByGroupId.get(group.id) || []).map(formatItem)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: section.key,
|
||||||
|
title: section.title,
|
||||||
|
sortOrder: section.sort_order ?? 0,
|
||||||
|
hasNavigation: Boolean(section.has_navigation_flag),
|
||||||
|
items: sectionItems,
|
||||||
|
groups: sectionGroups
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return formattedSections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPermissionValuesByGroup(groupId) {
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = selectGroupPermissionValues.all(numericId);
|
||||||
|
const values = {};
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row?.key && typeof row.level === 'string') {
|
||||||
|
values[row.key] = row.level;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGroupPermissionValues(groupId) {
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteGroupPermissionValuesStmt.run(numericId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveGroupPermissionValues(groupId, values = {}) {
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
const error = new Error('INVALID_GROUP_ID');
|
||||||
|
error.code = 'INVALID_GROUP_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
|
||||||
|
const error = new Error('PERMISSION_INVALID_PAYLOAD');
|
||||||
|
error.code = 'PERMISSION_INVALID_PAYLOAD';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = getPermissionItems();
|
||||||
|
const itemMap = new Map();
|
||||||
|
items.forEach((item) => {
|
||||||
|
itemMap.set(item.key, {
|
||||||
|
id: item.id,
|
||||||
|
key: item.key,
|
||||||
|
defaultLevel: item.default_level || 'none',
|
||||||
|
availableLevels: parseAvailableLevels(item.available_levels)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = Object.entries(values);
|
||||||
|
const normalizedEntries = [];
|
||||||
|
|
||||||
|
entries.forEach(([key, rawValue]) => {
|
||||||
|
const item = itemMap.get(key);
|
||||||
|
if (!item) {
|
||||||
|
const error = new Error('PERMISSION_UNKNOWN_KEY');
|
||||||
|
error.code = 'PERMISSION_UNKNOWN_KEY';
|
||||||
|
error.permissionKey = key;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let level = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue ?? '').trim();
|
||||||
|
if (!level) {
|
||||||
|
level = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.availableLevels.includes(level)) {
|
||||||
|
const error = new Error('PERMISSION_INVALID_LEVEL');
|
||||||
|
error.code = 'PERMISSION_INVALID_LEVEL';
|
||||||
|
error.permissionKey = key;
|
||||||
|
error.level = level;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedEntries.push({ item, level });
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSubmittedLevel = (permissionKey) => {
|
||||||
|
const entry = normalizedEntries.find((entry) => entry.item.key === permissionKey);
|
||||||
|
if (entry) {
|
||||||
|
return entry.level;
|
||||||
|
}
|
||||||
|
const fallback = itemMap.get(permissionKey);
|
||||||
|
return fallback ? fallback.defaultLevel || 'none' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverManageLevel = getSubmittedLevel('maintenance-server-manage');
|
||||||
|
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
|
||||||
|
|
||||||
|
if (getLevelPriority(serverDeleteLevel) >= getLevelPriority('full') &&
|
||||||
|
getLevelPriority(serverManageLevel) < getLevelPriority('full')) {
|
||||||
|
const error = new Error('PERMISSION_DEPENDENCY_LEVEL');
|
||||||
|
error.code = 'PERMISSION_DEPENDENCY_LEVEL';
|
||||||
|
error.permissionKey = 'maintenance-server-delete';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSave = db.transaction(() => {
|
||||||
|
deleteGroupPermissionValuesStmt.run(numericId);
|
||||||
|
normalizedEntries.forEach(({ item, level }) => {
|
||||||
|
if (level === (item.defaultLevel || 'none')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
insertGroupPermissionValueStmt.run(numericId, item.id, level, level);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
runSave();
|
||||||
|
resetPermissionItemsCache();
|
||||||
|
|
||||||
|
return getPermissionValuesByGroup(numericId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPermissionMap() {
|
||||||
|
const items = getPermissionItems();
|
||||||
|
const defaults = {};
|
||||||
|
items.forEach((item) => {
|
||||||
|
defaults[item.key] = item.default_level || 'none';
|
||||||
|
});
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuperuserPermissionMap() {
|
||||||
|
const items = getPermissionItems();
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const available = parseAvailableLevels(item.available_levels);
|
||||||
|
let chosen = 'full';
|
||||||
|
if (!available.includes('full')) {
|
||||||
|
chosen = available.reduce(
|
||||||
|
(best, candidate) =>
|
||||||
|
getLevelPriority(candidate) > getLevelPriority(best) ? candidate : best,
|
||||||
|
'none'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result[item.key] = chosen;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectivePermissionsForGroup(groupId) {
|
||||||
|
const defaults = getDefaultPermissionMap();
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides = getPermissionValuesByGroup(numericId);
|
||||||
|
const map = { ...defaults };
|
||||||
|
|
||||||
|
Object.keys(overrides).forEach((key) => {
|
||||||
|
const value = overrides[key];
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
map[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectivePermissionsForGroups(groupIds = []) {
|
||||||
|
const defaults = getDefaultPermissionMap();
|
||||||
|
const finalMap = { ...defaults };
|
||||||
|
|
||||||
|
const iterableIds = Array.isArray(groupIds) ? groupIds : [];
|
||||||
|
iterableIds
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
.forEach((groupId) => {
|
||||||
|
const map = getEffectivePermissionsForGroup(groupId);
|
||||||
|
Object.entries(map).forEach(([key, level]) => {
|
||||||
|
if (getLevelPriority(level) > getLevelPriority(finalMap[key] ?? 'none')) {
|
||||||
|
finalMap[key] = level;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRequiredPermission(permissions = {}, permissionKey, requiredLevel = 'full') {
|
||||||
|
if (!permissionKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (requiredLevel === 'none' || requiredLevel === null || requiredLevel === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const current = typeof permissions[permissionKey] === 'string' ? permissions[permissionKey] : 'none';
|
||||||
|
const required = typeof requiredLevel === 'string' ? requiredLevel : 'full';
|
||||||
|
return getLevelPriority(current) >= getLevelPriority(required);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
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-71db1823.js"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/index-dad5f6fa.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { hasSuperuser } from '../auth/superuser.js';
|
||||||
|
|
||||||
|
const selectAllServers = db.prepare('SELECT * FROM servers ORDER BY id ASC');
|
||||||
|
const selectServerById = db.prepare('SELECT * FROM servers WHERE id = ?');
|
||||||
|
const selectServerByUrl = db.prepare('SELECT * FROM servers WHERE url = ?');
|
||||||
|
const insertServer = db.prepare(`
|
||||||
|
INSERT INTO servers (name, url)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`);
|
||||||
|
const updateServer = db.prepare(`
|
||||||
|
UPDATE servers
|
||||||
|
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
|
||||||
|
|
||||||
|
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
|
||||||
|
const countApiKeysStmt = db.prepare('SELECT COUNT(*) as count FROM server_api_keys');
|
||||||
|
const selectAllApiKeys = db.prepare('SELECT server_id, created_at, updated_at FROM server_api_keys');
|
||||||
|
const upsertApiKey = db.prepare(`
|
||||||
|
INSERT INTO server_api_keys (server_id, key_cipher, key_iv, key_tag, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(server_id) DO UPDATE SET
|
||||||
|
key_cipher = excluded.key_cipher,
|
||||||
|
key_iv = excluded.key_iv,
|
||||||
|
key_tag = excluded.key_tag,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`);
|
||||||
|
const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE server_id = ?');
|
||||||
|
|
||||||
|
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
|
||||||
|
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
|
||||||
|
|
||||||
|
const API_KEY_SECRET = crypto.createHash('sha256')
|
||||||
|
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
|
||||||
|
.digest();
|
||||||
|
|
||||||
|
function encryptApiKey(apiKey) {
|
||||||
|
if (!apiKey || typeof apiKey !== 'string') return null;
|
||||||
|
const trimmed = apiKey.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(trimmed, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return {
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
content: encrypted.toString('base64'),
|
||||||
|
tag: tag.toString('base64')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptApiKey(row) {
|
||||||
|
if (!row) return '';
|
||||||
|
try {
|
||||||
|
const iv = Buffer.from(row.key_iv, 'base64');
|
||||||
|
const content = Buffer.from(row.key_cipher, 'base64');
|
||||||
|
const tag = Buffer.from(row.key_tag, 'base64');
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ [Setup] API-Key konnte nicht entschlüsselt werden:', error.message);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') return null;
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
||||||
|
const candidate = hasProtocol ? trimmed : `https://${trimmed}`;
|
||||||
|
const normalized = new URL(candidate);
|
||||||
|
const pathname = normalized.pathname.replace(/\/+$/, '');
|
||||||
|
normalized.pathname = pathname || '/';
|
||||||
|
normalized.hash = '';
|
||||||
|
return normalized.toString().replace(/\/$/, '');
|
||||||
|
} catch (err) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveServerName(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.hostname || url;
|
||||||
|
} catch (err) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServer({ name, url }) {
|
||||||
|
const normalizedUrl = normalizeUrl(url);
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
const error = new Error('SERVER_URL_REQUIRED');
|
||||||
|
error.code = 'SERVER_URL_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const normalizedName = (name || deriveServerName(normalizedUrl)).trim();
|
||||||
|
if (!normalizedName) {
|
||||||
|
const error = new Error('SERVER_NAME_REQUIRED');
|
||||||
|
error.code = 'SERVER_NAME_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = selectServerByUrl.get(normalizedUrl);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.name !== normalizedName) {
|
||||||
|
updateServer.run(normalizedName, normalizedUrl, existing.id);
|
||||||
|
const updated = selectServerById.get(existing.id);
|
||||||
|
console.log(`ℹ️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = insertServer.run(normalizedName, normalizedUrl);
|
||||||
|
const created = selectServerById.get(result.lastInsertRowid);
|
||||||
|
console.log(`✅ [Setup] Server angelegt: ${created.name} (${created.id})`);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setServerApiKey({ serverId, apiKey }) {
|
||||||
|
const id = Number(serverId);
|
||||||
|
if (!Number.isFinite(id)) {
|
||||||
|
const error = new Error('SERVER_ID_INVALID');
|
||||||
|
error.code = 'SERVER_ID_INVALID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = selectServerById.get(id);
|
||||||
|
if (!server) {
|
||||||
|
const error = new Error('SERVER_NOT_FOUND');
|
||||||
|
error.code = 'SERVER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedKey = typeof apiKey === 'string' ? apiKey.trim() : '';
|
||||||
|
if (!normalizedKey) {
|
||||||
|
const error = new Error('API_KEY_REQUIRED');
|
||||||
|
error.code = 'API_KEY_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = encryptApiKey(normalizedKey);
|
||||||
|
if (!encrypted) {
|
||||||
|
const error = new Error('API_KEY_ENCRYPT_FAILED');
|
||||||
|
error.code = 'API_KEY_ENCRYPT_FAILED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = selectApiKeyByServerId.get(id);
|
||||||
|
upsertApiKey.run(id, encrypted.content, encrypted.iv, encrypted.tag);
|
||||||
|
console.log(`${existing ? 'ℹ️' : '🔐'} [Setup] API-Key ${existing ? 'aktualisiert' : 'angelegt'}: Server ${server.name} (${server.id})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverId: server.id,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveApiKey() {
|
||||||
|
const firstServer = selectFirstServerStmt.get();
|
||||||
|
if (firstServer) {
|
||||||
|
const row = selectApiKeyByServerId.get(firstServer.id);
|
||||||
|
const key = decryptApiKey(row);
|
||||||
|
if (key) return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envKey = process.env.PORTAINER_API_KEY ? process.env.PORTAINER_API_KEY.trim() : '';
|
||||||
|
return envKey || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveServerUrl() {
|
||||||
|
const firstServer = selectFirstServerStmt.get();
|
||||||
|
if (firstServer?.url) {
|
||||||
|
const normalizedUrl = normalizeUrl(firstServer.url);
|
||||||
|
if (normalizedUrl) {
|
||||||
|
return normalizedUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envUrl = process.env.PORTAINER_URL ? process.env.PORTAINER_URL.trim() : '';
|
||||||
|
const normalizedEnvUrl = envUrl ? normalizeUrl(envUrl) : '';
|
||||||
|
return normalizedEnvUrl || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasServer() {
|
||||||
|
const { count } = countServersStmt.get();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeServer(serverId) {
|
||||||
|
const id = Number(serverId);
|
||||||
|
if (!Number.isFinite(id)) {
|
||||||
|
const error = new Error('SERVER_ID_INVALID');
|
||||||
|
error.code = 'SERVER_ID_INVALID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = selectServerById.get(id);
|
||||||
|
if (!server) {
|
||||||
|
const error = new Error('SERVER_NOT_FOUND');
|
||||||
|
error.code = 'SERVER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingApiKey = selectApiKeyByServerId.get(id);
|
||||||
|
|
||||||
|
deleteServerStmt.run(id);
|
||||||
|
|
||||||
|
if (existingApiKey) {
|
||||||
|
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
removed: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasApiKey() {
|
||||||
|
const { count } = countApiKeysStmt.get();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompleteSetup() {
|
||||||
|
return hasSuperuser() && hasServer() && hasApiKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDefaultsFromEnv() {
|
||||||
|
const envServerUrlRaw = process.env.PORTAINER_URL;
|
||||||
|
const envServerName = process.env.PORTAINER_SERVER_NAME;
|
||||||
|
let server = null;
|
||||||
|
if (envServerUrlRaw) {
|
||||||
|
try {
|
||||||
|
server = ensureServer({ name: envServerName, url: envServerUrlRaw });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('⚠️ [Setup] Konnte Server aus Umgebungsvariablen nicht anlegen:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envApiKeyRaw = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||||
|
if (envApiKeyRaw.trim()) {
|
||||||
|
const targetServer = server || selectFirstServerStmt.get();
|
||||||
|
if (targetServer) {
|
||||||
|
try {
|
||||||
|
setServerApiKey({ serverId: targetServer.id, apiKey: envApiKeyRaw.trim() });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('⚠️ [Setup] Konnte API-Key aus Umgebungsvariablen nicht speichern:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSetupStatus() {
|
||||||
|
const servers = selectAllServers.all();
|
||||||
|
const apiKeyRecords = selectAllApiKeys.all();
|
||||||
|
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
|
||||||
|
|
||||||
|
const rawEnvServerName = typeof process.env.PORTAINER_SERVER_NAME === 'string' ? process.env.PORTAINER_SERVER_NAME : '';
|
||||||
|
const envServerName = rawEnvServerName.trim();
|
||||||
|
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
|
||||||
|
const envServerUrl = rawEnvServerUrl.trim();
|
||||||
|
const rawEnvApiKey = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
||||||
|
const envApiKeyTrimmed = rawEnvApiKey.trim();
|
||||||
|
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
|
||||||
|
const envSuperuserUsernameRaw = typeof process.env.SUPERUSER_USERNAME === 'string' ? process.env.SUPERUSER_USERNAME : '';
|
||||||
|
const envSuperuserEmailRaw = typeof process.env.SUPERUSER_EMAIL === 'string' ? process.env.SUPERUSER_EMAIL : '';
|
||||||
|
const envSuperuserPasswordRaw = typeof process.env.SUPERUSER_PASSWORD === 'string' ? process.env.SUPERUSER_PASSWORD : '';
|
||||||
|
const envSuperuserUsername = envSuperuserUsernameRaw.trim();
|
||||||
|
const envSuperuserEmail = envSuperuserEmailRaw.trim();
|
||||||
|
|
||||||
|
const serverRequired = servers.length === 0;
|
||||||
|
|
||||||
|
const apiKeyItems = servers.map((server) => {
|
||||||
|
const keyMeta = apiKeyMap.get(server.id) || null;
|
||||||
|
return {
|
||||||
|
serverId: server.id,
|
||||||
|
serverName: server.name,
|
||||||
|
hasKey: Boolean(keyMeta),
|
||||||
|
updatedAt: keyMeta?.updated_at ?? null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const apiKeyCount = apiKeyItems.filter((item) => item.hasKey).length;
|
||||||
|
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
|
||||||
|
|
||||||
|
const superuserExists = hasSuperuser();
|
||||||
|
const setupComplete = superuserExists && !serverRequired && !apiKeyRequired;
|
||||||
|
|
||||||
|
return {
|
||||||
|
superuser: {
|
||||||
|
exists: superuserExists,
|
||||||
|
envProvided: Boolean(envSuperuserUsername || envSuperuserEmail || envSuperuserPasswordRaw),
|
||||||
|
env: {
|
||||||
|
username: envSuperuserUsernameRaw,
|
||||||
|
email: envSuperuserEmailRaw,
|
||||||
|
password: envSuperuserPasswordRaw
|
||||||
|
}
|
||||||
|
},
|
||||||
|
servers: {
|
||||||
|
count: servers.length,
|
||||||
|
items: servers,
|
||||||
|
requireInput: serverRequired,
|
||||||
|
envProvided: Boolean(envServerUrl)
|
||||||
|
},
|
||||||
|
apiKeys: {
|
||||||
|
count: apiKeyCount,
|
||||||
|
items: apiKeyItems,
|
||||||
|
requireInput: apiKeyRequired,
|
||||||
|
envProvided: envApiKeyProvided,
|
||||||
|
envValue: rawEnvApiKey
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
superuser: !superuserExists,
|
||||||
|
server: serverRequired,
|
||||||
|
apiKey: apiKeyRequired
|
||||||
|
},
|
||||||
|
setupComplete,
|
||||||
|
envDefaults: {
|
||||||
|
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
|
||||||
|
serverNameFromEnv: rawEnvServerName,
|
||||||
|
serverUrl: envServerUrl,
|
||||||
|
apiKeyProvided: envApiKeyProvided,
|
||||||
|
apiKeyValue: rawEnvApiKey,
|
||||||
|
superuserUsername: envSuperuserUsernameRaw,
|
||||||
|
superuserEmail: envSuperuserEmailRaw,
|
||||||
|
superuserPassword: envSuperuserPasswordRaw
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeSetup({ server: serverInput }) {
|
||||||
|
let serverRecord = null;
|
||||||
|
|
||||||
|
if (serverInput && typeof serverInput.url === 'string' && serverInput.url.trim()) {
|
||||||
|
serverRecord = ensureServer(serverInput);
|
||||||
|
} else {
|
||||||
|
serverRecord = selectFirstServerStmt.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverRecord) {
|
||||||
|
const error = new Error('SERVER_DETAILS_REQUIRED');
|
||||||
|
error.code = 'SERVER_DETAILS_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: serverRecord
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ensureDefaultsFromEnv,
|
||||||
|
ensureServer,
|
||||||
|
setServerApiKey,
|
||||||
|
getActiveApiKey,
|
||||||
|
getActiveServerUrl,
|
||||||
|
hasServer,
|
||||||
|
hasApiKey,
|
||||||
|
hasCompleteSetup,
|
||||||
|
getSetupStatus,
|
||||||
|
completeSetup,
|
||||||
|
removeServer
|
||||||
|
};
|
||||||
@@ -0,0 +1,987 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { logEvent } from '../logging/eventLogs.js';
|
||||||
|
import {
|
||||||
|
generateSecurityPhraseWords,
|
||||||
|
encryptSecurityPhrase,
|
||||||
|
decryptSecurityPhrase,
|
||||||
|
canonicalizeWords,
|
||||||
|
canonicalizePhraseInput
|
||||||
|
} from './securityPhrase.js';
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
normalizeAvatarColor,
|
||||||
|
pickRandomAvatarColor,
|
||||||
|
DEFAULT_AVATAR_COLOR,
|
||||||
|
SUPERUSER_GROUP_NAME
|
||||||
|
} from '../auth/superuser.js';
|
||||||
|
|
||||||
|
const selectUsersWithGroups = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.email,
|
||||||
|
u.is_active,
|
||||||
|
u.avatar_color,
|
||||||
|
u.last_login,
|
||||||
|
u.created_at,
|
||||||
|
u.updated_at,
|
||||||
|
u.security_phrase_downloaded_at,
|
||||||
|
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||||
|
LEFT JOIN user_groups g ON g.id = m.group_id
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.username COLLATE NOCASE
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectUserWithGroupsById = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.email,
|
||||||
|
u.is_active,
|
||||||
|
u.avatar_color,
|
||||||
|
u.last_login,
|
||||||
|
u.created_at,
|
||||||
|
u.updated_at,
|
||||||
|
u.security_phrase_content,
|
||||||
|
u.security_phrase_iv,
|
||||||
|
u.security_phrase_tag,
|
||||||
|
u.security_phrase_downloaded_at,
|
||||||
|
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||||
|
LEFT JOIN user_groups g ON g.id = m.group_id
|
||||||
|
WHERE u.id = ?
|
||||||
|
GROUP BY u.id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectUserCredentialsById = db.prepare(`
|
||||||
|
SELECT id, username, email, password_hash, password_salt, avatar_color
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectUserByUsername = db.prepare('SELECT id FROM users WHERE username = ?');
|
||||||
|
const selectUserByEmail = db.prepare('SELECT id FROM users WHERE email = ?');
|
||||||
|
|
||||||
|
const deleteMembershipsByUser = db.prepare(`
|
||||||
|
DELETE FROM user_group_memberships
|
||||||
|
WHERE user_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertMembershipForUser = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectSecurityPhraseByUsername = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
is_active,
|
||||||
|
security_phrase_content AS content,
|
||||||
|
security_phrase_iv AS iv,
|
||||||
|
security_phrase_tag AS tag,
|
||||||
|
security_phrase_downloaded_at AS downloaded_at
|
||||||
|
FROM users
|
||||||
|
WHERE lower(username) = lower(?)
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
|
||||||
|
const selectGroupIdByName = db.prepare('SELECT id FROM user_groups WHERE lower(name) = lower(?) LIMIT 1');
|
||||||
|
|
||||||
|
const isUserInGroupStatement = db.prepare(`
|
||||||
|
SELECT 1 AS has_membership
|
||||||
|
FROM user_group_memberships m
|
||||||
|
INNER JOIN user_groups g ON g.id = m.group_id
|
||||||
|
WHERE m.user_id = ? AND lower(g.name) = lower(?)
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertUserStatement = db.prepare(`
|
||||||
|
INSERT INTO users (
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
password_salt,
|
||||||
|
avatar_color,
|
||||||
|
is_active,
|
||||||
|
security_phrase_content,
|
||||||
|
security_phrase_iv,
|
||||||
|
security_phrase_tag,
|
||||||
|
security_phrase_downloaded_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateUserCoreStatement = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?,
|
||||||
|
email = ?,
|
||||||
|
password_hash = ?,
|
||||||
|
password_salt = ?,
|
||||||
|
avatar_color = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateUserActiveStatement = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET is_active = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateUserPasswordStatement = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?,
|
||||||
|
password_salt = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const parseGroupPairs = (rawPairs) => {
|
||||||
|
if (!rawPairs) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return rawPairs
|
||||||
|
.split('|||')
|
||||||
|
.map((entry) => {
|
||||||
|
const [idPart, namePart] = entry.split('::');
|
||||||
|
const groupName = String(namePart || '').trim();
|
||||||
|
if (!groupName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const numericId = Number(idPart);
|
||||||
|
return {
|
||||||
|
id: Number.isFinite(numericId) ? numericId : null,
|
||||||
|
name: groupName
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeUserRecord = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
email: row.email || null,
|
||||||
|
isActive: Boolean(row.is_active),
|
||||||
|
avatarColor: row.avatar_color || null,
|
||||||
|
lastLogin: row.last_login || null,
|
||||||
|
createdAt: row.created_at || null,
|
||||||
|
updatedAt: row.updated_at || null,
|
||||||
|
groups: parseGroupPairs(row.group_pairs),
|
||||||
|
securityPhraseDownloadedAt: row.security_phrase_downloaded_at || null
|
||||||
|
});
|
||||||
|
|
||||||
|
export function listUsers() {
|
||||||
|
const rows = selectUsersWithGroups.all();
|
||||||
|
return rows.map(sanitizeUserRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserById(userId) {
|
||||||
|
const row = selectUserWithGroupsById.get(userId);
|
||||||
|
return row ? sanitizeUserRecord(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
|
||||||
|
deleteMembershipsByUser.run(userId);
|
||||||
|
groupIds.forEach((groupId) => {
|
||||||
|
insertMembershipForUser.run(userId, groupId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertUserWithGroups = db.transaction(({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
passwordSalt,
|
||||||
|
avatarColor,
|
||||||
|
groupId,
|
||||||
|
securityPhrase
|
||||||
|
}) => {
|
||||||
|
const phraseToPersist = securityPhrase && typeof securityPhrase === 'object'
|
||||||
|
? {
|
||||||
|
content: securityPhrase.content ?? null,
|
||||||
|
iv: securityPhrase.iv ?? null,
|
||||||
|
tag: securityPhrase.tag ?? null
|
||||||
|
}
|
||||||
|
: { content: null, iv: null, tag: null };
|
||||||
|
|
||||||
|
const result = insertUserStatement.run(
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
passwordSalt,
|
||||||
|
avatarColor,
|
||||||
|
phraseToPersist.content,
|
||||||
|
phraseToPersist.iv,
|
||||||
|
phraseToPersist.tag
|
||||||
|
);
|
||||||
|
const userId = Number(result.lastInsertRowid);
|
||||||
|
applyUserGroupAssignments(userId, [groupId]);
|
||||||
|
return userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectSecurityPhraseByUserId = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
security_phrase_content AS content,
|
||||||
|
security_phrase_iv AS iv,
|
||||||
|
security_phrase_tag AS tag,
|
||||||
|
security_phrase_downloaded_at AS downloaded_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateSecurityPhraseStatement = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
security_phrase_content = ?,
|
||||||
|
security_phrase_iv = ?,
|
||||||
|
security_phrase_tag = ?,
|
||||||
|
security_phrase_downloaded_at = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const markSecurityPhraseDownloadedStatement = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET security_phrase_downloaded_at = COALESCE(security_phrase_downloaded_at, CURRENT_TIMESTAMP),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectUsersMissingSecurityPhraseStatement = db.prepare(`
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE security_phrase_content IS NULL
|
||||||
|
OR security_phrase_iv IS NULL
|
||||||
|
OR security_phrase_tag IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
const resolveActorFields = (actor) => {
|
||||||
|
if (!actor || actor.id === undefined || actor.id === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const name = actor.username || actor.email || `User ${actor.id}`;
|
||||||
|
return {
|
||||||
|
actorType: 'user',
|
||||||
|
actorId: String(actor.id),
|
||||||
|
actorName: name
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeEmail = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getUserSecurityPhrase(userId, options = {}) {
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actor = null, allowAutoGenerate = true } = options;
|
||||||
|
|
||||||
|
const record = selectSecurityPhraseByUserId.get(numericUserId);
|
||||||
|
if (!record) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = decryptSecurityPhrase(record);
|
||||||
|
|
||||||
|
if (allowAutoGenerate && (!Array.isArray(words) || words.length === 0)) {
|
||||||
|
return renewUserSecurityPhrase(numericUserId, { actor, suppressLog: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: numericUserId,
|
||||||
|
username: record.username,
|
||||||
|
words,
|
||||||
|
downloadedAt: record.downloaded_at || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renewUserSecurityPhrase(userId, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const suppressLog = Boolean(options.suppressLog);
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||||
|
if (!existingUser) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPhraseRecord = selectSecurityPhraseByUserId.get(numericUserId);
|
||||||
|
|
||||||
|
const words = generateSecurityPhraseWords(8);
|
||||||
|
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
||||||
|
|
||||||
|
updateSecurityPhraseStatement.run(
|
||||||
|
encryptedPhrase.content,
|
||||||
|
encryptedPhrase.iv,
|
||||||
|
encryptedPhrase.tag,
|
||||||
|
numericUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!suppressLog) {
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-sicherheitsschluessel-erneuert',
|
||||||
|
action: 'sicherheitsschluessel-erneuern',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: existingUser.username ?? `ID ${numericUserId}`,
|
||||||
|
message: `Sicherheitsschlüssel für Benutzer "${existingUser.username ?? numericUserId}" erneuert`,
|
||||||
|
metadata: {
|
||||||
|
previousDownloadedAt: previousPhraseRecord?.downloaded_at ?? null
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: numericUserId,
|
||||||
|
username: existingUser.username,
|
||||||
|
words,
|
||||||
|
downloadedAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markSecurityPhraseDownloaded(userId, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = selectSecurityPhraseByUserId.get(numericUserId);
|
||||||
|
if (!record) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
markSecurityPhraseDownloadedStatement.run(numericUserId);
|
||||||
|
|
||||||
|
const updatedRecord = selectSecurityPhraseByUserId.get(numericUserId) || record;
|
||||||
|
const downloadedAt = updatedRecord?.downloaded_at || null;
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-sicherheitsschluessel-heruntergeladen',
|
||||||
|
action: 'sicherheitsschluessel-herunterladen',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: record.username ?? `ID ${numericUserId}`,
|
||||||
|
message: `Sicherheitsschlüssel für Benutzer "${record.username ?? numericUserId}" heruntergeladen`,
|
||||||
|
metadata: {
|
||||||
|
downloadedAt
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: numericUserId,
|
||||||
|
username: record.username,
|
||||||
|
downloadedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySecurityPhraseForUsername(username, phraseInput) {
|
||||||
|
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
const error = new Error('USERNAME_REQUIRED');
|
||||||
|
error.code = 'USERNAME_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = selectSecurityPhraseByUsername.get(normalizedUsername);
|
||||||
|
if (!record) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateCanonical = canonicalizePhraseInput(phraseInput);
|
||||||
|
if (!candidateCanonical) {
|
||||||
|
const error = new Error('PHRASE_REQUIRED');
|
||||||
|
error.code = 'PHRASE_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = decryptSecurityPhrase(record);
|
||||||
|
if (!Array.isArray(words) || words.length === 0) {
|
||||||
|
const error = new Error('PHRASE_NOT_INITIALIZED');
|
||||||
|
error.code = 'PHRASE_NOT_INITIALIZED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedCanonical = canonicalizeWords(words);
|
||||||
|
if (!storedCanonical) {
|
||||||
|
const error = new Error('PHRASE_NOT_INITIALIZED');
|
||||||
|
error.code = 'PHRASE_NOT_INITIALIZED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedCanonical !== candidateCanonical) {
|
||||||
|
const error = new Error('PHRASE_MISMATCH');
|
||||||
|
error.code = 'PHRASE_MISMATCH';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: record.id,
|
||||||
|
username: record.username,
|
||||||
|
isActive: Boolean(record.is_active),
|
||||||
|
words
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserPassword(userId, newPassword, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const resetMethod = options.resetMethod || 'security-phrase';
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||||
|
if (!existingUser) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let passwordHash;
|
||||||
|
let passwordSalt;
|
||||||
|
try {
|
||||||
|
const hashed = hashPassword(newPassword);
|
||||||
|
passwordHash = hashed.hash;
|
||||||
|
passwordSalt = hashed.salt;
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const error = new Error('INVALID_PASSWORD');
|
||||||
|
error.code = 'INVALID_PASSWORD';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserPasswordStatement.run(passwordHash, passwordSalt, numericUserId);
|
||||||
|
const updated = getUserById(numericUserId);
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-passwort-zurueckgesetzt',
|
||||||
|
action: 'passwort-zuruecksetzen',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||||
|
message: `Passwort für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" zurückgesetzt`,
|
||||||
|
metadata: {
|
||||||
|
resetMethod
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSecurityPhrasesForExistingUsers() {
|
||||||
|
const rows = selectUsersMissingSecurityPhraseStatement.all();
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
const initialize = db.transaction((users) => {
|
||||||
|
users.forEach((entry) => {
|
||||||
|
if (!entry || entry.id === undefined || entry.id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const words = generateSecurityPhraseWords(8);
|
||||||
|
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
||||||
|
updateSecurityPhraseStatement.run(
|
||||||
|
encryptedPhrase.content,
|
||||||
|
encryptedPhrase.iv,
|
||||||
|
encryptedPhrase.tag,
|
||||||
|
entry.id
|
||||||
|
);
|
||||||
|
processed += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
initialize(rows);
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser({ username, email, password, groupId, avatarColor }, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
const error = new Error('USERNAME_REQUIRED');
|
||||||
|
error.code = 'USERNAME_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericGroupId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericGroupId) || numericGroupId <= 0) {
|
||||||
|
const error = new Error('INVALID_GROUP_ID');
|
||||||
|
error.code = 'INVALID_GROUP_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupRow = selectGroupById.get(numericGroupId);
|
||||||
|
if (!groupRow) {
|
||||||
|
const error = new Error('GROUP_NOT_FOUND');
|
||||||
|
error.code = 'GROUP_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((groupRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME) {
|
||||||
|
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
||||||
|
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = normalizeEmail(email);
|
||||||
|
if (normalizedEmail && !normalizedEmail.includes('@')) {
|
||||||
|
const error = new Error('INVALID_EMAIL');
|
||||||
|
error.code = 'INVALID_EMAIL';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUsername = selectUserByUsername.get(normalizedUsername);
|
||||||
|
if (existingUsername) {
|
||||||
|
const error = new Error('USERNAME_TAKEN');
|
||||||
|
error.code = 'USERNAME_TAKEN';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEmail) {
|
||||||
|
const existingEmail = selectUserByEmail.get(normalizedEmail);
|
||||||
|
if (existingEmail) {
|
||||||
|
const error = new Error('EMAIL_TAKEN');
|
||||||
|
error.code = 'EMAIL_TAKEN';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let passwordHash;
|
||||||
|
let passwordSalt;
|
||||||
|
try {
|
||||||
|
const hashed = hashPassword(password);
|
||||||
|
passwordHash = hashed.hash;
|
||||||
|
passwordSalt = hashed.salt;
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const error = new Error('INVALID_PASSWORD');
|
||||||
|
error.code = 'INVALID_PASSWORD';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
|
||||||
|
const avatarColorToPersist = normalizedAvatarColor || pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
||||||
|
|
||||||
|
const securityPhraseWords = generateSecurityPhraseWords(8);
|
||||||
|
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(securityPhraseWords);
|
||||||
|
const securityPhrasePayload = {
|
||||||
|
content: encryptedPhrase.content,
|
||||||
|
iv: encryptedPhrase.iv,
|
||||||
|
tag: encryptedPhrase.tag
|
||||||
|
};
|
||||||
|
|
||||||
|
const userId = insertUserWithGroups({
|
||||||
|
username: normalizedUsername,
|
||||||
|
email: normalizedEmail,
|
||||||
|
passwordHash,
|
||||||
|
passwordSalt,
|
||||||
|
avatarColor: avatarColorToPersist,
|
||||||
|
groupId: numericGroupId,
|
||||||
|
securityPhrase: securityPhrasePayload
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRecord = getUserById(userId);
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-angelegt',
|
||||||
|
action: 'anlegen',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(userRecord?.id ?? userId),
|
||||||
|
entityName: userRecord?.username ?? normalizedUsername,
|
||||||
|
message: `Benutzer "${normalizedUsername}" angelegt`,
|
||||||
|
metadata: {
|
||||||
|
email: userRecord?.email ?? normalizedEmail ?? null,
|
||||||
|
primaryGroupId: numericGroupId,
|
||||||
|
primaryGroupName: groupRow.name
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
|
||||||
|
return userRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserGroups(userId, groupIds, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = getUserById(numericUserId);
|
||||||
|
if (!existingUser) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedGroupIds = Array.isArray(groupIds)
|
||||||
|
? Array.from(
|
||||||
|
new Set(
|
||||||
|
groupIds
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const missingGroupIds = [];
|
||||||
|
const groupDetails = [];
|
||||||
|
normalizedGroupIds.forEach((groupId) => {
|
||||||
|
const groupRow = selectGroupById.get(groupId);
|
||||||
|
if (!groupRow) {
|
||||||
|
missingGroupIds.push(groupId);
|
||||||
|
} else {
|
||||||
|
groupDetails.push({
|
||||||
|
id: groupId,
|
||||||
|
name: groupRow.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missingGroupIds.length > 0) {
|
||||||
|
const error = new Error('GROUP_NOT_FOUND');
|
||||||
|
error.code = 'GROUP_NOT_FOUND';
|
||||||
|
error.missingGroupIds = missingGroupIds;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
|
||||||
|
const updated = getUserById(numericUserId);
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-gruppen-aktualisiert',
|
||||||
|
action: 'gruppe-aktualisieren',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||||
|
message: `Gruppenzuordnung für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktualisiert`,
|
||||||
|
metadata: {
|
||||||
|
previousGroups: (existingUser.groups || []).map((group) => ({ id: group.id, name: group.name })),
|
||||||
|
groups: updated?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? groupDetails
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserDetails(userId, { username, email, password, avatarColor, groupId, groupIds } = {}, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||||
|
if (!existingUser) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousUserRecord = getUserById(numericUserId);
|
||||||
|
|
||||||
|
const normalizedUsername = typeof username === 'string' ? username.trim() : existingUser.username;
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
const error = new Error('USERNAME_REQUIRED');
|
||||||
|
error.code = 'USERNAME_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameRow = selectUserByUsername.get(normalizedUsername);
|
||||||
|
if (usernameRow && Number(usernameRow.id) !== numericUserId) {
|
||||||
|
const error = new Error('USERNAME_TAKEN');
|
||||||
|
error.code = 'USERNAME_TAKEN';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email === undefined ? existingUser.email : normalizeEmail(email);
|
||||||
|
if (normalizedEmail && !normalizedEmail.includes('@')) {
|
||||||
|
const error = new Error('INVALID_EMAIL');
|
||||||
|
error.code = 'INVALID_EMAIL';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEmail) {
|
||||||
|
const emailRow = selectUserByEmail.get(normalizedEmail);
|
||||||
|
if (emailRow && Number(emailRow.id) !== numericUserId) {
|
||||||
|
const error = new Error('EMAIL_TAKEN');
|
||||||
|
error.code = 'EMAIL_TAKEN';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let passwordHash = existingUser.password_hash;
|
||||||
|
let passwordSalt = existingUser.password_salt;
|
||||||
|
if (typeof password === 'string') {
|
||||||
|
const trimmedPassword = password.trim();
|
||||||
|
if (trimmedPassword.length > 0) {
|
||||||
|
const hashed = hashPassword(trimmedPassword);
|
||||||
|
passwordHash = hashed.hash;
|
||||||
|
passwordSalt = hashed.salt;
|
||||||
|
}
|
||||||
|
} else if (password !== undefined && password !== null) {
|
||||||
|
const error = new Error('INVALID_PASSWORD');
|
||||||
|
error.code = 'INVALID_PASSWORD';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorToPersist = existingUser.avatar_color || DEFAULT_AVATAR_COLOR;
|
||||||
|
if (avatarColor !== undefined) {
|
||||||
|
const candidate = String(avatarColor || '').trim();
|
||||||
|
if (!candidate) {
|
||||||
|
colorToPersist = DEFAULT_AVATAR_COLOR;
|
||||||
|
} else {
|
||||||
|
const normalizedColor = normalizeAvatarColor(candidate);
|
||||||
|
if (!normalizedColor) {
|
||||||
|
const error = new Error('INVALID_AVATAR_COLOR');
|
||||||
|
error.code = 'INVALID_AVATAR_COLOR';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
colorToPersist = normalizedColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldUpdateGroups = groupId !== undefined || groupIds !== undefined;
|
||||||
|
let normalizedGroupIds = null;
|
||||||
|
let nextGroups = null;
|
||||||
|
if (shouldUpdateGroups) {
|
||||||
|
const incomingGroupIds = Array.isArray(groupIds)
|
||||||
|
? groupIds
|
||||||
|
: groupId !== undefined && groupId !== null
|
||||||
|
? [groupId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
normalizedGroupIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
incomingGroupIds
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingGroupIds = [];
|
||||||
|
nextGroups = [];
|
||||||
|
normalizedGroupIds.forEach((value) => {
|
||||||
|
const groupRow = selectGroupById.get(value);
|
||||||
|
if (!groupRow) {
|
||||||
|
missingGroupIds.push(value);
|
||||||
|
} else {
|
||||||
|
nextGroups.push({ id: value, name: groupRow.name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missingGroupIds.length > 0) {
|
||||||
|
const error = new Error('GROUP_NOT_FOUND');
|
||||||
|
error.code = 'GROUP_NOT_FOUND';
|
||||||
|
error.missingGroupIds = missingGroupIds;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const performUpdate = db.transaction(() => {
|
||||||
|
updateUserCoreStatement.run(
|
||||||
|
normalizedUsername,
|
||||||
|
normalizedEmail,
|
||||||
|
passwordHash,
|
||||||
|
passwordSalt,
|
||||||
|
colorToPersist,
|
||||||
|
numericUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalizedGroupIds !== null) {
|
||||||
|
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
performUpdate();
|
||||||
|
const updatedUser = getUserById(numericUserId);
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-aktualisiert',
|
||||||
|
action: 'aktualisieren',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: updatedUser?.username ?? normalizedUsername,
|
||||||
|
message: `Benutzer "${updatedUser?.username ?? normalizedUsername}" aktualisiert`,
|
||||||
|
metadata: {
|
||||||
|
email: updatedUser?.email ?? normalizedEmail ?? null,
|
||||||
|
groupsUpdated: normalizedGroupIds !== null
|
||||||
|
? (updatedUser?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? nextGroups)
|
||||||
|
: undefined,
|
||||||
|
previousGroups: normalizedGroupIds !== null
|
||||||
|
? (previousUserRecord?.groups || []).map((group) => ({ id: group.id, name: group.name }))
|
||||||
|
: undefined,
|
||||||
|
avatarColor: updatedUser?.avatarColor ?? colorToPersist
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMembershipsStatement = db.prepare(`
|
||||||
|
DELETE FROM user_group_memberships
|
||||||
|
WHERE user_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteUserStatement = db.prepare(`
|
||||||
|
DELETE FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
export function deleteUser(userId, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||||
|
if (!existingUser) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
|
||||||
|
if (superuserMembership && superuserMembership.has_membership) {
|
||||||
|
const error = new Error('USER_SUPERUSER_PROTECTED');
|
||||||
|
error.code = 'USER_SUPERUSER_PROTECTED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const performDelete = db.transaction(() => {
|
||||||
|
deleteMembershipsStatement.run(numericUserId);
|
||||||
|
deleteUserStatement.run(numericUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
performDelete();
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-gelöscht',
|
||||||
|
action: 'gelöscht',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: existingUser.username ?? `ID ${numericUserId}`,
|
||||||
|
message: `Benutzer "${existingUser.username ?? numericUserId}" gelöscht`,
|
||||||
|
metadata: {
|
||||||
|
email: existingUser.email ?? null
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserActiveStatus(userId, isActive, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||||
|
if (!existingUser) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
|
||||||
|
if (superuserMembership && superuserMembership.has_membership && !isActive) {
|
||||||
|
const error = new Error('USER_SUPERUSER_PROTECTED');
|
||||||
|
error.code = 'USER_SUPERUSER_PROTECTED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedIsActive = isActive ? 1 : 0;
|
||||||
|
updateUserActiveStatement.run(normalizedIsActive, numericUserId);
|
||||||
|
const updated = getUserById(numericUserId);
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: normalizedIsActive ? 'benutzer-aktiviert' : 'benutzer-deaktiviert',
|
||||||
|
action: 'status-aktualisieren',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||||
|
message: normalizedIsActive
|
||||||
|
? `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktiviert`
|
||||||
|
: `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" deaktiviert`,
|
||||||
|
metadata: {
|
||||||
|
isActive: Boolean(normalizedIsActive)
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { wordlist as englishWordlist } from '@scure/bip39/wordlists/english.js';
|
||||||
|
|
||||||
|
const FALLBACK_SECRET = 'stackpulse-security-phrase-secret';
|
||||||
|
|
||||||
|
const WORD_LIST = englishWordlist;
|
||||||
|
|
||||||
|
|
||||||
|
const SECURITY_PHRASE_KEY = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(String(process.env.SECURITY_PHRASE_SECRET || FALLBACK_SECRET))
|
||||||
|
.digest();
|
||||||
|
|
||||||
|
const encryptValue = (value) => {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return {
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
content: encrypted.toString('base64'),
|
||||||
|
tag: tag.toString('base64')
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptValue = ({ content, iv, tag }) => {
|
||||||
|
if (!content || !iv || !tag) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, Buffer.from(iv, 'base64'));
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(content, 'base64')),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateSecurityPhraseWords = (count = 8) => {
|
||||||
|
const words = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const index = crypto.randomInt(0, WORD_LIST.length);
|
||||||
|
words.push(WORD_LIST[index]);
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encodeWords = (words = []) => {
|
||||||
|
return words
|
||||||
|
.map((word) => {
|
||||||
|
const index = WORD_LIST.indexOf(word);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error(`SECURITY_PHRASE_WORD_UNKNOWN:${word}`);
|
||||||
|
}
|
||||||
|
return String(index);
|
||||||
|
})
|
||||||
|
.join('-');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeWords = (encoded) => {
|
||||||
|
if (!encoded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return encoded.split('-').map((entry) => {
|
||||||
|
const index = Number(entry);
|
||||||
|
if (!Number.isFinite(index) || index < 0 || index >= WORD_LIST.length) {
|
||||||
|
throw new Error('SECURITY_PHRASE_DECODE_FAILED');
|
||||||
|
}
|
||||||
|
return WORD_LIST[index];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encryptSecurityPhrase = (words) => {
|
||||||
|
const encoded = encodeWords(words);
|
||||||
|
const encrypted = encryptValue(encoded);
|
||||||
|
return {
|
||||||
|
encrypted,
|
||||||
|
encoded
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptSecurityPhrase = (record) => {
|
||||||
|
if (!record) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const encoded = decryptValue(record);
|
||||||
|
if (!encoded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return decodeWords(encoded);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPhraseForDisplay = (words) => {
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < words.length; i += 4) {
|
||||||
|
rows.push(words.slice(i, i + 4));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeWord = (value) => {
|
||||||
|
if (!value && value !== 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value).trim().toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canonicalizeWords = (words = []) => {
|
||||||
|
return words
|
||||||
|
.map(normalizeWord)
|
||||||
|
.filter((word) => word.length > 0)
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractWordsFromString = (input) => {
|
||||||
|
if (!input && input !== 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const normalized = String(input)
|
||||||
|
.replace(/[\r\n\t]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return normalized.split(/\s+/).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canonicalizePhraseInput = (input) => {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return canonicalizeWords(input);
|
||||||
|
}
|
||||||
|
return canonicalizeWords(extractWordsFromString(input));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WORD_LIST;
|
||||||
+11
-4
@@ -6,8 +6,15 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "4001:4001"
|
- "4001:4001"
|
||||||
environment:
|
volumes:
|
||||||
PORTAINER_URL: "Your_Portainer_Server_Address"
|
- stackpulse_data:/app/backend/data
|
||||||
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
|
||||||
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_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:
|
||||||
|
stackpulse_data:
|
||||||
|
|||||||
+32
-3
@@ -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/*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
+24
-12
@@ -1,22 +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",
|
||||||
"socket.io-client": "^4.8.1"
|
"@material-tailwind/react": "2.1.4",
|
||||||
},
|
"apexcharts": "3.44.0",
|
||||||
|
"prop-types": "15.8.1",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-apexcharts": "1.4.1",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-router-dom": "6.17.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"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));
|
||||||
+12
-12
@@ -1,16 +1,16 @@
|
|||||||
import React from "react";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import Stacks from "./Stacks.jsx";
|
import { Dashboard, Auth } from "@/layouts";
|
||||||
|
import Setup from "@/pages/setup/setup";
|
||||||
|
|
||||||
export default function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white">
|
<Routes>
|
||||||
<header className="p-6 bg-gray-800 shadow-md">
|
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||||
<h1 className="text-2xl font-bold text-white">StackPulse</h1>
|
<Route path="/auth/*" element={<Auth />} />
|
||||||
<p className="text-gray-400 mt-1">Verwalte deine Docker Stacks</p>
|
<Route path="/setup" element={<Setup />} />
|
||||||
</header>
|
<Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
|
||||||
<main className="p-6">
|
</Routes>
|
||||||
<Stacks />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
@@ -1,112 +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("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const socket = io("/", { transports: ["websocket"] });
|
|
||||||
console.log("🔌 Socket connected");
|
|
||||||
|
|
||||||
socket.on("redeployStatus", async ({ stackId, status }) => {
|
|
||||||
console.log(`🔄 Stack ${stackId} Redeploy Status: ${status ? "running" : "finished"}`);
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
// Status nach Redeploy neu vom Server holen
|
|
||||||
try {
|
|
||||||
const res = await axios.get("/api/stacks");
|
|
||||||
setStacks(res.data.sort((a, b) => a.Name.localeCompare(b.Name)));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Fehler beim Aktualisieren nach Redeploy:", err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// UI direkt auf redeploying setzen
|
|
||||||
setStacks(prev =>
|
|
||||||
prev.map(stack =>
|
|
||||||
stack.Id === stackId ? { ...stack, redeploying: true } : stack
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => socket.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchStacks = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await axios.get("/api/stacks");
|
|
||||||
setStacks(res.data.map(stack => ({ ...stack, redeploying: stack.redeploying || false })));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("❌ Fehler beim Abrufen der Stacks:", err);
|
|
||||||
setError("Fehler beim Laden der Stacks");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStacks();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRedeploy = async (stackId) => {
|
|
||||||
setStacks(prev =>
|
|
||||||
prev.map(stack => stack.Id === stackId ? { ...stack, redeploying: true } : stack)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await axios.put(`/api/stacks/${stackId}/redeploy`);
|
|
||||||
// Socket.IO Event aktualisiert Status automatisch
|
|
||||||
} catch (err) {
|
|
||||||
console.error("❌ Fehler beim Redeploy:", err);
|
|
||||||
setStacks(prev =>
|
|
||||||
prev.map(stack => stack.Id === stackId ? { ...stack, redeploying: false } : stack)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-400">Lade Stacks...</p>;
|
|
||||||
if (error) return <p className="text-red-400">{error}</p>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
|
|
||||||
{stacks.map(stack => {
|
|
||||||
const isRedeploying = stack.redeploying;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={stack.Id}
|
|
||||||
className={`flex justify-between items-center p-5 rounded-xl shadow-lg transition
|
|
||||||
${isRedeploying ? "bg-gray-700 cursor-not-allowed" : "bg-gray-800 hover:bg-gray-700"}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleRedeploy(stack.Id)}
|
|
||||||
disabled={isRedeploying}
|
|
||||||
className={`px-5 py-2 rounded-lg font-medium transition
|
|
||||||
${isRedeploying ? "bg-orange-500 cursor-not-allowed" :
|
|
||||||
"bg-blue-500 hover:bg-blue-600"}`}
|
|
||||||
>
|
|
||||||
{isRedeploying ? "Redeploying" : "Redeploy"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{stacks.length === 0 && <p className="text-gray-400">Keine Stacks gefunden.</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const LEVEL_PRIORITY = {
|
||||||
|
none: 0,
|
||||||
|
read: 1,
|
||||||
|
full: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
AuthContext.displayName = "AuthContext";
|
||||||
|
|
||||||
|
const normalizePermissions = (raw) => {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return Object.entries(raw).reduce((acc, [key, value]) => {
|
||||||
|
if (typeof key !== "string" || !key) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
const normalizedValue = typeof value === "string" ? value : String(value ?? "").trim();
|
||||||
|
acc[key] = normalizedValue || "none";
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
user: null,
|
||||||
|
permissions: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastErrorCode: null,
|
||||||
|
initialized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setSession = useCallback((payload) => {
|
||||||
|
const user = payload?.user ?? null;
|
||||||
|
const permissions = normalizePermissions(payload?.permissions);
|
||||||
|
setState({
|
||||||
|
user,
|
||||||
|
permissions,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastErrorCode: null,
|
||||||
|
initialized: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSession = useCallback(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
user: null,
|
||||||
|
permissions: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastErrorCode: null,
|
||||||
|
initialized: prev.initialized || true,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshSession = useCallback(async () => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
lastErrorCode: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/session", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setState({
|
||||||
|
user: null,
|
||||||
|
permissions: {},
|
||||||
|
loading: false,
|
||||||
|
error: payload?.error ?? null,
|
||||||
|
lastErrorCode: payload?.error ?? null,
|
||||||
|
initialized: true,
|
||||||
|
});
|
||||||
|
return { ok: false, status: response.status, error: payload?.error ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(payload);
|
||||||
|
return { ok: true, data: payload };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.message || "NETWORK_ERROR";
|
||||||
|
setState({
|
||||||
|
user: null,
|
||||||
|
permissions: {},
|
||||||
|
loading: false,
|
||||||
|
error: message,
|
||||||
|
lastErrorCode: "NETWORK_ERROR",
|
||||||
|
initialized: true,
|
||||||
|
});
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
}, [setSession]);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
let capturedError = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
const logoutError = new Error(payload?.error || "LOGOUT_FAILED");
|
||||||
|
logoutError.code = payload?.error || `STATUS_${response.status}`;
|
||||||
|
throw logoutError;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
capturedError = err;
|
||||||
|
} finally {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
if (capturedError) {
|
||||||
|
throw capturedError;
|
||||||
|
}
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
const hasPermission = useCallback(
|
||||||
|
(permissionKey, requiredLevel = "full") => {
|
||||||
|
if (!permissionKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (state.user?.isSuperuser) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const current = state.permissions?.[permissionKey] ?? "none";
|
||||||
|
const currentPriority = LEVEL_PRIORITY[current] ?? 0;
|
||||||
|
const requiredPriority = LEVEL_PRIORITY[requiredLevel] ?? LEVEL_PRIORITY.full;
|
||||||
|
return currentPriority >= requiredPriority;
|
||||||
|
},
|
||||||
|
[state.permissions, state.user]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user: state.user,
|
||||||
|
permissions: state.permissions,
|
||||||
|
loading: state.loading,
|
||||||
|
error: state.error,
|
||||||
|
lastErrorCode: state.lastErrorCode,
|
||||||
|
initialized: state.initialized,
|
||||||
|
isAuthenticated: Boolean(state.user),
|
||||||
|
refreshSession,
|
||||||
|
setSession,
|
||||||
|
clearSession,
|
||||||
|
logout,
|
||||||
|
hasPermission,
|
||||||
|
}),
|
||||||
|
[state, refreshSession, setSession, clearSession, logout, hasPermission]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContextDisplayName = "/src/components/AuthProvider.jsx";
|
||||||
@@ -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,226 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Routes, Route, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Cog6ToothIcon } from "@heroicons/react/24/solid";
|
||||||
|
import { IconButton } from "@material-tailwind/react";
|
||||||
|
import {
|
||||||
|
Sidenav,
|
||||||
|
DashboardNavbar,
|
||||||
|
Configurator,
|
||||||
|
Footer,
|
||||||
|
} from "@/widgets/layout";
|
||||||
|
import routes from "@/routes";
|
||||||
|
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
|
||||||
|
import { UserGroupDetail } from "@/pages/dashboard/userGroupDetail.jsx";
|
||||||
|
import { SecurityPhrase } from "@/pages/dashboard/securityPhrase.jsx";
|
||||||
|
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
import PermissionGate from "@/components/PermissionGate.jsx";
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [controller, dispatch] = useMaterialTailwindController();
|
||||||
|
const { sidenavType } = controller;
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [setupChecked, setSetupChecked] = useState(false);
|
||||||
|
const [setupIncomplete, setSetupIncomplete] = useState(true);
|
||||||
|
const {
|
||||||
|
initialized: authInitialized,
|
||||||
|
loading: authLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
refreshSession,
|
||||||
|
user
|
||||||
|
} = useAuth();
|
||||||
|
|
||||||
|
const checkSetupStatus = useCallback(async () => {
|
||||||
|
setSetupChecked(false);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/setup/status", { credentials: "include" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("STATUS_REQUEST_FAILED");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setSetupIncomplete(!data.setupComplete);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚠️ [Setup] Statusprüfung fehlgeschlagen:", error);
|
||||||
|
setSetupIncomplete(true);
|
||||||
|
} finally {
|
||||||
|
setSetupChecked(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSetupStatus();
|
||||||
|
}, [checkSetupStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setupChecked || setupIncomplete) return;
|
||||||
|
if (authInitialized || authLoading) return;
|
||||||
|
refreshSession();
|
||||||
|
}, [setupChecked, setupIncomplete, authInitialized, authLoading, refreshSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setupChecked) return;
|
||||||
|
|
||||||
|
if (setupIncomplete) {
|
||||||
|
if (location.pathname !== "/setup") {
|
||||||
|
navigate("/setup", { replace: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authInitialized) return;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
if (location.pathname !== "/auth/sign-in") {
|
||||||
|
navigate("/auth/sign-in", { replace: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresSecurityPhrase = Boolean(user?.requiresSecurityPhraseDownload);
|
||||||
|
const isSecurityPhrasePath = location.pathname === "/dashboard/security-phrase";
|
||||||
|
|
||||||
|
if (requiresSecurityPhrase && !isSecurityPhrasePath) {
|
||||||
|
navigate("/dashboard/security-phrase", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requiresSecurityPhrase && isSecurityPhrasePath) {
|
||||||
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
setupChecked,
|
||||||
|
setupIncomplete,
|
||||||
|
authInitialized,
|
||||||
|
isAuthenticated,
|
||||||
|
location.pathname,
|
||||||
|
navigate,
|
||||||
|
user?.requiresSecurityPhraseDownload
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setupChecked || setupIncomplete) return;
|
||||||
|
if (!authInitialized || authLoading) return;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
if (location.pathname !== "/auth/sign-in") {
|
||||||
|
navigate("/auth/sign-in", { replace: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthPath = location.pathname.startsWith("/auth/");
|
||||||
|
const isLogoutPath = location.pathname === "/auth/logout";
|
||||||
|
|
||||||
|
if (location.pathname === "/setup" || (isAuthPath && !isLogoutPath)) {
|
||||||
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
setupChecked,
|
||||||
|
setupIncomplete,
|
||||||
|
authInitialized,
|
||||||
|
authLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
location.pathname,
|
||||||
|
navigate
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!setupChecked) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||||
|
<span className="text-blue-gray-500">Pruefe Systemkonfiguration ...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setupIncomplete) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||||
|
<span className="text-blue-gray-500">Setup erforderlich ...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authInitialized || authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||||
|
<span className="text-blue-gray-500">Pruefe Anmeldung ...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||||
|
<span className="text-blue-gray-500">Weiterleitung zur Anmeldung ...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSecurityPhraseRoute = location.pathname === "/dashboard/security-phrase";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-blue-gray-50/50">
|
||||||
|
{!isSecurityPhraseRoute && (
|
||||||
|
<Sidenav
|
||||||
|
routes={routes}
|
||||||
|
brandImg={
|
||||||
|
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={isSecurityPhraseRoute ? "" : "p-4 xl:ml-80"}>
|
||||||
|
{!isSecurityPhraseRoute && (
|
||||||
|
<>
|
||||||
|
<DashboardNavbar />
|
||||||
|
<Configurator />
|
||||||
|
<IconButton
|
||||||
|
size="lg"
|
||||||
|
color="white"
|
||||||
|
className="fixed bottom-8 right-8 z-40 rounded-full shadow-blue-gray-900/10 xl:hidden"
|
||||||
|
ripple={false}
|
||||||
|
onClick={() => setOpenConfigurator(dispatch, true)}
|
||||||
|
>
|
||||||
|
<Cog6ToothIcon className="h-5 w-5" />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Routes>
|
||||||
|
{routes.map(
|
||||||
|
({ layout, pages }) =>
|
||||||
|
layout === "dashboard" &&
|
||||||
|
pages.map(({ path, element }) => (
|
||||||
|
<Route exact path={path} element={element} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Route path="security-phrase" element={<SecurityPhrase />} />
|
||||||
|
<Route
|
||||||
|
path="users/:userId"
|
||||||
|
element={
|
||||||
|
<PermissionGate permission="users-access" requiredLevel="read">
|
||||||
|
<UserDetails />
|
||||||
|
</PermissionGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="usergroups/:groupId"
|
||||||
|
element={
|
||||||
|
<PermissionGate permission="user-groups-access" requiredLevel="read">
|
||||||
|
<UserGroupDetail />
|
||||||
|
</PermissionGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
{!isSecurityPhraseRoute && (
|
||||||
|
<div className="text-blue-gray-600">
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Dashboard.displayName = "/src/layout/dashboard.jsx";
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "@/layouts/dashboard";
|
||||||
|
export * from "@/layouts/auth";
|
||||||
+38
-3
@@ -1,10 +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.jsx";
|
import App from "./App";
|
||||||
import './index.css'; // Tailwind CSS
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { ThemeProvider } from "@material-tailwind/react";
|
||||||
|
import { MaterialTailwindControllerProvider } from "@/components";
|
||||||
|
import "./tailwind.css";
|
||||||
|
import ToastProvider from "@/components/ToastProvider.jsx";
|
||||||
|
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
|
||||||
|
import PageProvider from "@/components/PageProvider.jsx";
|
||||||
|
import { AuthProvider } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
|
axios.defaults.withCredentials = true;
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<MaintenanceProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<PageProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<MaterialTailwindControllerProvider>
|
||||||
|
<App />
|
||||||
|
</MaterialTailwindControllerProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</PageProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</MaintenanceProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</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,8 @@
|
|||||||
|
export * from "@/pages/dashboard/stacks";
|
||||||
|
export * from "@/pages/dashboard/maintenance";
|
||||||
|
export * from "@/pages/dashboard/logs";
|
||||||
|
export * from "@/pages/dashboard/users";
|
||||||
|
export * from "@/pages/dashboard/usergroups";
|
||||||
|
export * from "@/pages/dashboard/userDetails";
|
||||||
|
export * from "@/pages/dashboard/userGroupDetail";
|
||||||
|
export * from "@/pages/dashboard/securityPhrase";
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,907 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
Select,
|
||||||
|
Option,
|
||||||
|
Input,
|
||||||
|
Switch
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
|
||||||
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
import { platformSettingsData } from "@/data";
|
||||||
|
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||||
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
|
const _ = AVATAR_COLORS.join(" ");
|
||||||
|
|
||||||
|
const UPDATE_STAGE_LABELS = {
|
||||||
|
initializing: "Vorbereitung",
|
||||||
|
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||||
|
"executing-script": "Skript wird ausgeführt",
|
||||||
|
waiting: "Warte auf Portainer",
|
||||||
|
completed: "Abgeschlossen",
|
||||||
|
failed: "Fehlgeschlagen"
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUserGroups = (rawGroups) => {
|
||||||
|
if (!Array.isArray(rawGroups)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return rawGroups
|
||||||
|
.map((group) => {
|
||||||
|
if (group && typeof group === "object") {
|
||||||
|
const id = Number(group.id);
|
||||||
|
const name = typeof group.name === "string" ? group.name : "";
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: Number.isFinite(id) ? id : null,
|
||||||
|
name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof group === "string") {
|
||||||
|
const name = group.trim();
|
||||||
|
return name ? { id: null, name } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapUser = (item) => ({
|
||||||
|
id: item?.id ?? null,
|
||||||
|
username: item?.username || "",
|
||||||
|
email: item?.email || "",
|
||||||
|
isActive: Boolean(item?.isActive),
|
||||||
|
avatarColor: item?.avatarColor || null,
|
||||||
|
lastLogin: item?.lastLogin || null,
|
||||||
|
createdAt: item?.createdAt || null,
|
||||||
|
updatedAt: item?.updatedAt || null,
|
||||||
|
groups: normalizeUserGroups(item?.groups),
|
||||||
|
securityPhraseDownloadedAt: item?.securityPhraseDownloadedAt || null
|
||||||
|
});
|
||||||
|
|
||||||
|
const extractPrimaryGroupId = (user) => {
|
||||||
|
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const firstValid = user.groups
|
||||||
|
.map((group) => Number(group.id))
|
||||||
|
.find((id) => Number.isFinite(id) && id > 0);
|
||||||
|
return Number.isFinite(firstValid) ? firstValid : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildInitialFormValues = (user) => {
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
groupId: null,
|
||||||
|
avatarColor: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryGroupId = extractPrimaryGroupId(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: user.username || "",
|
||||||
|
email: user.email || "",
|
||||||
|
password: "",
|
||||||
|
groupId: Number.isFinite(primaryGroupId) ? primaryGroupId : null,
|
||||||
|
avatarColor: user.avatarColor || ""
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserDetails() {
|
||||||
|
const { userId } = useParams();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||||
|
const { hasPermission, user: authUser } = useAuth();
|
||||||
|
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||||
|
const initialFormValuesRef = useRef(buildInitialFormValues(null));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
const [availableGroups, setAvailableGroups] = useState([]);
|
||||||
|
const [groupsLoading, setGroupsLoading] = useState(false);
|
||||||
|
const [groupsError, setGroupsError] = useState("");
|
||||||
|
const [savingUser, setSavingUser] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
|
const [securityPhraseWords, setSecurityPhraseWords] = useState([]);
|
||||||
|
const [securityPhraseDownloadedAt, setSecurityPhraseDownloadedAt] = useState(null);
|
||||||
|
const [securityPhraseLoading, setSecurityPhraseLoading] = useState(false);
|
||||||
|
const [securityPhraseError, setSecurityPhraseError] = useState("");
|
||||||
|
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
|
||||||
|
|
||||||
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
|
const updateRunning = Boolean(updateState?.running);
|
||||||
|
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||||
|
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||||
|
|
||||||
|
const canEditUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "full"));
|
||||||
|
const canReadUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "read"));
|
||||||
|
const canManageSecurityPhrase = Boolean(authUser?.isSuperuser || hasPermission("users-security-phrase", "full"));
|
||||||
|
const canRenewSecurityPhrase = canManageSecurityPhrase;
|
||||||
|
|
||||||
|
if (!canReadUsers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuperuserUser = useMemo(() => {
|
||||||
|
if (!Array.isArray(user?.groups)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser");
|
||||||
|
}, [user]);
|
||||||
|
const canCurrentUserEditSuperuser = authUser?.isSuperuser && user?.id && Number(authUser.id) === Number(user.id);
|
||||||
|
const superuserFieldsLocked = isSuperuserUser && !canCurrentUserEditSuperuser;
|
||||||
|
|
||||||
|
const numericUserId = useMemo(() => {
|
||||||
|
const asNumber = Number(userId);
|
||||||
|
return Number.isFinite(asNumber) ? asNumber : null;
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const fetchUserDetails = useCallback(async () => {
|
||||||
|
if (!numericUserId) {
|
||||||
|
setError("Ungültige Benutzer-ID.");
|
||||||
|
setUser(null);
|
||||||
|
setFormValues(buildInitialFormValues(null));
|
||||||
|
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||||
|
setHasLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${numericUserId}`);
|
||||||
|
const item = mapUser(response.data?.item);
|
||||||
|
if (!item.id) {
|
||||||
|
throw new Error("USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
setUser(item);
|
||||||
|
setSecurityPhraseWords([]);
|
||||||
|
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
|
||||||
|
setSecurityPhraseError("");
|
||||||
|
const initialValues = buildInitialFormValues(item);
|
||||||
|
initialFormValuesRef.current = { ...initialValues };
|
||||||
|
setFormValues(initialValues);
|
||||||
|
setSaveError("");
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Benutzerdetails konnten nicht geladen werden.";
|
||||||
|
|
||||||
|
if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der angeforderte Benutzer wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die angegebene Benutzer-ID ist ungültig.";
|
||||||
|
} else if (err.response?.status === 404) {
|
||||||
|
message = "Der angeforderte Benutzer existiert nicht.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(null);
|
||||||
|
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||||
|
setFormValues(buildInitialFormValues(null));
|
||||||
|
setError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Fehler beim Laden",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
|
}
|
||||||
|
}, [numericUserId, showToast]);
|
||||||
|
|
||||||
|
const fetchAvailableGroups = useCallback(async () => {
|
||||||
|
setGroupsLoading(true);
|
||||||
|
setGroupsError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/groups");
|
||||||
|
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||||
|
const normalized = items
|
||||||
|
.map((item) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
name: item.name || "",
|
||||||
|
description: item.description || "",
|
||||||
|
memberCount: Number.isFinite(Number(item.memberCount)) ? Number(item.memberCount) : 0
|
||||||
|
}))
|
||||||
|
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
|
||||||
|
setAvailableGroups(normalized);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Benutzergruppen konnten nicht geladen werden.";
|
||||||
|
setGroupsError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Benutzergruppen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setGroupsLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserDetails();
|
||||||
|
}, [fetchUserDetails]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAvailableGroups();
|
||||||
|
}, [fetchAvailableGroups]);
|
||||||
|
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!hasLoaded || !user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = initialFormValuesRef.current;
|
||||||
|
if (!initial) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialUsername = initial.username || "";
|
||||||
|
const currentUsername = formValues.username || "";
|
||||||
|
|
||||||
|
const initialEmail = initial.email || "";
|
||||||
|
const currentEmail = formValues.email || "";
|
||||||
|
|
||||||
|
const initialGroupId = Number.isFinite(initial.groupId) ? Number(initial.groupId) : null;
|
||||||
|
const currentGroupId = Number.isFinite(formValues.groupId) ? Number(formValues.groupId) : null;
|
||||||
|
|
||||||
|
const initialAvatar = initial.avatarColor || "";
|
||||||
|
const currentAvatar = formValues.avatarColor || "";
|
||||||
|
|
||||||
|
const passwordChanged = Boolean(formValues.password && formValues.password.trim().length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
initialUsername !== currentUsername ||
|
||||||
|
initialEmail !== currentEmail ||
|
||||||
|
(!isSuperuserUser && initialGroupId !== currentGroupId) ||
|
||||||
|
initialAvatar !== currentAvatar ||
|
||||||
|
passwordChanged
|
||||||
|
);
|
||||||
|
}, [formValues, hasLoaded, user, isSuperuserUser]);
|
||||||
|
|
||||||
|
const renderSelectedAvatarLabel = useCallback(
|
||||||
|
(element) => {
|
||||||
|
if (element?.props?.children) {
|
||||||
|
return element.props.children;
|
||||||
|
}
|
||||||
|
if (!formValues.avatarColor) {
|
||||||
|
return "Standardfarbe";
|
||||||
|
}
|
||||||
|
return formValues.avatarColor;
|
||||||
|
},
|
||||||
|
[formValues.avatarColor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUsernameChange = useCallback((event) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
|
const { value } = event.target;
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
username: value
|
||||||
|
}));
|
||||||
|
}, [canEditUsers]);
|
||||||
|
|
||||||
|
const handleEmailChange = useCallback((event) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
|
const { value } = event.target;
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
email: value
|
||||||
|
}));
|
||||||
|
}, [canEditUsers]);
|
||||||
|
|
||||||
|
const handlePasswordChange = useCallback((event) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
|
const { value } = event.target;
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: value
|
||||||
|
}));
|
||||||
|
}, [canEditUsers]);
|
||||||
|
|
||||||
|
const handleGroupChange = useCallback((value) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
|
if (!value) {
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
groupId: null
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numeric = Number(value);
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
groupId: Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
||||||
|
}));
|
||||||
|
}, [canEditUsers]);
|
||||||
|
|
||||||
|
const handleAvatarColorChange = useCallback((value) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
avatarColor: value || ""
|
||||||
|
}));
|
||||||
|
}, [canEditUsers]);
|
||||||
|
|
||||||
|
const handleSaveUser = useCallback(async () => {
|
||||||
|
if (!canEditUsers || !user || !hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingUser(true);
|
||||||
|
setSaveError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
username: formValues.username,
|
||||||
|
email: formValues.email,
|
||||||
|
password: formValues.password,
|
||||||
|
avatarColor: formValues.avatarColor
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSuperuserUser) {
|
||||||
|
payload.groupId = formValues.groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.put(`/api/users/${user.id}`, payload);
|
||||||
|
const updatedUser = mapUser(response.data?.item || response.data?.user);
|
||||||
|
setUser(updatedUser);
|
||||||
|
const nextInitial = buildInitialFormValues(updatedUser);
|
||||||
|
initialFormValuesRef.current = { ...nextInitial };
|
||||||
|
setFormValues(nextInitial);
|
||||||
|
setGroupsError("");
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Benutzer gespeichert",
|
||||||
|
description: "Die Änderungen wurden erfolgreich gespeichert."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Die Benutzerdaten konnten nicht gespeichert werden.";
|
||||||
|
|
||||||
|
if (serverError === "USERNAME_REQUIRED") {
|
||||||
|
message = "Bitte einen Benutzernamen angeben.";
|
||||||
|
} else if (serverError === "USERNAME_TAKEN") {
|
||||||
|
message = "Der Benutzername wird bereits verwendet.";
|
||||||
|
} else if (serverError === "INVALID_EMAIL") {
|
||||||
|
message = "Bitte eine gültige E-Mail-Adresse eingeben.";
|
||||||
|
} else if (serverError === "EMAIL_TAKEN") {
|
||||||
|
message = "Die E-Mail-Adresse wird bereits verwendet.";
|
||||||
|
} else if (serverError === "INVALID_PASSWORD") {
|
||||||
|
message = "Das Passwort ist ungültig.";
|
||||||
|
} else if (serverError === "PASSWORD_TOO_SHORT") {
|
||||||
|
message = "Das Passwort muss mindestens 8 Zeichen enthalten.";
|
||||||
|
} else if (serverError === "INVALID_AVATAR_COLOR") {
|
||||||
|
message = "Bitte eine gültige Avatar-Farbe auswählen.";
|
||||||
|
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||||
|
message = "Die ausgewählte Benutzergruppe existiert nicht mehr.";
|
||||||
|
setGroupsError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Speichern fehlgeschlagen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingUser(false);
|
||||||
|
}
|
||||||
|
}, [canEditUsers, user, hasChanges, formValues, showToast, isSuperuserUser]);
|
||||||
|
|
||||||
|
const fetchSecurityPhrase = useCallback(async () => {
|
||||||
|
if (!canManageSecurityPhrase || !numericUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSecurityPhraseLoading(true);
|
||||||
|
setSecurityPhraseError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${numericUserId}/security-phrase`);
|
||||||
|
const words = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
|
||||||
|
setSecurityPhraseWords(words);
|
||||||
|
setSecurityPhraseDownloadedAt(response.data?.item?.downloadedAt || null);
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Sicherheitsschlüssel konnte nicht geladen werden.";
|
||||||
|
|
||||||
|
if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der Benutzer wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die Benutzer-ID ist ungültig.";
|
||||||
|
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
|
||||||
|
message = "Keine Berechtigung zum Anzeigen des Sicherheitsschlüssels.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setSecurityPhraseError(message);
|
||||||
|
setSecurityPhraseWords([]);
|
||||||
|
setSecurityPhraseDownloadedAt(null);
|
||||||
|
} finally {
|
||||||
|
setSecurityPhraseLoading(false);
|
||||||
|
}
|
||||||
|
}, [canManageSecurityPhrase, numericUserId]);
|
||||||
|
|
||||||
|
const handleReloadSecurityPhrase = useCallback(() => {
|
||||||
|
if (!canRenewSecurityPhrase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (securityPhraseLoading || renewingSecurityPhrase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchSecurityPhrase();
|
||||||
|
}, [canRenewSecurityPhrase, fetchSecurityPhrase, renewingSecurityPhrase, securityPhraseLoading]);
|
||||||
|
|
||||||
|
const handleRenewSecurityPhrase = useCallback(async () => {
|
||||||
|
if (!canRenewSecurityPhrase || !numericUserId || maintenanceLocked || renewingSecurityPhrase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRenewingSecurityPhrase(true);
|
||||||
|
setSecurityPhraseError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/users/${numericUserId}/security-phrase/renew`);
|
||||||
|
const words = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
|
||||||
|
setSecurityPhraseWords(words);
|
||||||
|
setSecurityPhraseDownloadedAt(response.data?.item?.downloadedAt || null);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Sicherheitsschlüssel erneuert",
|
||||||
|
description: "Der Benutzer muss den neuen Schlüssel erneut herunterladen."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Der Sicherheitsschlüssel konnte nicht erneuert werden.";
|
||||||
|
|
||||||
|
if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der Benutzer wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die Benutzer-ID ist ungültig.";
|
||||||
|
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
|
||||||
|
message = "Keine Berechtigung zum Erneuern des Sicherheitsschlüssels.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setSecurityPhraseError(message);
|
||||||
|
} finally {
|
||||||
|
setRenewingSecurityPhrase(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
canRenewSecurityPhrase,
|
||||||
|
numericUserId,
|
||||||
|
maintenanceLocked,
|
||||||
|
renewingSecurityPhrase,
|
||||||
|
showToast
|
||||||
|
]);
|
||||||
|
|
||||||
|
const avatarLabel = useMemo(() => {
|
||||||
|
const source = (formValues.username || formValues.email || "").trim();
|
||||||
|
if (!source) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
return source.charAt(0).toUpperCase();
|
||||||
|
}, [formValues.username, formValues.email]);
|
||||||
|
|
||||||
|
const avatarColorClass = useMemo(() => {
|
||||||
|
if (formValues.avatarColor) {
|
||||||
|
return formValues.avatarColor;
|
||||||
|
}
|
||||||
|
return user?.avatarColor || "";
|
||||||
|
}, [formValues.avatarColor, user]);
|
||||||
|
|
||||||
|
const securityPhraseRows = useMemo(() => {
|
||||||
|
if (!Array.isArray(securityPhraseWords) || securityPhraseWords.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
for (let index = 0; index < securityPhraseWords.length; index += 4) {
|
||||||
|
rows.push(securityPhraseWords.slice(index, index + 4));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [securityPhraseWords]);
|
||||||
|
|
||||||
|
const securityPhraseStatus = useMemo(() => {
|
||||||
|
if (!securityPhraseDownloadedAt) {
|
||||||
|
return {
|
||||||
|
text: "Noch nicht heruntergeladen",
|
||||||
|
tone: "text-red-500"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const date = new Date(securityPhraseDownloadedAt);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return {
|
||||||
|
text: "Zuletzt heruntergeladen: unbekannt",
|
||||||
|
tone: "text-blue-gray-500"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: `Zuletzt heruntergeladen: ${date.toLocaleString("de-DE")}`,
|
||||||
|
tone: "text-blue-gray-500"
|
||||||
|
};
|
||||||
|
}, [securityPhraseDownloadedAt]);
|
||||||
|
|
||||||
|
const filteredGroups = useMemo(() => {
|
||||||
|
if (!Array.isArray(availableGroups)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return availableGroups.filter((group) => (group?.name || "").toLowerCase() !== "superuser");
|
||||||
|
}, [availableGroups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSuperuserUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentGroupId = formValues.groupId ? Number(formValues.groupId) : null;
|
||||||
|
const stillExists = filteredGroups.some((group) => Number(group.id) === currentGroupId);
|
||||||
|
if (!stillExists) {
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
groupId: null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [filteredGroups, formValues.groupId, isSuperuserUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManageSecurityPhrase || !numericUserId) {
|
||||||
|
setSecurityPhraseWords([]);
|
||||||
|
setSecurityPhraseDownloadedAt(null);
|
||||||
|
setSecurityPhraseError("");
|
||||||
|
setSecurityPhraseLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSecurityPhrase();
|
||||||
|
}, [hasLoaded, canManageSecurityPhrase, fetchSecurityPhrase, numericUserId]);
|
||||||
|
|
||||||
|
const inputDisabled = maintenanceLocked || savingUser || !user || !canEditUsers;
|
||||||
|
const selectDisabled = maintenanceLocked || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
|
||||||
|
const avatarSelectDisabled = maintenanceLocked || savingUser || !user || !canEditUsers || superuserFieldsLocked;
|
||||||
|
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-12 flex flex-col gap-12">
|
||||||
|
{(maintenanceActive || updateRunning) && (
|
||||||
|
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>
|
||||||
|
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||||
|
</span>
|
||||||
|
{updateRunning && (
|
||||||
|
<span className="text-xs text-indigo-900">
|
||||||
|
Phase: {updateStageLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
||||||
|
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||||
|
</div>
|
||||||
|
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||||
|
<CardBody className="p-4">
|
||||||
|
<div className="mb-10 flex flex-wrap items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div
|
||||||
|
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${avatarColorClass}`}
|
||||||
|
aria-label={formValues.username || "Benutzeravatar"}
|
||||||
|
>
|
||||||
|
{avatarLabel}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography variant="h5" color="blue-gray">
|
||||||
|
{formValues.username || "–"}
|
||||||
|
</Typography>
|
||||||
|
<Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
|
||||||
|
{formValues.email || "–"}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasChanges && (
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleSaveUser}
|
||||||
|
disabled={maintenanceLocked || savingUser}
|
||||||
|
>
|
||||||
|
{savingUser ? "Speichert ..." : "Änderungen speichern"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading && !user && (
|
||||||
|
<div className="mb-6 flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
<span>Benutzerdaten werden geladen ...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveError && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||||
|
Allgemeine Einstellungen
|
||||||
|
</Typography>
|
||||||
|
{(isSuperuserUser) && (
|
||||||
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Systemgruppe – Name und Beschreibung sind geschützt.
|
||||||
|
{superuserFieldsLocked && (
|
||||||
|
<span className="mt-1 block text-xs text-amber-600">
|
||||||
|
Nur der Superuser selbst kann Benutzername, E-Mail-Adresse und Passwort ändern.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Benutzername
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
value={formValues.username}
|
||||||
|
onChange={handleUsernameChange}
|
||||||
|
placeholder="Benutzername"
|
||||||
|
disabled={inputDisabled || superuserFieldsLocked}
|
||||||
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
|
labelProps={{
|
||||||
|
className: "before:content-none after:content-none"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
E-Mail-Adresse
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={formValues.email}
|
||||||
|
onChange={handleEmailChange}
|
||||||
|
placeholder="benutzer@example.com"
|
||||||
|
disabled={inputDisabled || superuserFieldsLocked}
|
||||||
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
|
labelProps={{
|
||||||
|
className: "before:content-none after:content-none"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Neues Passwort
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={formValues.password}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
placeholder="Passwort setzen"
|
||||||
|
disabled={inputDisabled || superuserFieldsLocked}
|
||||||
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
|
labelProps={{
|
||||||
|
className: "before:content-none after:content-none"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography className="mt-1 text-xs text-blue-gray-400">
|
||||||
|
Das Passwort wird nur geändert, wenn ein neuer Wert eingetragen wird.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Globale Rolle
|
||||||
|
</Typography>
|
||||||
|
{isSuperuserUser ? (
|
||||||
|
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Die Rolle für den Superuser kann nicht geändert werden.
|
||||||
|
</Typography>
|
||||||
|
) : groupsLoading && availableGroups.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
<span>Benutzergruppen werden geladen ...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredGroups.length === 0 ? (
|
||||||
|
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Es sind keine weiteren Benutzergruppen vorhanden. Bitte lege zunächst eine neue Gruppe an.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
label="Benutzergruppe wählen"
|
||||||
|
value={groupSelectValue}
|
||||||
|
onChange={handleGroupChange}
|
||||||
|
disabled={selectDisabled}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{filteredGroups.map((group) => (
|
||||||
|
<Option key={group.id} value={String(group.id)}>
|
||||||
|
{group.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Avatar-Farbe
|
||||||
|
</Typography>
|
||||||
|
<Select
|
||||||
|
label="Avatar-Farbe auswählen"
|
||||||
|
variant="outlined"
|
||||||
|
value={formValues.avatarColor}
|
||||||
|
onChange={handleAvatarColorChange}
|
||||||
|
disabled={avatarSelectDisabled}
|
||||||
|
selected={renderSelectedAvatarLabel}
|
||||||
|
>
|
||||||
|
<Option value="">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-4 w-4 rounded border border-blue-gray-100" />
|
||||||
|
<span className="text-xs">Keine</span>
|
||||||
|
</span>
|
||||||
|
</Option>
|
||||||
|
{AVATAR_COLORS.map((color) => (
|
||||||
|
<Option key={color} value={color}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
|
||||||
|
<span className="text-xs">{color}</span>
|
||||||
|
</span>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-3">
|
||||||
|
Platform Settings
|
||||||
|
</Typography>
|
||||||
|
<div className="flex flex-col gap-12">
|
||||||
|
{platformSettingsData.map(({ title, options }) => (
|
||||||
|
<div key={title}>
|
||||||
|
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{options.map(({ checked, label }) => (
|
||||||
|
<Switch
|
||||||
|
key={label}
|
||||||
|
id={label}
|
||||||
|
label={label}
|
||||||
|
defaultChecked={checked}
|
||||||
|
labelProps={{
|
||||||
|
className: "text-sm font-normal text-blue-gray-500",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManageSecurityPhrase && (
|
||||||
|
<div className="mt-10 border-t border-blue-gray-50 pt-8">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Typography variant="h6" color="blue-gray">
|
||||||
|
Sicherheitsschlüssel
|
||||||
|
</Typography>
|
||||||
|
<Typography className={`text-sm ${securityPhraseStatus.tone}`}>
|
||||||
|
{securityPhraseStatus.text}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="blue"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleReloadSecurityPhrase}
|
||||||
|
disabled={
|
||||||
|
!canRenewSecurityPhrase ||
|
||||||
|
maintenanceLocked ||
|
||||||
|
securityPhraseLoading ||
|
||||||
|
renewingSecurityPhrase
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleRenewSecurityPhrase}
|
||||||
|
disabled={
|
||||||
|
!canRenewSecurityPhrase ||
|
||||||
|
maintenanceLocked ||
|
||||||
|
securityPhraseLoading ||
|
||||||
|
renewingSecurityPhrase
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{renewingSecurityPhrase ? "Erneuert ..." : "Schlüssel erneuern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{securityPhraseLoading ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
<span>Sicherheitsschlüssel wird geladen ...</span>
|
||||||
|
</div>
|
||||||
|
) : securityPhraseError ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<span>{securityPhraseError}</span>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
color="red"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleReloadSecurityPhrase}
|
||||||
|
disabled={
|
||||||
|
!canRenewSecurityPhrase ||
|
||||||
|
maintenanceLocked ||
|
||||||
|
securityPhraseLoading ||
|
||||||
|
renewingSecurityPhrase
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Erneut laden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : securityPhraseRows.length > 0 ? (
|
||||||
|
<div className="rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 p-4">
|
||||||
|
<div className="space-y-2 text-center font-mono text-base tracking-wide text-blue-gray-900 md:text-lg">
|
||||||
|
{securityPhraseRows.map((row, rowIndex) => (
|
||||||
|
<div
|
||||||
|
key={`security-phrase-row-${rowIndex}`}
|
||||||
|
className="flex flex-wrap items-center justify-center gap-4"
|
||||||
|
>
|
||||||
|
{row.map((word, wordIndex) => (
|
||||||
|
<span
|
||||||
|
key={`security-phrase-${rowIndex}-${wordIndex}`}
|
||||||
|
className="uppercase"
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography className="text-sm text-blue-gray-500">
|
||||||
|
Kein Sicherheitsschlüssel verfügbar.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography className="mt-3 text-sm text-blue-gray-500">
|
||||||
|
Nach dem Erneuern muss der Benutzer den neuen Sicherheitsschlüssel erneut herunterladen.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</CardBody>
|
||||||
|
</Card></div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserDetails;
|
||||||
@@ -0,0 +1,960 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
Select,
|
||||||
|
Option,
|
||||||
|
Input,
|
||||||
|
Chip,
|
||||||
|
Radio,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemPrefix
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
|
||||||
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||||
|
|
||||||
|
const _ = AVATAR_COLORS.join(" ");
|
||||||
|
|
||||||
|
const UPDATE_STAGE_LABELS = {
|
||||||
|
initializing: "Vorbereitung",
|
||||||
|
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||||
|
"executing-script": "Skript wird ausgeführt",
|
||||||
|
waiting: "Warte auf Portainer",
|
||||||
|
completed: "Abgeschlossen",
|
||||||
|
failed: "Fehlgeschlagen"
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapGroup = (item) => ({
|
||||||
|
id: item?.id ?? null,
|
||||||
|
name: item?.name || "",
|
||||||
|
description: item?.description || "",
|
||||||
|
avatarColor: item?.avatarColor || null,
|
||||||
|
createdAt: item?.createdAt || null,
|
||||||
|
updatedAt: item?.updatedAt || null,
|
||||||
|
memberCount: Number.isFinite(Number(item?.memberCount)) ? Number(item.memberCount) : 0,
|
||||||
|
members: Array.isArray(item?.members)
|
||||||
|
? item.members
|
||||||
|
.map((member) => ({
|
||||||
|
id: member?.id ?? null,
|
||||||
|
username: member?.username || ""
|
||||||
|
}))
|
||||||
|
.filter((member) => member.username)
|
||||||
|
: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildInitialFormValues = (group) => {
|
||||||
|
if (!group) {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
avatarColor: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: group.name || "",
|
||||||
|
description: group.description || "",
|
||||||
|
avatarColor: group.avatarColor || ""
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS = [
|
||||||
|
{ value: "full", label: "Vollzugriff" },
|
||||||
|
{ value: "read", label: "Nur lesen" },
|
||||||
|
{ value: "none", label: "Kein Zugriff" }
|
||||||
|
];
|
||||||
|
const LEVEL_PRIORITY = {
|
||||||
|
none: 0,
|
||||||
|
read: 1,
|
||||||
|
full: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePermissionLevel = (key, value) =>
|
||||||
|
key === "maintenance-server-delete" && value !== "full" ? "none" : value;
|
||||||
|
|
||||||
|
export function UserGroupDetail() {
|
||||||
|
const { groupId } = useParams();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||||
|
const { hasPermission, user: authUser } = useAuth();
|
||||||
|
|
||||||
|
const [group, setGroup] = useState(null);
|
||||||
|
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||||
|
const initialFormValuesRef = useRef(buildInitialFormValues(null));
|
||||||
|
const [initialPermissionSelection, setInitialPermissionSelection] = useState({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
const [savingGroup, setSavingGroup] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
|
const [permissionSections, setPermissionSections] = useState([]);
|
||||||
|
const [permissionSelection, setPermissionSelection] = useState({});
|
||||||
|
const [permissionDefaults, setPermissionDefaults] = useState({});
|
||||||
|
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
||||||
|
const [permissionsError, setPermissionsError] = useState("");
|
||||||
|
|
||||||
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
|
const updateRunning = Boolean(updateState?.running);
|
||||||
|
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||||
|
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||||
|
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
|
||||||
|
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
|
||||||
|
const canEditGroupDetails = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
||||||
|
const canViewPermissionSettings = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "read"));
|
||||||
|
const canAdjustPermissions = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
||||||
|
const superuserGroupLocked = isSuperuserGroup && !isSuperuserAccount;
|
||||||
|
|
||||||
|
const numericGroupId = useMemo(() => {
|
||||||
|
const candidate = Number(groupId);
|
||||||
|
return Number.isFinite(candidate) ? candidate : null;
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
const fetchGroupPermissions = useCallback(
|
||||||
|
async (targetGroupId) => {
|
||||||
|
const numericTargetId = Number(targetGroupId);
|
||||||
|
if (!Number.isFinite(numericTargetId) || numericTargetId <= 0) {
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
|
setInitialPermissionSelection({});
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPermissionsLoading(true);
|
||||||
|
setPermissionsError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/groups/${numericTargetId}/permissions`);
|
||||||
|
const rawSections = Array.isArray(response.data?.sections) ? response.data.sections : [];
|
||||||
|
const rawValues =
|
||||||
|
response.data?.values && typeof response.data.values === "object" ? response.data.values : {};
|
||||||
|
|
||||||
|
const defaults = {};
|
||||||
|
const initialSelection = {};
|
||||||
|
|
||||||
|
const normalizeItem = (item, index) => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = item.key || "";
|
||||||
|
const defaultLevel =
|
||||||
|
typeof item.defaultLevel === "string" && item.defaultLevel ? item.defaultLevel : "none";
|
||||||
|
defaults[key] = defaultLevel;
|
||||||
|
|
||||||
|
const assigned = rawValues[key];
|
||||||
|
let normalizedAssigned =
|
||||||
|
typeof assigned === "string" && assigned ? assigned : defaultLevel;
|
||||||
|
|
||||||
|
const availableLevels =
|
||||||
|
Array.isArray(item.availableLevels) && item.availableLevels.length
|
||||||
|
? item.availableLevels
|
||||||
|
: LEVEL_OPTIONS.map((option) => option.value);
|
||||||
|
if (!availableLevels.includes(normalizedAssigned)) {
|
||||||
|
normalizedAssigned = defaultLevel;
|
||||||
|
}
|
||||||
|
initialSelection[key] = normalizedAssigned;
|
||||||
|
|
||||||
|
const dependencies = Array.isArray(item.dependencies) ? item.dependencies : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
availableLevels,
|
||||||
|
dependencies,
|
||||||
|
sortOrder: typeof item.sortOrder === "number" ? item.sortOrder : index
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedSections = rawSections.map((section, sectionIndex) => {
|
||||||
|
const sectionItems = Array.isArray(section.items)
|
||||||
|
? section.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const sectionGroups = Array.isArray(section.groups)
|
||||||
|
? section.groups.map((group, groupIdx) => {
|
||||||
|
const groupItems = Array.isArray(group.items)
|
||||||
|
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
|
||||||
|
items: groupItems
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
sortOrder: typeof section.sortOrder === "number" ? section.sortOrder : sectionIndex,
|
||||||
|
hasNavigation: Boolean(section.hasNavigation),
|
||||||
|
items: sectionItems,
|
||||||
|
groups: sectionGroups
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setPermissionSections(normalizedSections);
|
||||||
|
setPermissionDefaults(defaults);
|
||||||
|
setPermissionSelection({ ...initialSelection });
|
||||||
|
setInitialPermissionSelection({ ...initialSelection });
|
||||||
|
} catch (err) {
|
||||||
|
let message = "Die Berechtigungen konnten nicht geladen werden.";
|
||||||
|
if (err.response?.data?.error === "GROUP_NOT_FOUND") {
|
||||||
|
message = "Die angeforderte Benutzergruppe wurde nicht gefunden.";
|
||||||
|
}
|
||||||
|
setPermissionsError(message);
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setInitialPermissionSelection({});
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Fehler beim Laden",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchGroupDetails = useCallback(async () => {
|
||||||
|
if (!numericGroupId) {
|
||||||
|
setError("Ungültige Gruppen-ID.");
|
||||||
|
setGroup(null);
|
||||||
|
setFormValues(buildInitialFormValues(null));
|
||||||
|
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
|
setHasLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/groups/${numericGroupId}`);
|
||||||
|
const item = mapGroup(response.data?.item);
|
||||||
|
if (!item.id) {
|
||||||
|
throw new Error("GROUP_NOT_FOUND");
|
||||||
|
}
|
||||||
|
setGroup(item);
|
||||||
|
const initialValues = buildInitialFormValues(item);
|
||||||
|
initialFormValuesRef.current = { ...initialValues };
|
||||||
|
setFormValues(initialValues);
|
||||||
|
setSaveError("");
|
||||||
|
|
||||||
|
const superuserDetected = (item.name || "").toLowerCase() === "superuser";
|
||||||
|
if (superuserDetected) {
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
|
setInitialPermissionSelection({});
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
} else if (canViewPermissionSettings) {
|
||||||
|
fetchGroupPermissions(item.id);
|
||||||
|
} else {
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
|
setInitialPermissionSelection({});
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Gruppendetails konnten nicht geladen werden.";
|
||||||
|
|
||||||
|
if (serverError === "GROUP_NOT_FOUND") {
|
||||||
|
message = "Die angeforderte Benutzergruppe wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "INVALID_GROUP_ID") {
|
||||||
|
message = "Die angegebene Gruppen-ID ist ungültig.";
|
||||||
|
} else if (err.response?.status === 404) {
|
||||||
|
message = "Die angeforderte Benutzergruppe existiert nicht.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroup(null);
|
||||||
|
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||||
|
setFormValues(buildInitialFormValues(null));
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
setError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Fehler beim Laden",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
|
}
|
||||||
|
}, [numericGroupId, showToast, fetchGroupPermissions, canViewPermissionSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGroupDetails();
|
||||||
|
}, [fetchGroupDetails]);
|
||||||
|
|
||||||
|
const detailsChanged = useMemo(() => {
|
||||||
|
if (!hasLoaded || !group) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = initialFormValuesRef.current;
|
||||||
|
if (!initial) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialName = initial.name || "";
|
||||||
|
const currentName = formValues.name || "";
|
||||||
|
|
||||||
|
const initialDescription = initial.description || "";
|
||||||
|
const currentDescription = formValues.description || "";
|
||||||
|
|
||||||
|
const initialAvatar = initial.avatarColor || "";
|
||||||
|
const currentAvatar = formValues.avatarColor || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
initialName !== currentName ||
|
||||||
|
initialDescription !== currentDescription ||
|
||||||
|
initialAvatar !== currentAvatar
|
||||||
|
);
|
||||||
|
}, [formValues, hasLoaded, group]);
|
||||||
|
|
||||||
|
const permissionsChanged = useMemo(() => {
|
||||||
|
if (isSuperuserGroup) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const initial = initialPermissionSelection || {};
|
||||||
|
const current = permissionSelection || {};
|
||||||
|
const allKeys = new Set([...Object.keys(initial), ...Object.keys(current)]);
|
||||||
|
for (const key of allKeys) {
|
||||||
|
const initialValue = initial[key] ?? null;
|
||||||
|
const currentValue = current[key] ?? null;
|
||||||
|
if (initialValue !== currentValue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [permissionSelection, initialPermissionSelection, isSuperuserGroup]);
|
||||||
|
|
||||||
|
const hasChanges = useMemo(
|
||||||
|
() => detailsChanged || permissionsChanged,
|
||||||
|
[detailsChanged, permissionsChanged]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNameChange = useCallback((event) => {
|
||||||
|
const { value } = event.target;
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: value
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDescriptionChange = useCallback((event) => {
|
||||||
|
const { value } = event.target;
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
description: value
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAvatarColorChange = useCallback((value) => {
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
avatarColor: value || ""
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveGroup = useCallback(async () => {
|
||||||
|
if (!group || (!detailsChanged && !permissionsChanged)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailsChanged && !canEditGroupDetails) {
|
||||||
|
setSaveError("Dir fehlt die Berechtigung, diese Gruppe zu bearbeiten.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionsChanged && !canAdjustPermissions) {
|
||||||
|
setSaveError("Dir fehlt die Berechtigung, die Gruppenrechte anzupassen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingGroup(true);
|
||||||
|
setSaveError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (detailsChanged) {
|
||||||
|
const payload = {
|
||||||
|
avatarColor: formValues.avatarColor
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSuperuserGroup) {
|
||||||
|
payload.name = formValues.name;
|
||||||
|
payload.description = formValues.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.put(`/api/groups/${group.id}`, payload);
|
||||||
|
const updated = mapGroup(response.data?.item || response.data?.group);
|
||||||
|
if (updated?.id) {
|
||||||
|
setGroup(updated);
|
||||||
|
const nextInitial = buildInitialFormValues(updated);
|
||||||
|
initialFormValuesRef.current = { ...nextInitial };
|
||||||
|
setFormValues(nextInitial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionsChanged) {
|
||||||
|
await axios.put(`/api/groups/${group.id}/permissions`, {
|
||||||
|
values: permissionSelection
|
||||||
|
});
|
||||||
|
setPermissionSelection((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...permissionSelection
|
||||||
|
}));
|
||||||
|
setInitialPermissionSelection({ ...permissionSelection });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailsChanged) {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Gruppe gespeichert",
|
||||||
|
description: "Die Änderungen wurden erfolgreich gespeichert."
|
||||||
|
});
|
||||||
|
} else if (permissionsChanged) {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Berechtigungen gespeichert",
|
||||||
|
description: "Die Berechtigungen wurden erfolgreich gespeichert."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveError("");
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Die Änderungen konnten nicht gespeichert werden.";
|
||||||
|
|
||||||
|
if (serverError === "GROUP_NAME_REQUIRED") {
|
||||||
|
message = "Bitte einen Gruppennamen angeben.";
|
||||||
|
} else if (serverError === "GROUP_NAME_TAKEN") {
|
||||||
|
message = "Der Gruppenname wird bereits verwendet.";
|
||||||
|
} else if (serverError === "INVALID_AVATAR_COLOR") {
|
||||||
|
message = "Bitte eine gültige Avatar-Farbe auswählen.";
|
||||||
|
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||||
|
message = "Die Benutzergruppe wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
|
||||||
|
message = "Für die Superuser-Gruppe können Berechtigungen nicht angepasst werden.";
|
||||||
|
} else if (serverError === "PERMISSION_INVALID_PAYLOAD") {
|
||||||
|
message = "Ungültige Berechtigungsdaten übermittelt.";
|
||||||
|
} else if (serverError === "PERMISSION_INVALID_LEVEL") {
|
||||||
|
message = "Für mindestens eine Berechtigung wurde ein nicht erlaubter Wert übermittelt.";
|
||||||
|
} else if (serverError === "PERMISSION_UNKNOWN_KEY") {
|
||||||
|
message = "Es wurde eine unbekannte Berechtigung übermittelt.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Speichern fehlgeschlagen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingGroup(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
group,
|
||||||
|
detailsChanged,
|
||||||
|
permissionsChanged,
|
||||||
|
formValues,
|
||||||
|
showToast,
|
||||||
|
isSuperuserGroup,
|
||||||
|
permissionSelection,
|
||||||
|
canEditGroupDetails,
|
||||||
|
canAdjustPermissions
|
||||||
|
]);
|
||||||
|
|
||||||
|
const avatarLabel = useMemo(() => {
|
||||||
|
const source = (formValues.name || formValues.description || "").trim();
|
||||||
|
if (!source) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
return source.charAt(0).toUpperCase();
|
||||||
|
}, [formValues.name, formValues.description]);
|
||||||
|
|
||||||
|
const avatarColorClass = useMemo(() => {
|
||||||
|
if (formValues.avatarColor) {
|
||||||
|
return formValues.avatarColor;
|
||||||
|
}
|
||||||
|
return group?.avatarColor || "";
|
||||||
|
}, [formValues.avatarColor, group]);
|
||||||
|
|
||||||
|
const inputDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
|
||||||
|
const selectDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
|
||||||
|
const selectValue = formValues.avatarColor || "";
|
||||||
|
|
||||||
|
const handlePermissionChange = useCallback((permissionKey, value) => {
|
||||||
|
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextValue = normalizePermissionLevel(permissionKey, value);
|
||||||
|
setPermissionSelection((prev) => {
|
||||||
|
const shouldResetDelete =
|
||||||
|
permissionKey === "maintenance-server-manage" && nextValue !== "full";
|
||||||
|
|
||||||
|
if (!shouldResetDelete && prev[permissionKey] === nextValue) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = {
|
||||||
|
...prev,
|
||||||
|
[permissionKey]: nextValue
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shouldResetDelete) {
|
||||||
|
nextState["maintenance-server-delete"] = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
}, [canAdjustPermissions, isSuperuserGroup]);
|
||||||
|
|
||||||
|
const getPermissionValue = useCallback(
|
||||||
|
(permissionKey) => {
|
||||||
|
if (!permissionKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let value = null;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(permissionSelection, permissionKey)) {
|
||||||
|
value = permissionSelection[permissionKey];
|
||||||
|
} else if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) {
|
||||||
|
value = permissionDefaults[permissionKey];
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return normalizePermissionLevel(permissionKey, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
[permissionSelection, permissionDefaults]
|
||||||
|
);
|
||||||
|
|
||||||
|
const radioIcon = useMemo(
|
||||||
|
() => (
|
||||||
|
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-400 bg-black" />
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const radioCheckedIcon = useMemo(
|
||||||
|
() => (
|
||||||
|
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-700 bg-gray-900" />
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPermissionRowVisible = useCallback(
|
||||||
|
(row) => {
|
||||||
|
if (!row || !row.key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dependencies = Array.isArray(row.dependencies) ? row.dependencies : [];
|
||||||
|
return dependencies.every((dependency) => {
|
||||||
|
if (!dependency || !dependency.key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const value = getPermissionValue(dependency.key) ?? "none";
|
||||||
|
const requirement = dependency.requiredLevel || "!=none";
|
||||||
|
|
||||||
|
if (requirement === "!=none") {
|
||||||
|
return value !== "none";
|
||||||
|
}
|
||||||
|
if (requirement.startsWith("!=")) {
|
||||||
|
return value !== requirement.substring(2);
|
||||||
|
}
|
||||||
|
if (requirement.startsWith("=")) {
|
||||||
|
return value === requirement.substring(1);
|
||||||
|
}
|
||||||
|
if (!requirement) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return value === requirement;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getPermissionValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPermissionRow = (row, ownerKey, options = {}) => {
|
||||||
|
if (!isPermissionRowVisible(row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowName = `permission-${ownerKey}-${row.key}`;
|
||||||
|
const currentValue = getPermissionValue(row.key) ?? "none";
|
||||||
|
const displayValue = normalizePermissionLevel(row.key, currentValue);
|
||||||
|
const serverManageLevel = getPermissionValue("maintenance-server-manage") ?? "none";
|
||||||
|
const { addTopBorder = true } = options;
|
||||||
|
|
||||||
|
const rowClasses = [
|
||||||
|
"flex flex-col gap-2.5 px-4 py-4 md:flex-row md:items-center md:gap-6",
|
||||||
|
addTopBorder ? "border-t border-blue-gray-100" : ""
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const baseLevels = Array.isArray(row.availableLevels) && row.availableLevels.length
|
||||||
|
? row.availableLevels
|
||||||
|
: LEVEL_OPTIONS.map((option) => option.value);
|
||||||
|
const availableLevels = new Set(
|
||||||
|
row.key === "maintenance-server-delete"
|
||||||
|
? baseLevels.filter((level) => level === "full" || level === "none")
|
||||||
|
: baseLevels
|
||||||
|
);
|
||||||
|
const levelOptions = LEVEL_OPTIONS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${ownerKey}-${row.key}`} className={rowClasses}>
|
||||||
|
<div className="text-sm font-medium leading-5 text-blue-gray-600 md:w-1/2">
|
||||||
|
{row.label}
|
||||||
|
</div>
|
||||||
|
<List className="flex-col gap-1.5 md:ml-auto md:flex-row md:flex-nowrap md:w-1/2 md:items-center md:justify-end md:gap-3">
|
||||||
|
{levelOptions.map((option) => {
|
||||||
|
const optionId = `${rowName}-${option.value}`;
|
||||||
|
const checked = displayValue === option.value;
|
||||||
|
const requiresManageFull =
|
||||||
|
row.key === "maintenance-server-delete" &&
|
||||||
|
option.value === "full" &&
|
||||||
|
(LEVEL_PRIORITY[serverManageLevel] ?? 0) < LEVEL_PRIORITY.full;
|
||||||
|
const disabled =
|
||||||
|
!availableLevels.has(option.value) ||
|
||||||
|
permissionsLoading ||
|
||||||
|
savingGroup ||
|
||||||
|
!canAdjustPermissions ||
|
||||||
|
isSuperuserGroup ||
|
||||||
|
requiresManageFull;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={`${ownerKey}-${row.key}-${option.value}`}
|
||||||
|
className="w-full min-w-[140px] p-0 md:w-auto md:flex-1"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={optionId}
|
||||||
|
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListItemPrefix className="flex-shrink-0">
|
||||||
|
<Radio
|
||||||
|
id={optionId}
|
||||||
|
name={rowName}
|
||||||
|
value={option.value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => handlePermissionChange(row.key, option.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
ripple={false}
|
||||||
|
className="h-4 w-4 hover:before:opacity-0"
|
||||||
|
containerProps={{
|
||||||
|
className: "p-0"
|
||||||
|
}}
|
||||||
|
icon={radioIcon}
|
||||||
|
checkedIcon={radioCheckedIcon}
|
||||||
|
/>
|
||||||
|
</ListItemPrefix>
|
||||||
|
<Typography
|
||||||
|
color="blue-gray"
|
||||||
|
className={`text-sm font-medium leading-5 whitespace-nowrap ${disabled ? "text-blue-gray-300" : "text-blue-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Typography>
|
||||||
|
</label>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-12 flex flex-col gap-12">
|
||||||
|
{(maintenanceActive || updateRunning) && (
|
||||||
|
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>
|
||||||
|
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||||
|
</span>
|
||||||
|
{updateRunning && (
|
||||||
|
<span className="text-xs text-indigo-900">
|
||||||
|
Phase: {updateStageLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
||||||
|
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||||||
|
</div>
|
||||||
|
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||||||
|
<CardBody className="p-4">
|
||||||
|
<div className="mb-10 flex flex-wrap items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div
|
||||||
|
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${avatarColorClass}`}
|
||||||
|
aria-label={formValues.name || "Gruppenavatar"}
|
||||||
|
>
|
||||||
|
{avatarLabel}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography variant="h5" color="blue-gray">
|
||||||
|
{formValues.name || "–"}
|
||||||
|
</Typography>
|
||||||
|
<Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
|
||||||
|
Gruppen-ID: {group?.id ?? "–"}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasChanges && (
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleSaveGroup}
|
||||||
|
disabled={maintenanceLocked || savingGroup}
|
||||||
|
>
|
||||||
|
{savingGroup ? "Speichert ..." : "Änderungen speichern"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading && !group && (
|
||||||
|
<div className="mb-6 flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
<span>Gruppendaten werden geladen ...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveError && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||||
|
Gruppendaten
|
||||||
|
</Typography>
|
||||||
|
{isSuperuserGroup && (
|
||||||
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Systemgruppe – Name und Beschreibung sind geschützt.
|
||||||
|
{superuserGroupLocked && (
|
||||||
|
<span className="mt-1 block text-xs text-amber-600">
|
||||||
|
Nur der Superuser darf die Superuser-Gruppe bearbeiten.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Gruppenname
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
value={formValues.name}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
placeholder="Gruppenname"
|
||||||
|
disabled={inputDisabled}
|
||||||
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
|
labelProps={{
|
||||||
|
className: "before:content-none after:content-none"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Beschreibung
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
value={formValues.description}
|
||||||
|
onChange={handleDescriptionChange}
|
||||||
|
placeholder="Beschreibung der Gruppe"
|
||||||
|
disabled={inputDisabled}
|
||||||
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
|
labelProps={{
|
||||||
|
className: "before:content-none after:content-none"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Avatar-Farbe
|
||||||
|
</Typography>
|
||||||
|
<Select
|
||||||
|
label="Avatar-Farbe auswählen"
|
||||||
|
variant="outlined"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={handleAvatarColorChange}
|
||||||
|
disabled={selectDisabled}
|
||||||
|
selected={(element) => {
|
||||||
|
if (element?.props?.children) {
|
||||||
|
return element.props.children;
|
||||||
|
}
|
||||||
|
if (!formValues.avatarColor) {
|
||||||
|
return "Standardfarbe";
|
||||||
|
}
|
||||||
|
return formValues.avatarColor;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Option value="">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-4 w-4 rounded border border-blue-gray-100" />
|
||||||
|
<span className="text-xs">Keine</span>
|
||||||
|
</span>
|
||||||
|
</Option>
|
||||||
|
{AVATAR_COLORS.map((color) => (
|
||||||
|
<Option key={color} value={color}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
|
||||||
|
<span className="text-xs">{color}</span>
|
||||||
|
</span>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||||
|
Mitglieder
|
||||||
|
</Typography>
|
||||||
|
{!group || group.members.length === 0 ? (
|
||||||
|
<Typography className="text-sm text-stormGrey-500">
|
||||||
|
Aktuell sind keine Benutzer dieser Gruppe zugeordnet.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{group.members.map((member) => (
|
||||||
|
<Chip
|
||||||
|
key={`${group.id}-${member.id}-${member.username}`}
|
||||||
|
value={member.username}
|
||||||
|
size="sm"
|
||||||
|
color="blue-gray"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 text-xs text-stormGrey-400">
|
||||||
|
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2 xl:col-span-3">
|
||||||
|
{(!maintenanceActive && !updateRunning) && (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||||
|
Berechtigungen (Ansicht)
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isSuperuserGroup ? (
|
||||||
|
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
|
||||||
|
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
|
||||||
|
</div>
|
||||||
|
) : !canViewPermissionSettings ? null : (
|
||||||
|
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
|
||||||
|
{permissionsLoading && (
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-blue-gray-500">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
<span>Berechtigungen werden geladen ...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!permissionsLoading && permissionsError && (
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-red-700">
|
||||||
|
{permissionsError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!permissionsLoading && !permissionsError &&
|
||||||
|
(permissionSections.length === 0 ? (
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-blue-gray-500">
|
||||||
|
Für diese Gruppe sind keine Berechtigungen definiert.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
permissionSections.map((section, sectionIndex) => {
|
||||||
|
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||||
|
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={section.key || sectionIndex}
|
||||||
|
className={sectionIndex === 0 ? "" : "border-t border-blue-gray-100"}
|
||||||
|
>
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-4">
|
||||||
|
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
|
||||||
|
{section.title}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
{sectionItems.map((row, rowIndex) =>
|
||||||
|
renderPermissionRow(row, section.key || sectionIndex, {
|
||||||
|
addTopBorder: rowIndex !== 0
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{sectionGroups.map((group, groupIdx) => {
|
||||||
|
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||||
|
const hasVisibleRows = groupItems.some(isPermissionRowVisible);
|
||||||
|
|
||||||
|
if (!hasVisibleRows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-3">
|
||||||
|
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
|
||||||
|
{group.title}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
{groupItems.map((row, rowIndex) =>
|
||||||
|
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
|
||||||
|
addTopBorder: rowIndex !== 0
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserGroupDetail;
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardBody,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
Option,
|
||||||
|
Input
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||||
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
|
const UPDATE_STAGE_LABELS = {
|
||||||
|
initializing: "Vorbereitung",
|
||||||
|
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||||
|
"executing-script": "Skript wird ausgeführt",
|
||||||
|
waiting: "Warte auf Portainer",
|
||||||
|
completed: "Abgeschlossen",
|
||||||
|
failed: "Fehlgeschlagen"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Usergroups() {
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
|
const [newGroupDescription, setNewGroupDescription] = useState("");
|
||||||
|
const [createGroupError, setCreateGroupError] = useState("");
|
||||||
|
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||||
|
const [deletingGroupId, setDeletingGroupId] = useState(null);
|
||||||
|
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||||
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
|
const updateRunning = Boolean(updateState?.running);
|
||||||
|
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||||
|
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
|
const canEditGroups = hasPermission("user-groups-edit", "full");
|
||||||
|
const canDeleteGroups = hasPermission("user-groups-delete", "full");
|
||||||
|
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
perPageOptions,
|
||||||
|
perPageIsAll,
|
||||||
|
handlePerPageChange,
|
||||||
|
setPage,
|
||||||
|
setTotals,
|
||||||
|
resetPagination
|
||||||
|
} = usePage();
|
||||||
|
|
||||||
|
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||||
|
const noop = useCallback(() => { }, []);
|
||||||
|
|
||||||
|
const fetchGroups = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/groups");
|
||||||
|
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||||
|
const normalized = items
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || "",
|
||||||
|
description: item.description || "",
|
||||||
|
avatarColor: item.avatarColor || null,
|
||||||
|
memberCount: Number(item.memberCount) || 0,
|
||||||
|
members: Array.isArray(item.members)
|
||||||
|
? item.members.map((member) => ({
|
||||||
|
id: member.id,
|
||||||
|
username: member.username || ""
|
||||||
|
})).filter((member) => member.username)
|
||||||
|
: [],
|
||||||
|
createdAt: item.createdAt || null,
|
||||||
|
updatedAt: item.updatedAt || null
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
|
||||||
|
setGroups(normalized);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Unbekannter Fehler";
|
||||||
|
setError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Benutzergruppen konnten nicht geladen werden",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGroups();
|
||||||
|
}, [fetchGroups]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
fetchGroups();
|
||||||
|
}, [fetchGroups]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((event) => {
|
||||||
|
setSearchQuery(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [searchQuery, page, setPage]);
|
||||||
|
|
||||||
|
const filteredGroups = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
return groups.filter((group) => {
|
||||||
|
const haystacks = [
|
||||||
|
group.name,
|
||||||
|
group.description,
|
||||||
|
...group.members.map((member) => member.username)
|
||||||
|
].map((value) => String(value || "").toLowerCase());
|
||||||
|
return haystacks.some((value) => value.includes(query));
|
||||||
|
});
|
||||||
|
}, [groups, searchQuery]);
|
||||||
|
|
||||||
|
const perPageNumber = useMemo(() => {
|
||||||
|
if (perPageIsAll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const numeric = Number(perPage);
|
||||||
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||||
|
}, [perPage, perPageIsAll]);
|
||||||
|
|
||||||
|
const paginatedGroups = useMemo(() => {
|
||||||
|
if (perPageIsAll || !perPageNumber) {
|
||||||
|
return filteredGroups;
|
||||||
|
}
|
||||||
|
const startIndex = (page - 1) * perPageNumber;
|
||||||
|
return filteredGroups.slice(startIndex, startIndex + perPageNumber);
|
||||||
|
}, [filteredGroups, page, perPageIsAll, perPageNumber]);
|
||||||
|
|
||||||
|
const filteredCount = filteredGroups.length;
|
||||||
|
const visibleCount = paginatedGroups.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (perPageIsAll) {
|
||||||
|
setTotals(filteredCount, filteredCount);
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(perPage);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
|
setTotals(filteredCount, filteredCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPages = Math.max(1, Math.ceil(filteredCount / numeric));
|
||||||
|
if (page > maxPages) {
|
||||||
|
setPage(maxPages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotals(filteredCount, visibleCount);
|
||||||
|
}, [filteredCount, visibleCount, page, perPage, perPageIsAll, setPage, setTotals]);
|
||||||
|
|
||||||
|
const formatTimestamp = useCallback((value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "–";
|
||||||
|
}
|
||||||
|
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short"
|
||||||
|
}).format(parsed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetNewGroupForm = useCallback(() => {
|
||||||
|
setNewGroupName("");
|
||||||
|
setNewGroupDescription("");
|
||||||
|
setCreateGroupError("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateGroup = useCallback(async () => {
|
||||||
|
if (maintenanceLocked) {
|
||||||
|
setCreateGroupError("Im Wartungsmodus oder während eines Updates können keine Gruppen angelegt werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canEditGroups) {
|
||||||
|
setCreateGroupError("Du verfügst nicht über die Berechtigung zum Anlegen von Gruppen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedName = newGroupName.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
setCreateGroupError("Gruppenname ist erforderlich.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreatingGroup(true);
|
||||||
|
setCreateGroupError("");
|
||||||
|
try {
|
||||||
|
const payload = { name: trimmedName };
|
||||||
|
const trimmedDescription = newGroupDescription.trim();
|
||||||
|
if (trimmedDescription) {
|
||||||
|
payload.description = trimmedDescription;
|
||||||
|
}
|
||||||
|
const response = await axios.post("/api/groups", payload);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Gruppe angelegt",
|
||||||
|
description: `Die Benutzergruppe "${response.data?.item?.name ?? trimmedName}" wurde erstellt.`
|
||||||
|
});
|
||||||
|
resetNewGroupForm();
|
||||||
|
fetchGroups();
|
||||||
|
} catch (err) {
|
||||||
|
const errorCode = err.response?.data?.error;
|
||||||
|
let message = "Benutzergruppe konnte nicht angelegt werden.";
|
||||||
|
if (errorCode === "GROUP_NAME_REQUIRED") {
|
||||||
|
message = "Gruppenname ist erforderlich.";
|
||||||
|
} else if (errorCode === "GROUP_NAME_TAKEN") {
|
||||||
|
message = "Der Gruppenname wird bereits verwendet.";
|
||||||
|
}
|
||||||
|
setCreateGroupError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Erstellen fehlgeschlagen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreatingGroup(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
maintenanceLocked,
|
||||||
|
canEditGroups,
|
||||||
|
newGroupName,
|
||||||
|
newGroupDescription,
|
||||||
|
showToast,
|
||||||
|
resetNewGroupForm,
|
||||||
|
fetchGroups
|
||||||
|
]);
|
||||||
|
|
||||||
|
const createGroupDisabled = maintenanceLocked || creatingGroup;
|
||||||
|
|
||||||
|
const handleDeleteGroup = useCallback(async (group) => {
|
||||||
|
if (maintenanceLocked || !group?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canDeleteGroups) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
|
||||||
|
if (isSuperuserGroup) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Löschen nicht möglich",
|
||||||
|
description: "Die Superuser-Gruppe kann nicht gelöscht werden."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCount = Number(group.memberCount) || 0;
|
||||||
|
if (memberCount > 0) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Löschen nicht möglich",
|
||||||
|
description: "Die Gruppe enthält noch Benutzer. Bitte entfernen Sie diese zuerst."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmation = window.confirm(`Benutzergruppe "${group.name}" wirklich löschen?`);
|
||||||
|
if (!confirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericId = Number(group.id);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Löschen fehlgeschlagen",
|
||||||
|
description: "Ungültige Gruppen-ID."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingGroupId(numericId);
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/groups/${numericId}`);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Gruppe gelöscht",
|
||||||
|
description: `Die Benutzergruppe "${group.name}" wurde entfernt.`
|
||||||
|
});
|
||||||
|
fetchGroups();
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Die Benutzergruppe konnte nicht gelöscht werden.";
|
||||||
|
|
||||||
|
if (serverError === "INVALID_GROUP_ID") {
|
||||||
|
message = "Die Gruppen-ID ist ungültig.";
|
||||||
|
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||||
|
message = "Die Benutzergruppe wurde bereits entfernt.";
|
||||||
|
} else if (serverError === "GROUP_HAS_MEMBERS") {
|
||||||
|
message = "Die Gruppe enthält noch Benutzer. Bitte entfernen Sie diese zuerst.";
|
||||||
|
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
|
||||||
|
message = "Die Superuser-Gruppe kann nicht gelöscht werden.";
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Löschen fehlgeschlagen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeletingGroupId(null);
|
||||||
|
}
|
||||||
|
}, [maintenanceLocked, canDeleteGroups, showToast, fetchGroups]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||||
|
{(maintenanceActive || updateRunning) && (
|
||||||
|
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>
|
||||||
|
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||||
|
</span>
|
||||||
|
{updateRunning && (
|
||||||
|
<span className="text-xs text-indigo-900">
|
||||||
|
Phase: {updateStageLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Card className="border border-blue-gray-100 shadow-sm">
|
||||||
|
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
color="white"
|
||||||
|
>
|
||||||
|
<span>Benutzergruppen</span>
|
||||||
|
</Typography>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0">
|
||||||
|
{canEditGroups && (
|
||||||
|
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||||
|
Neue Benutzergruppe anlegen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
|
||||||
|
Der Gruppenname ist Pflicht, die Beschreibung optional.
|
||||||
|
</Typography>
|
||||||
|
{createGroupError && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{createGroupError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Gruppenname"
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={(event) => setNewGroupName(event.target.value)}
|
||||||
|
disabled={createGroupDisabled}
|
||||||
|
crossOrigin=""
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Beschreibung (optional)"
|
||||||
|
value={newGroupDescription}
|
||||||
|
onChange={(event) => setNewGroupDescription(event.target.value)}
|
||||||
|
disabled={createGroupDisabled}
|
||||||
|
crossOrigin=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
|
||||||
|
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="blue-gray"
|
||||||
|
onClick={resetNewGroupForm}
|
||||||
|
disabled={creatingGroup || maintenanceLocked}
|
||||||
|
>
|
||||||
|
Formular zurücksetzen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="md:flex-1">
|
||||||
|
<Input
|
||||||
|
label="Suchen nach Name, E-Mail oder Gruppe"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
disabled={loading && !groups.length}
|
||||||
|
crossOrigin=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:mt-0 mt-8 md:flex-1">
|
||||||
|
<Select
|
||||||
|
variant="static"
|
||||||
|
label="Einträge pro Seite"
|
||||||
|
onChange={noop}
|
||||||
|
value={perPage}
|
||||||
|
>
|
||||||
|
{perPageOptions.map(({ value, label }) => (
|
||||||
|
<Option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
||||||
|
<table className="w-full min-w-[720px] table-auto text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
|
||||||
|
<th className="px-6 py-4 font-semibold">Gruppenname</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Beschreibung</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Mitglieder</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Erstellt am</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Aktionen</th>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && groups.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-8 text-center text-stormGrey-400">
|
||||||
|
Gruppen werden geladen ...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paginatedGroups.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-8 text-center text-stormGrey-400">
|
||||||
|
Keine Gruppen gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginatedGroups.map((group, index) => {
|
||||||
|
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||||
|
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
|
||||||
|
return (
|
||||||
|
<tr key={group.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small" className="font-medium text-stormGrey-900">
|
||||||
|
{group.name || "–"}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small">
|
||||||
|
{group.description ? (
|
||||||
|
group.description
|
||||||
|
) : (
|
||||||
|
<span className="text-stormGrey-400">–</span>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||||
|
{group.memberCount.toLocaleString("de-DE")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||||
|
{formatTimestamp(group.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||||
|
{formatTimestamp(group.updatedAt)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outlined"
|
||||||
|
color="blue-gray"
|
||||||
|
onClick={() => navigate(`/dashboard/usergroups/${group.id}`)}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
{canDeleteGroups && !isSuperuserGroup && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="text"
|
||||||
|
color="red"
|
||||||
|
onClick={() => handleDeleteGroup(group)}
|
||||||
|
disabled={
|
||||||
|
maintenanceLocked ||
|
||||||
|
deletingGroupId === group.id ||
|
||||||
|
Number(group.memberCount) > 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deletingGroupId === group.id ? "Löscht ..." : "Löschen"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Typography variant="small" color="gray">
|
||||||
|
{!loading && groups.length === 0
|
||||||
|
? "Noch keine Gruppen angelegt."
|
||||||
|
: `${filteredCount.toLocaleString("de-DE")} Gruppen gefunden`}
|
||||||
|
</Typography>
|
||||||
|
<PaginationControls disabled={loading && groups.length === 0} />
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Usergroups;
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardBody,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
Option,
|
||||||
|
Input
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||||
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
|
const UPDATE_STAGE_LABELS = {
|
||||||
|
initializing: "Vorbereitung",
|
||||||
|
"activating-maintenance": "Wartungsmodus aktivieren",
|
||||||
|
"executing-script": "Skript wird ausgeführt",
|
||||||
|
waiting: "Warte auf Portainer",
|
||||||
|
completed: "Abgeschlossen",
|
||||||
|
failed: "Fehlgeschlagen"
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUserGroups = (rawGroups) => {
|
||||||
|
if (!Array.isArray(rawGroups)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return rawGroups
|
||||||
|
.map((group) => {
|
||||||
|
if (group && typeof group === "object") {
|
||||||
|
const id = Number(group.id);
|
||||||
|
const name = typeof group.name === "string" ? group.name : "";
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: Number.isFinite(id) ? id : null,
|
||||||
|
name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof group === "string") {
|
||||||
|
const name = group.trim();
|
||||||
|
return name ? { id: null, name } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Users() {
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [availableGroups, setAvailableGroups] = useState([]);
|
||||||
|
const [groupsLoading, setGroupsLoading] = useState(false);
|
||||||
|
const [groupsError, setGroupsError] = useState("");
|
||||||
|
const [newUsername, setNewUsername] = useState("");
|
||||||
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [newGroupId, setNewGroupId] = useState("");
|
||||||
|
const [createError, setCreateError] = useState("");
|
||||||
|
const [creatingUser, setCreatingUser] = useState(false);
|
||||||
|
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||||
|
const [togglingUserId, setTogglingUserId] = useState(null);
|
||||||
|
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
|
||||||
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
|
const updateRunning = Boolean(updateState?.running);
|
||||||
|
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||||
|
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const noop = useCallback(() => { }, []);
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
|
const canManageUsers = hasPermission("users-edit", "full");
|
||||||
|
const canDeleteUsers = hasPermission("users-delete", "full");
|
||||||
|
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
perPageOptions,
|
||||||
|
perPageIsAll,
|
||||||
|
handlePerPageChange,
|
||||||
|
setPage,
|
||||||
|
setTotals,
|
||||||
|
resetPagination
|
||||||
|
} = usePage();
|
||||||
|
|
||||||
|
useEffect(() => () => resetPagination(), [resetPagination]);
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/users");
|
||||||
|
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||||
|
const sorted = items
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
username: item.username || "",
|
||||||
|
email: item.email || "",
|
||||||
|
isActive: Boolean(item.isActive),
|
||||||
|
avatarColor: typeof item.avatarColor === "string" ? item.avatarColor.trim() : null,
|
||||||
|
lastLogin: item.lastLogin || null,
|
||||||
|
createdAt: item.createdAt || null,
|
||||||
|
updatedAt: item.updatedAt || null,
|
||||||
|
groups: normalizeUserGroups(item.groups)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.username.localeCompare(b.username, "de-DE"));
|
||||||
|
setUsers(sorted);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Unbekannter Fehler";
|
||||||
|
setError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Benutzer konnten nicht geladen werden",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const fetchGroups = useCallback(async () => {
|
||||||
|
setGroupsLoading(true);
|
||||||
|
setGroupsError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/groups");
|
||||||
|
const items = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||||
|
const normalized = items
|
||||||
|
.map((item) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
name: item.name || ""
|
||||||
|
}))
|
||||||
|
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name)
|
||||||
|
.filter((group) => group.name.toLowerCase() !== "superuser")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
|
||||||
|
setAvailableGroups(normalized);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.response?.data?.error || err.message || "Benutzergruppen konnten nicht geladen werden.";
|
||||||
|
setGroupsError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Benutzergruppen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setGroupsLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGroups();
|
||||||
|
}, [fetchGroups]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
fetchUsers();
|
||||||
|
fetchGroups();
|
||||||
|
}, [fetchUsers, fetchGroups]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((event) => {
|
||||||
|
setSearchQuery(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [searchQuery, page, setPage]);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
return users.filter((user) => {
|
||||||
|
const haystacks = [
|
||||||
|
user.username,
|
||||||
|
user.email,
|
||||||
|
...user.groups.map((group) => group.name)
|
||||||
|
].map((value) => String(value || "").toLowerCase());
|
||||||
|
return haystacks.some((value) => value.includes(query));
|
||||||
|
});
|
||||||
|
}, [users, searchQuery]);
|
||||||
|
|
||||||
|
const perPageNumber = useMemo(() => {
|
||||||
|
if (perPageIsAll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const numeric = Number(perPage);
|
||||||
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||||
|
}, [perPage, perPageIsAll]);
|
||||||
|
|
||||||
|
const paginatedUsers = useMemo(() => {
|
||||||
|
if (perPageIsAll || !perPageNumber) {
|
||||||
|
return filteredUsers;
|
||||||
|
}
|
||||||
|
const startIndex = (page - 1) * perPageNumber;
|
||||||
|
return filteredUsers.slice(startIndex, startIndex + perPageNumber);
|
||||||
|
}, [filteredUsers, page, perPageIsAll, perPageNumber]);
|
||||||
|
|
||||||
|
const filteredCount = filteredUsers.length;
|
||||||
|
const visibleCount = paginatedUsers.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (perPageIsAll) {
|
||||||
|
setTotals(filteredCount, filteredCount);
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(perPage);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
|
setTotals(filteredCount, filteredCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPages = Math.max(1, Math.ceil(filteredCount / numeric));
|
||||||
|
if (page > maxPages) {
|
||||||
|
setPage(maxPages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotals(filteredCount, visibleCount);
|
||||||
|
}, [filteredCount, visibleCount, page, perPage, perPageIsAll, setPage, setTotals]);
|
||||||
|
|
||||||
|
const formatTimestamp = useCallback((value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "–";
|
||||||
|
}
|
||||||
|
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short"
|
||||||
|
}).format(parsed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetNewUserForm = useCallback(() => {
|
||||||
|
setNewUsername("");
|
||||||
|
setNewEmail("");
|
||||||
|
setNewPassword("");
|
||||||
|
setNewGroupId("");
|
||||||
|
setCreateError("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteUser = useCallback(async (user) => {
|
||||||
|
if (maintenanceLocked || !user?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canDeleteUsers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isSuperuserUser = Array.isArray(user?.groups)
|
||||||
|
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
|
||||||
|
: false;
|
||||||
|
if (isSuperuserUser) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Löschen nicht möglich",
|
||||||
|
description: "Der Superuser kann nicht gelöscht werden."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmation = window.confirm(`Benutzer "${user.username}" wirklich löschen?`);
|
||||||
|
if (!confirmation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericId = Number(user.id);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Löschen fehlgeschlagen",
|
||||||
|
description: "Ungültige Benutzer-ID."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingUserId(numericId);
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/users/${numericId}`);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Benutzer gelöscht",
|
||||||
|
description: `Der Benutzer "${user.username}" wurde entfernt.`
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Der Benutzer konnte nicht gelöscht werden.";
|
||||||
|
|
||||||
|
if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die Benutzer-ID ist ungültig.";
|
||||||
|
} else if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der Benutzer wurde bereits entfernt.";
|
||||||
|
} else if (serverError === "USER_SUPERUSER_PROTECTED") {
|
||||||
|
message = "Der Superuser kann nicht gelöscht werden.";
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Löschen fehlgeschlagen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeletingUserId(null);
|
||||||
|
}
|
||||||
|
}, [maintenanceLocked, canDeleteUsers, showToast, fetchUsers]);
|
||||||
|
|
||||||
|
const handleToggleUserStatus = useCallback(async (user) => {
|
||||||
|
if (!user?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canManageUsers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (maintenanceLocked) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Aktion nicht möglich",
|
||||||
|
description: "Im Wartungsmodus oder während eines Updates sind Statusänderungen gesperrt."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuperuserUser = Array.isArray(user?.groups)
|
||||||
|
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (isSuperuserUser && user.isActive) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Änderung nicht möglich",
|
||||||
|
description: "Der Superuser kann nicht deaktiviert werden."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericId = Number(user.id);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Statusänderung fehlgeschlagen",
|
||||||
|
description: "Ungültige Benutzer-ID."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextActive = !user.isActive;
|
||||||
|
setTogglingUserId(numericId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/users/${numericId}/active`, { isActive: nextActive });
|
||||||
|
fetchUsers();
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Status aktualisiert",
|
||||||
|
description: `Der Benutzer wurde ${nextActive ? "aktiviert" : "deaktiviert"}.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Der Benutzerstatus konnte nicht geändert werden.";
|
||||||
|
|
||||||
|
if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die Benutzer-ID ist ungültig.";
|
||||||
|
} else if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der Benutzer wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "USER_SUPERUSER_PROTECTED") {
|
||||||
|
message = "Der Superuser kann nicht deaktiviert werden.";
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Statusänderung fehlgeschlagen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTogglingUserId(null);
|
||||||
|
}
|
||||||
|
}, [maintenanceLocked, canManageUsers, showToast, fetchUsers]);
|
||||||
|
|
||||||
|
const handleCreateUser = useCallback(async () => {
|
||||||
|
if (maintenanceLocked) {
|
||||||
|
setCreateError("Im Wartungsmodus oder während eines Updates können keine Benutzer angelegt werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canManageUsers) {
|
||||||
|
setCreateError("Du verfügst nicht über die Berechtigung zum Anlegen von Benutzern.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedUsername = newUsername.trim();
|
||||||
|
if (!trimmedUsername) {
|
||||||
|
setCreateError("Benutzername ist erforderlich.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sanitizedPassword = typeof newPassword === "string" ? newPassword.trim() : "";
|
||||||
|
if (sanitizedPassword.length < 8) {
|
||||||
|
setCreateError("Passwort muss mindestens 8 Zeichen enthalten.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newGroupId) {
|
||||||
|
setCreateError("Bitte eine globale Rolle auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreatingUser(true);
|
||||||
|
setCreateError("");
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
username: trimmedUsername,
|
||||||
|
password: sanitizedPassword,
|
||||||
|
groupId: Number(newGroupId)
|
||||||
|
};
|
||||||
|
const trimmedEmail = newEmail.trim();
|
||||||
|
if (trimmedEmail) {
|
||||||
|
payload.email = trimmedEmail;
|
||||||
|
}
|
||||||
|
const response = await axios.post("/api/users", payload);
|
||||||
|
resetNewUserForm();
|
||||||
|
fetchUsers();
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Benutzer angelegt",
|
||||||
|
description: `Der Benutzer "${response.data?.item?.username ?? trimmedUsername}" wurde erstellt.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorCode = err.response?.data?.error;
|
||||||
|
let message = "Benutzer konnte nicht angelegt werden.";
|
||||||
|
switch (errorCode) {
|
||||||
|
case "USERNAME_REQUIRED":
|
||||||
|
message = "Benutzername ist erforderlich.";
|
||||||
|
break;
|
||||||
|
case "INVALID_EMAIL":
|
||||||
|
message = "Bitte eine gültige E-Mail-Adresse angeben.";
|
||||||
|
break;
|
||||||
|
case "INVALID_PASSWORD":
|
||||||
|
case "PASSWORD_TOO_SHORT":
|
||||||
|
message = "Passwort muss mindestens 8 Zeichen enthalten.";
|
||||||
|
break;
|
||||||
|
case "INVALID_GROUP_ID":
|
||||||
|
case "GROUP_NOT_FOUND":
|
||||||
|
message = "Bitte eine gültige globale Rolle auswählen.";
|
||||||
|
break;
|
||||||
|
case "USERNAME_TAKEN":
|
||||||
|
message = "Benutzername wird bereits verwendet.";
|
||||||
|
break;
|
||||||
|
case "EMAIL_TAKEN":
|
||||||
|
message = "E-Mail-Adresse wird bereits verwendet.";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setCreateError(message);
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Erstellen fehlgeschlagen",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreatingUser(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
maintenanceLocked,
|
||||||
|
canManageUsers,
|
||||||
|
newUsername,
|
||||||
|
newEmail,
|
||||||
|
newPassword,
|
||||||
|
newGroupId,
|
||||||
|
showToast,
|
||||||
|
resetNewUserForm,
|
||||||
|
fetchUsers
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasSelectableGroups = availableGroups.length > 0;
|
||||||
|
const createDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
|
||||||
|
const groupSelectDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
|
||||||
|
const renderSelectedGroup = useCallback(
|
||||||
|
(element) => {
|
||||||
|
if (element && element.props && element.props.children) {
|
||||||
|
return element.props.children;
|
||||||
|
}
|
||||||
|
if (!newGroupId) {
|
||||||
|
return "Bitte auswählen";
|
||||||
|
}
|
||||||
|
const selected = availableGroups.find((group) => String(group.id) === newGroupId);
|
||||||
|
return selected ? selected.name : "Bitte auswählen";
|
||||||
|
},
|
||||||
|
[availableGroups, newGroupId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||||
|
{(maintenanceActive || updateRunning) && (
|
||||||
|
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>
|
||||||
|
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||||||
|
</span>
|
||||||
|
{updateRunning && (
|
||||||
|
<span className="text-xs text-indigo-900">
|
||||||
|
Phase: {updateStageLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Card>
|
||||||
|
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
color="white"
|
||||||
|
>
|
||||||
|
<span>Benutzerverwaltung</span>
|
||||||
|
</Typography>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0">
|
||||||
|
|
||||||
|
|
||||||
|
{canManageUsers && (
|
||||||
|
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||||
|
Neuen Benutzer anlegen
|
||||||
|
</Typography>
|
||||||
|
{hasSelectableGroups ? (
|
||||||
|
<>
|
||||||
|
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
|
||||||
|
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
|
||||||
|
</Typography>
|
||||||
|
{createError && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groupsError && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{groupsError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Benutzername"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(event) => setNewUsername(event.target.value)}
|
||||||
|
disabled={maintenanceLocked || creatingUser}
|
||||||
|
crossOrigin=""
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-Mail (optional)"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(event) => setNewEmail(event.target.value)}
|
||||||
|
disabled={maintenanceLocked || creatingUser}
|
||||||
|
crossOrigin=""
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="Passwort"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
disabled={maintenanceLocked || creatingUser}
|
||||||
|
crossOrigin=""
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Globale Rolle"
|
||||||
|
variant="outlined"
|
||||||
|
value={newGroupId}
|
||||||
|
onChange={setNewGroupId}
|
||||||
|
disabled={groupSelectDisabled}
|
||||||
|
selected={renderSelectedGroup}
|
||||||
|
>
|
||||||
|
<Option value="">Bitte auswählen</Option>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<Option key={group.id} value={String(group.id)}>
|
||||||
|
{group.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm text-stormGrey-500">
|
||||||
|
{groupsLoading
|
||||||
|
? "Benutzergruppen werden geladen ..."
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
|
||||||
|
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="blue-gray"
|
||||||
|
onClick={resetNewUserForm}
|
||||||
|
disabled={creatingUser || maintenanceLocked}
|
||||||
|
>
|
||||||
|
Formular zurücksetzen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Es existiert keine verfügbare Benutzergruppe. Bitte lege zuerst eine Gruppe an.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
|
||||||
|
<div className="md:flex-1">
|
||||||
|
<Input
|
||||||
|
label="Suchen nach Name, E-Mail oder Gruppe"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
disabled={loading && !users.length}
|
||||||
|
crossOrigin=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:mt-0 mt-8 md:flex-1">
|
||||||
|
<Select
|
||||||
|
variant="static"
|
||||||
|
label="Einträge pro Seite"
|
||||||
|
onChange={noop}
|
||||||
|
value={perPage}
|
||||||
|
>
|
||||||
|
{perPageOptions.map(({ value, label }) => (
|
||||||
|
<Option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
|
||||||
|
<table className="w-full min-w-[720px] table-auto text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
|
||||||
|
<th className="px-6 py-4 font-semibold">Benutzername</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">E-Mail</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Gruppen</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Status</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Letzte Anmeldung</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Erstellt am</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Aktionen</th>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-6 py-8 text-center text-blue-gray-400">
|
||||||
|
Benutzerdaten werden geladen ...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paginatedUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-6 py-8 text-center text-blue-gray-400">
|
||||||
|
Keine Benutzer gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginatedUsers.map((user, index) => {
|
||||||
|
const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50";
|
||||||
|
const isSuperuserUser = Array.isArray(user?.groups)
|
||||||
|
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
|
||||||
|
: false;
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small" className="font-medium text-stormGrey-900">
|
||||||
|
{user.username || "–"}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small">
|
||||||
|
{user.email || "–"}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{user.groups.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{user.groups.map((group) => (
|
||||||
|
<Chip
|
||||||
|
key={group.id ?? group.name}
|
||||||
|
value={group.name}
|
||||||
|
size="sm"
|
||||||
|
color="blue-gray"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-stormGrey-400">–</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{canManageUsers ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceLocked ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
|
onClick={() => handleToggleUserStatus(user)}
|
||||||
|
disabled={
|
||||||
|
maintenanceLocked ||
|
||||||
|
togglingUserId === user.id ||
|
||||||
|
(Array.isArray(user.groups) &&
|
||||||
|
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||||
|
size="sm"
|
||||||
|
color={user.isActive ? "green" : "red"}
|
||||||
|
variant="ghost"
|
||||||
|
className="pointer-events-none"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
value={user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||||
|
size="sm"
|
||||||
|
color={user.isActive ? "green" : "red"}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||||
|
{formatTimestamp(user.lastLogin)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||||
|
{formatTimestamp(user.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outlined"
|
||||||
|
color="blue-gray"
|
||||||
|
onClick={() => navigate(`/dashboard/users/${user.id}`)}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
{canDeleteUsers && !isSuperuserUser && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="text"
|
||||||
|
color="red"
|
||||||
|
onClick={() => handleDeleteUser(user)}
|
||||||
|
disabled={maintenanceLocked || deletingUserId === user.id}
|
||||||
|
>
|
||||||
|
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Typography variant="small" color="gray">
|
||||||
|
{""}
|
||||||
|
</Typography>
|
||||||
|
<PaginationControls disabled={loading && users.length === 0} />
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Users;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Setup as default, Setup } from "./setup";
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Typography,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Stepper,
|
||||||
|
Step,
|
||||||
|
Radio
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const STEP_LABELS = [
|
||||||
|
"Willkommen",
|
||||||
|
"Superuser",
|
||||||
|
"Server & API-Key",
|
||||||
|
"Stack auswählen (optional)",
|
||||||
|
"Abschluss"
|
||||||
|
];
|
||||||
|
|
||||||
|
const matchesStackpulse = (stack) =>
|
||||||
|
typeof stack?.Name === "string" && stack.Name.toLowerCase().includes("stackpulse");
|
||||||
|
|
||||||
|
const initialSuperuser = {
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
password: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialServer = {
|
||||||
|
name: "",
|
||||||
|
url: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Setup() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState(null);
|
||||||
|
|
||||||
|
const [superuserForm, setSuperuserForm] = useState(initialSuperuser);
|
||||||
|
const [serverForm, setServerForm] = useState(initialServer);
|
||||||
|
const [apiKeyValue, setApiKeyValue] = useState("");
|
||||||
|
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState({
|
||||||
|
loading: false,
|
||||||
|
success: false,
|
||||||
|
tried: false,
|
||||||
|
message: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const [stackLookup, setStackLookup] = useState({
|
||||||
|
loading: false,
|
||||||
|
fetched: false,
|
||||||
|
error: "",
|
||||||
|
items: [],
|
||||||
|
updatedAt: null
|
||||||
|
});
|
||||||
|
const [selectedStackId, setSelectedStackId] = useState("");
|
||||||
|
const [manualStackId, setManualStackId] = useState("");
|
||||||
|
|
||||||
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
|
|
||||||
|
const [finalizing, setFinalizing] = useState(false);
|
||||||
|
const [finalError, setFinalError] = useState(null);
|
||||||
|
const [finalSuccess, setFinalSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoadingStatus(true);
|
||||||
|
setFetchError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/setup/status", {
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("STATUS_REQUEST_FAILED");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
setStatus(data);
|
||||||
|
|
||||||
|
if (data.setupComplete) {
|
||||||
|
navigate("/auth/sign-in", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuperuserForm((prev) => ({
|
||||||
|
username: data.envDefaults?.superuserUsername?.length
|
||||||
|
? data.envDefaults.superuserUsername
|
||||||
|
: prev.username,
|
||||||
|
email: data.envDefaults?.superuserEmail?.length
|
||||||
|
? data.envDefaults.superuserEmail
|
||||||
|
: prev.email,
|
||||||
|
password: data.envDefaults?.superuserPassword?.length
|
||||||
|
? data.envDefaults.superuserPassword
|
||||||
|
: prev.password
|
||||||
|
}));
|
||||||
|
|
||||||
|
setServerForm((prev) => ({
|
||||||
|
name: data.envDefaults?.serverName?.length
|
||||||
|
? data.envDefaults.serverName
|
||||||
|
: prev.name,
|
||||||
|
url: data.envDefaults?.serverUrl?.length
|
||||||
|
? data.envDefaults.serverUrl
|
||||||
|
: prev.url
|
||||||
|
}));
|
||||||
|
|
||||||
|
setApiKeyValue((prev) =>
|
||||||
|
data.envDefaults?.apiKeyValue?.length ? data.envDefaults.apiKeyValue : prev
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultSelfStack = data.selfStack?.current || data.envDefaults?.selfStackId || "";
|
||||||
|
setManualStackId(defaultSelfStack);
|
||||||
|
setSelectedStackId(data.selfStack?.current ? String(data.selfStack.current) : "");
|
||||||
|
setActiveStep(0);
|
||||||
|
setConnectionStatus({ loading: false, success: false, tried: false, message: "" });
|
||||||
|
setStackLookup({ loading: false, fetched: false, error: "", items: [], updatedAt: null });
|
||||||
|
setFinalError(null);
|
||||||
|
setFinalSuccess(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "AbortError") return;
|
||||||
|
console.error("⚠️ [Setup] Status konnte nicht geladen werden:", error);
|
||||||
|
if (isActive) {
|
||||||
|
setFetchError("Setup-Status konnte nicht geladen werden. Bitte Seite aktualisieren.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isActive) {
|
||||||
|
setLoadingStatus(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStatus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const envDefaults = status?.envDefaults || {};
|
||||||
|
const requireSuperuser = Boolean(status?.requirements?.superuser);
|
||||||
|
const requireServer = Boolean(status?.requirements?.server);
|
||||||
|
const requireApiKey = Boolean(status?.requirements?.apiKey);
|
||||||
|
const hasAnyApiKey = Boolean(status?.apiKeys?.count);
|
||||||
|
|
||||||
|
const serverEnvProvided = Boolean(envDefaults.serverUrl || status?.servers?.envProvided);
|
||||||
|
const apiKeyEnvProvided = Boolean(envDefaults.apiKeyValue || status?.apiKeys?.envProvided);
|
||||||
|
|
||||||
|
const showServerSection = requireServer || serverEnvProvided;
|
||||||
|
const showApiKeyField = requireApiKey || !hasAnyApiKey || apiKeyEnvProvided;
|
||||||
|
|
||||||
|
const superuserUsernameReadOnly = Boolean(envDefaults.superuserUsername);
|
||||||
|
const superuserEmailReadOnly = Boolean(envDefaults.superuserEmail);
|
||||||
|
const superuserPasswordReadOnly = Boolean(envDefaults.superuserPassword);
|
||||||
|
const serverNameReadOnly = Boolean(envDefaults.serverNameFromEnv);
|
||||||
|
const serverUrlReadOnly = Boolean(envDefaults.serverUrl);
|
||||||
|
const apiKeyReadOnly = Boolean(envDefaults.apiKeyValue);
|
||||||
|
|
||||||
|
const resetConnectionStatus = useCallback(() => {
|
||||||
|
setConnectionStatus((prev) =>
|
||||||
|
prev.loading || !prev.tried
|
||||||
|
? prev
|
||||||
|
: { loading: false, success: false, tried: false, message: "" }
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSuperuserChange = useCallback((field) => (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
setSuperuserForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleServerChange = useCallback(
|
||||||
|
(field) => (event) => {
|
||||||
|
if (field === "name" && serverNameReadOnly) return;
|
||||||
|
if (field === "url" && serverUrlReadOnly) return;
|
||||||
|
const value = event.target.value;
|
||||||
|
setServerForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
resetConnectionStatus();
|
||||||
|
},
|
||||||
|
[resetConnectionStatus, serverNameReadOnly, serverUrlReadOnly]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleApiKeyChange = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (apiKeyReadOnly) return;
|
||||||
|
setApiKeyValue(event.target.value);
|
||||||
|
resetConnectionStatus();
|
||||||
|
},
|
||||||
|
[apiKeyReadOnly, resetConnectionStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTestConnection = useCallback(async () => {
|
||||||
|
const serverUrl = serverForm.url.trim();
|
||||||
|
const keyValue = apiKeyValue.trim();
|
||||||
|
|
||||||
|
if (!serverUrl) {
|
||||||
|
setConnectionStatus({
|
||||||
|
loading: false,
|
||||||
|
success: false,
|
||||||
|
tried: true,
|
||||||
|
message: "Bitte gib eine Server-URL an."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!keyValue) {
|
||||||
|
setConnectionStatus({
|
||||||
|
loading: false,
|
||||||
|
success: false,
|
||||||
|
tried: true,
|
||||||
|
message: "Bitte gib einen API-Key ein."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionStatus({ loading: true, success: false, tried: true, message: "" });
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/setup/test-portainer", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
server: {
|
||||||
|
name: serverForm.name.trim() || null,
|
||||||
|
url: serverUrl
|
||||||
|
},
|
||||||
|
apiKey: keyValue
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
const message = payload.message || "Verbindungstest fehlgeschlagen. Bitte Daten prüfen.";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionStatus({
|
||||||
|
loading: false,
|
||||||
|
success: true,
|
||||||
|
tried: true,
|
||||||
|
message: "Verbindung wurde erfolgreich hergestellt."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚠️ [Setup] Verbindungstest fehlgeschlagen:", error);
|
||||||
|
setConnectionStatus({
|
||||||
|
loading: false,
|
||||||
|
success: false,
|
||||||
|
tried: true,
|
||||||
|
message: error.message || "Verbindungstest fehlgeschlagen."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [apiKeyValue, serverForm]);
|
||||||
|
|
||||||
|
const handleFetchStacks = useCallback(
|
||||||
|
async ({ silent = false } = {}) => {
|
||||||
|
if (!connectionStatus.success) {
|
||||||
|
setStackLookup((prev) => ({ ...prev, error: "Bitte teste zuerst die Verbindung." }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serverUrl = serverForm.url.trim();
|
||||||
|
const keyValue = apiKeyValue.trim();
|
||||||
|
if (!serverUrl || !keyValue) return;
|
||||||
|
|
||||||
|
setStackLookup((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: !silent,
|
||||||
|
error: "",
|
||||||
|
fetched: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/setup/portainer-stacks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
server: {
|
||||||
|
name: serverForm.name.trim() || null,
|
||||||
|
url: serverUrl
|
||||||
|
},
|
||||||
|
apiKey: keyValue
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
const message = payload.message || "Stacks konnten nicht geladen werden.";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => ({ stacks: [] }));
|
||||||
|
const stacks = Array.isArray(data.stacks) ? data.stacks : [];
|
||||||
|
const filteredStacks = stacks.filter(matchesStackpulse);
|
||||||
|
setStackLookup({
|
||||||
|
loading: false,
|
||||||
|
fetched: true,
|
||||||
|
error: filteredStacks.length ? "" : "Es wurden keine passenden Stacks gefunden.",
|
||||||
|
items: filteredStacks,
|
||||||
|
updatedAt: new Date()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚠️ [Setup] Stack-Abruf fehlgeschlagen:", error);
|
||||||
|
setStackLookup({
|
||||||
|
loading: false,
|
||||||
|
fetched: true,
|
||||||
|
error: error.message || "Stacks konnten nicht geladen werden.",
|
||||||
|
items: [],
|
||||||
|
updatedAt: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiKeyValue, connectionStatus.success, serverForm]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeStep === 3 && connectionStatus.success && !stackLookup.loading && !stackLookup.fetched) {
|
||||||
|
handleFetchStacks({ silent: true });
|
||||||
|
}
|
||||||
|
}, [activeStep, connectionStatus.success, handleFetchStacks, stackLookup.fetched, stackLookup.loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connectionStatus.success) {
|
||||||
|
setStackLookup((prev) => ({ ...prev, items: [], fetched: false, error: "" }));
|
||||||
|
setSelectedStackId("");
|
||||||
|
}
|
||||||
|
}, [connectionStatus.success]);
|
||||||
|
|
||||||
|
const finalSelfStackId = (selectedStackId || manualStackId || "").trim();
|
||||||
|
|
||||||
|
const canProceed = useMemo(() => {
|
||||||
|
switch (activeStep) {
|
||||||
|
case 0:
|
||||||
|
return true;
|
||||||
|
case 1:
|
||||||
|
if (!requireSuperuser) return true;
|
||||||
|
return (
|
||||||
|
superuserForm.username.trim() &&
|
||||||
|
superuserForm.email.trim() &&
|
||||||
|
superuserForm.password.trim()
|
||||||
|
);
|
||||||
|
case 2: {
|
||||||
|
const serverUrlValid = showServerSection ? Boolean(serverForm.url.trim()) : true;
|
||||||
|
const apiKeyValid = showApiKeyField ? Boolean(apiKeyValue.trim()) : true;
|
||||||
|
const connectionValid = (showServerSection || showApiKeyField)
|
||||||
|
? connectionStatus.success
|
||||||
|
: true;
|
||||||
|
return serverUrlValid && apiKeyValid && connectionValid;
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
return true;
|
||||||
|
case 4:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeStep,
|
||||||
|
apiKeyValue,
|
||||||
|
connectionStatus.success,
|
||||||
|
requireSuperuser,
|
||||||
|
showApiKeyField,
|
||||||
|
showServerSection,
|
||||||
|
superuserForm.email,
|
||||||
|
superuserForm.password,
|
||||||
|
superuserForm.username,
|
||||||
|
serverForm.url
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (activeStep >= STEP_LABELS.length - 1) return;
|
||||||
|
setActiveStep((prev) => Math.min(prev + 1, STEP_LABELS.length - 1));
|
||||||
|
}, [activeStep]);
|
||||||
|
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
setActiveStep((prev) => Math.max(prev - 1, 0));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFinishSetup = useCallback(async () => {
|
||||||
|
if (!status || finalizing) return;
|
||||||
|
|
||||||
|
setFinalError(null);
|
||||||
|
setFinalSuccess(false);
|
||||||
|
setFinalizing(true);
|
||||||
|
try {
|
||||||
|
const payload = {};
|
||||||
|
|
||||||
|
if (requireSuperuser) {
|
||||||
|
payload.superuser = {
|
||||||
|
username: superuserForm.username.trim(),
|
||||||
|
email: superuserForm.email.trim(),
|
||||||
|
password: superuserForm.password
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showServerSection) {
|
||||||
|
payload.server = {
|
||||||
|
name: serverForm.name.trim(),
|
||||||
|
url: serverForm.url.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showApiKeyField || apiKeyValue.trim()) {
|
||||||
|
payload.apiKey = { value: apiKeyValue.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalSelfStackId) {
|
||||||
|
payload.selfStackId = finalSelfStackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/setup/complete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
const message = (() => {
|
||||||
|
switch (payload.error) {
|
||||||
|
case "SERVER_DETAILS_REQUIRED":
|
||||||
|
return "Bitte gib eine gültige Server-URL an.";
|
||||||
|
case "API_KEY_REQUIRED":
|
||||||
|
return "Bitte gib einen gültigen API-Key an.";
|
||||||
|
case "USERNAME_REQUIRED":
|
||||||
|
return "Benutzername wird benötigt.";
|
||||||
|
case "EMAIL_INVALID":
|
||||||
|
return "Bitte gib eine gültige E-Mail-Adresse an.";
|
||||||
|
case "PASSWORD_TOO_SHORT":
|
||||||
|
return "Passwort muss mindestens 8 Zeichen enthalten.";
|
||||||
|
case "INVALID_PASSWORD":
|
||||||
|
return "Das Passwort ist ungültig.";
|
||||||
|
case "API_KEY_ENCRYPT_FAILED":
|
||||||
|
return "API-Key konnte nicht verschlüsselt werden.";
|
||||||
|
case "SUPERUSER_EXISTS":
|
||||||
|
return "Der Superuser wurde bereits angelegt.";
|
||||||
|
default:
|
||||||
|
return "Setup konnte nicht abgeschlossen werden.";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
setStatus(result.status);
|
||||||
|
setFinalSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate("/auth/sign-in", { replace: true });
|
||||||
|
}, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚠️ [Setup] Abschluss fehlgeschlagen:", error);
|
||||||
|
setFinalError(error.message || "Setup fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setFinalizing(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
apiKeyValue,
|
||||||
|
finalSelfStackId,
|
||||||
|
finalizing,
|
||||||
|
navigate,
|
||||||
|
requireSuperuser,
|
||||||
|
showApiKeyField,
|
||||||
|
showServerSection,
|
||||||
|
status,
|
||||||
|
superuserForm.email,
|
||||||
|
superuserForm.password,
|
||||||
|
superuserForm.username,
|
||||||
|
serverForm.name,
|
||||||
|
serverForm.url
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stackItemsForDisplay = useMemo(() => stackLookup.items, [stackLookup.items]);
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (activeStep) {
|
||||||
|
case 0:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Typography variant="h5" color="blue-gray">
|
||||||
|
Willkommen bei StackPulse
|
||||||
|
</Typography>
|
||||||
|
<Typography className="text-sm text-blue-gray-600">
|
||||||
|
Dieser Assistent führt dich durch fünf Schritte:
|
||||||
|
</Typography>
|
||||||
|
<ol className="list-decimal space-y-2 pl-5 text-sm text-blue-gray-600">
|
||||||
|
<li>Superuser-Zugang anlegen</li>
|
||||||
|
<li>Server-URL und Portainer API-Key hinterlegen und testen</li>
|
||||||
|
<li>Den eigenen Stack in Portainer identifizieren</li>
|
||||||
|
<li>Alle Angaben überprüfen und Setup abschließen</li>
|
||||||
|
</ol>
|
||||||
|
<Alert color="blue" className="bg-blue-50 text-blue-800">
|
||||||
|
Du kannst jederzeit zum vorherigen Schritt zurückkehren. Vorwärts geht es,
|
||||||
|
sobald alle Pflichtangaben eines Schritts erledigt sind.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
return requireSuperuser ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Typography variant="h5" color="blue-gray">
|
||||||
|
Superuser anlegen
|
||||||
|
</Typography>
|
||||||
|
<Typography className="text-sm text-blue-gray-600">
|
||||||
|
Dieser Benutzer hat Zugriff auf alle Funktionen.
|
||||||
|
</Typography>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Input
|
||||||
|
label="Benutzername"
|
||||||
|
value={superuserForm.username}
|
||||||
|
onChange={handleSuperuserChange("username")}
|
||||||
|
readOnly={superuserUsernameReadOnly}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-Mail-Adresse"
|
||||||
|
type="email"
|
||||||
|
value={superuserForm.email}
|
||||||
|
onChange={handleSuperuserChange("email")}
|
||||||
|
readOnly={superuserEmailReadOnly}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
value={superuserForm.password}
|
||||||
|
onChange={handleSuperuserChange("password")}
|
||||||
|
readOnly={superuserPasswordReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert color="green" className="bg-green-50 text-green-800">
|
||||||
|
Superuser ist bereits vorhanden. Du kannst direkt zum nächsten Schritt gehen.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Typography variant="h5" color="blue-gray">
|
||||||
|
Server und Portainer-Zugriff
|
||||||
|
</Typography>
|
||||||
|
<Typography className="text-sm text-blue-gray-600">
|
||||||
|
Gib die URL zu deiner Portainer-Instanz sowie den dazugehörigen Business-API-Key ein
|
||||||
|
und teste anschließend die Verbindung.
|
||||||
|
</Typography>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Input
|
||||||
|
label="Server-Name"
|
||||||
|
value={serverForm.name}
|
||||||
|
onChange={handleServerChange("name")}
|
||||||
|
readOnly={serverNameReadOnly}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Server-URL"
|
||||||
|
required={showServerSection}
|
||||||
|
value={serverForm.url}
|
||||||
|
onChange={handleServerChange("url")}
|
||||||
|
readOnly={serverUrlReadOnly}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Portainer API-Key"
|
||||||
|
type="password"
|
||||||
|
required={showApiKeyField}
|
||||||
|
value={apiKeyValue}
|
||||||
|
onChange={handleApiKeyChange}
|
||||||
|
readOnly={apiKeyReadOnly}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={connectionStatus.loading}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{connectionStatus.loading ? "Teste Verbindung…" : "Verbindung testen"}
|
||||||
|
</Button>
|
||||||
|
{connectionStatus.tried && (
|
||||||
|
<span
|
||||||
|
className={`text-sm ${connectionStatus.success ? "text-green-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{connectionStatus.message ||
|
||||||
|
(connectionStatus.success
|
||||||
|
? "Verbindung erfolgreich"
|
||||||
|
: "Verbindungstest fehlgeschlagen")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Typography variant="h5" color="blue-gray">
|
||||||
|
Eigenen Stack wählen (optional)
|
||||||
|
</Typography>
|
||||||
|
<Typography className="text-sm text-blue-gray-600">
|
||||||
|
StackPulse kann deinen eigenen Stack hinterlegen, um spätere Wartungsschritte zu erleichtern.
|
||||||
|
Wir versuchen, passende Stacks automatisch zu finden.
|
||||||
|
</Typography>
|
||||||
|
<Alert color="blue" className="bg-blue-50 text-blue-800">
|
||||||
|
Dieser Schritt ist optional. Du kannst die Self-Stack-ID später jederzeit im Bereich
|
||||||
|
„Wartung > Server & API-Keys“ hinterlegen oder anpassen.
|
||||||
|
</Alert>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => handleFetchStacks({ silent: false })}
|
||||||
|
disabled={!connectionStatus.success || stackLookup.loading}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{stackLookup.loading ? "Lade…" : "Stacks neu laden"}
|
||||||
|
</Button>
|
||||||
|
{stackLookup.updatedAt && (
|
||||||
|
<span className="text-xs text-blue-gray-500">
|
||||||
|
Stand: {stackLookup.updatedAt.toLocaleTimeString("de-DE")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{stackLookup.error && (
|
||||||
|
<Alert color="red" className="bg-red-50 text-red-700">
|
||||||
|
{stackLookup.error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stackItemsForDisplay.length === 0 && !stackLookup.loading ? (
|
||||||
|
<p className="text-sm text-blue-gray-500">
|
||||||
|
Es wurden keine Stacks gefunden, die "stackpulse" im Namen enthalten. Bitte die ID
|
||||||
|
manuell eingeben oder die Suche erneut ausführen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
stackItemsForDisplay.map((stack) => (
|
||||||
|
<label
|
||||||
|
key={stack.Id}
|
||||||
|
className="flex cursor-pointer items-center justify-between rounded-md border border-blue-gray-100 p-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-gray-900">{stack.Name || "Ohne Namen"}</p>
|
||||||
|
<p className="text-xs text-blue-gray-500">ID: {stack.Id}</p>
|
||||||
|
</div>
|
||||||
|
<Radio
|
||||||
|
name="stack-selection"
|
||||||
|
value={String(stack.Id)}
|
||||||
|
checked={selectedStackId === String(stack.Id)}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedStackId(String(stack.Id));
|
||||||
|
setManualStackId("");
|
||||||
|
}}
|
||||||
|
ripple={false}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Typography className="text-sm font-semibold text-blue-gray-700">
|
||||||
|
Alternative: Stack-ID manuell eintragen (optional)
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
label="Stack-ID (optional)"
|
||||||
|
value={manualStackId}
|
||||||
|
onChange={(event) => {
|
||||||
|
setManualStackId(event.target.value);
|
||||||
|
setSelectedStackId("");
|
||||||
|
}}
|
||||||
|
placeholder="z. B. 42"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Typography variant="h5" color="blue-gray">
|
||||||
|
Zusammenfassung
|
||||||
|
</Typography>
|
||||||
|
<div className="space-y-4 rounded-md border border-blue-gray-100 p-4 text-sm text-blue-gray-700">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Superuser</p>
|
||||||
|
<p>
|
||||||
|
{requireSuperuser
|
||||||
|
? `${superuserForm.username || "-"} (${superuserForm.email || "ohne E-Mail"})`
|
||||||
|
: "Bereits vorhanden"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Server</p>
|
||||||
|
<p>{serverForm.url || "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">API-Key</p>
|
||||||
|
<p>{apiKeyValue ? "(ausgefüllt)" : "nicht angegeben"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Self-Stack-ID</p>
|
||||||
|
<p>{finalSelfStackId || "nicht ausgewählt"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{finalError && (
|
||||||
|
<Alert color="red" className="bg-red-50 text-red-700">
|
||||||
|
{finalError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{finalSuccess && (
|
||||||
|
<Alert color="green" className="bg-green-50 text-green-800">
|
||||||
|
Setup abgeschlossen. Du wirst zum Login weitergeleitet …
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingStatus) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||||
|
<span className="text-blue-gray-500">Lade Setup-Status …</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||||
|
<Alert color="red" className="w-full max-w-lg border border-red-200 bg-red-50 text-red-700">
|
||||||
|
{fetchError}
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||||
|
<span className="text-blue-gray-500">Keine Setup-Informationen verfügbar.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen w-full items-center justify-center py-10">
|
||||||
|
<Card className="w-full max-w-3xl border border-blue-gray-100 shadow-sm">
|
||||||
|
<CardHeader
|
||||||
|
floated={false}
|
||||||
|
shadow={false}
|
||||||
|
className="grid gap-1 rounded-none bg-transparent p-6 text-left"
|
||||||
|
>
|
||||||
|
<Typography variant="h4" color="blue-gray">
|
||||||
|
System Setup
|
||||||
|
</Typography>
|
||||||
|
<Typography color="gray" className="text-sm">
|
||||||
|
Folge den Schritten, um StackPulse mit deiner Portainer-Instanz zu verbinden.
|
||||||
|
</Typography>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0">
|
||||||
|
<Stepper activeStep={activeStep} className="w-full py-6">
|
||||||
|
{STEP_LABELS.map((label, index) => (
|
||||||
|
<Step
|
||||||
|
key={label}
|
||||||
|
onClick={() => setActiveStep(index)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
activeClassName="bg-blue-600 text-white"
|
||||||
|
completedClassName="bg-blue-600 text-white"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-2 text-center text-xs font-medium text-blue-gray-600 sm:grid-cols-5">
|
||||||
|
{STEP_LABELS.map((label) => (
|
||||||
|
<span key={label}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 min-h-[260px] space-y-6">{renderStepContent()}</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="blue"
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={activeStep === 0 || finalizing}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
{activeStep < STEP_LABELS.length - 1 ? (
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canProceed || finalizing}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
onClick={handleFinishSetup}
|
||||||
|
disabled={!canProceed || finalizing}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{finalizing ? "Setup wird abgeschlossen…" : "Setup abschließen"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Setup;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
Square3Stack3DIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
ListBulletIcon,
|
||||||
|
ServerStackIcon,
|
||||||
|
RectangleStackIcon,
|
||||||
|
ArrowLeftOnRectangleIcon,
|
||||||
|
UserIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
KeyIcon
|
||||||
|
} from "@heroicons/react/24/solid";
|
||||||
|
import { Stacks, Maintenance, Logs, Users, Usergroups } from "@/pages/dashboard";
|
||||||
|
import { SignIn, SignUp, SignOut, ForgotPassword } from "@/pages/auth";
|
||||||
|
import PermissionGate from "@/components/PermissionGate.jsx";
|
||||||
|
|
||||||
|
const icon = {
|
||||||
|
className: "w-5 h-5 text-inherit",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const routes = [
|
||||||
|
{
|
||||||
|
layout: "dashboard",
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
icon: <Square3Stack3DIcon {...icon} />,
|
||||||
|
name: "stacks",
|
||||||
|
path: "/stacks",
|
||||||
|
element: <Stacks />,
|
||||||
|
permission: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <WrenchScrewdriverIcon {...icon} />,
|
||||||
|
name: "wartung",
|
||||||
|
path: "/maintenance",
|
||||||
|
element: (
|
||||||
|
<PermissionGate permission="maintenance-access" requiredLevel="read">
|
||||||
|
<Maintenance />
|
||||||
|
</PermissionGate>
|
||||||
|
),
|
||||||
|
permission: { key: "maintenance-access", requiredLevel: "read" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ListBulletIcon {...icon} />,
|
||||||
|
name: "logs",
|
||||||
|
path: "/logs",
|
||||||
|
element: (
|
||||||
|
<PermissionGate permission="logs-access" requiredLevel="full">
|
||||||
|
<Logs />
|
||||||
|
</PermissionGate>
|
||||||
|
),
|
||||||
|
permission: { key: "logs-access", requiredLevel: "full" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <UserIcon {...icon} />,
|
||||||
|
name: "benutzer",
|
||||||
|
path: "/users",
|
||||||
|
element: (
|
||||||
|
<PermissionGate permission="users-access" requiredLevel="read">
|
||||||
|
<Users />
|
||||||
|
</PermissionGate>
|
||||||
|
),
|
||||||
|
permission: { key: "users-access", requiredLevel: "read" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <UserGroupIcon {...icon} />,
|
||||||
|
name: "rechtegruppen",
|
||||||
|
path: "/usergroups",
|
||||||
|
element: (
|
||||||
|
<PermissionGate permission="user-groups-access" requiredLevel="read">
|
||||||
|
<Usergroups />
|
||||||
|
</PermissionGate>
|
||||||
|
),
|
||||||
|
permission: { key: "user-groups-access", requiredLevel: "read" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "auth pages",
|
||||||
|
layout: "auth",
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
icon: <ServerStackIcon {...icon} />,
|
||||||
|
name: "sign in",
|
||||||
|
path: "/sign-in",
|
||||||
|
element: <SignIn />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <KeyIcon {...icon} />,
|
||||||
|
name: "passwort vergessen",
|
||||||
|
path: "/forgot-password",
|
||||||
|
element: <ForgotPassword />,
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ArrowLeftOnRectangleIcon {...icon} />,
|
||||||
|
name: "log out",
|
||||||
|
path: "/logout",
|
||||||
|
element: <SignOut />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <RectangleStackIcon {...icon} />,
|
||||||
|
name: "sign up",
|
||||||
|
path: "/sign-up",
|
||||||
|
element: <SignUp />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "@/widgets/cards/statistics-card";
|
||||||
|
export * from "@/widgets/cards/profile-info-card";
|
||||||
|
export * from "@/widgets/cards/message-card";
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Avatar, Typography } from "@material-tailwind/react";
|
||||||
|
|
||||||
|
export function MessageCard({ img, name, message, action }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
src={img}
|
||||||
|
alt={name}
|
||||||
|
variant="rounded"
|
||||||
|
className="shadow-lg shadow-blue-gray-500/25"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
color="blue-gray"
|
||||||
|
className="mb-1 font-semibold"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Typography>
|
||||||
|
<Typography className="text-xs font-normal text-blue-gray-400">
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageCard.defaultProps = {
|
||||||
|
action: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageCard.propTypes = {
|
||||||
|
img: PropTypes.string.isRequired,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
message: PropTypes.node.isRequired,
|
||||||
|
action: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageCard.displayName = "/src/widgets/cards/message-card.jsx";
|
||||||
|
|
||||||
|
export default MessageCard;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardBody,
|
||||||
|
Typography,
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
|
||||||
|
export function ProfileInfoCard({ title, description, details, action }) {
|
||||||
|
return (
|
||||||
|
<Card color="transparent" shadow={false}>
|
||||||
|
<CardHeader
|
||||||
|
color="transparent"
|
||||||
|
shadow={false}
|
||||||
|
floated={false}
|
||||||
|
className="mx-0 mt-0 mb-4 flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="blue-gray">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{action}
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
{description && (
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
className="font-normal text-blue-gray-500"
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{description && details ? (
|
||||||
|
<hr className="my-8 border-blue-gray-50" />
|
||||||
|
) : null}
|
||||||
|
{details && (
|
||||||
|
<ul className="flex flex-col gap-4 p-0">
|
||||||
|
{Object.keys(details).map((el, key) => (
|
||||||
|
<li key={key} className="flex items-center gap-4">
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
color="blue-gray"
|
||||||
|
className="font-semibold capitalize"
|
||||||
|
>
|
||||||
|
{el}:
|
||||||
|
</Typography>
|
||||||
|
{typeof details[el] === "string" ? (
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
className="font-normal text-blue-gray-500"
|
||||||
|
>
|
||||||
|
{details[el]}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
details[el]
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileInfoCard.defaultProps = {
|
||||||
|
action: null,
|
||||||
|
description: null,
|
||||||
|
details: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
ProfileInfoCard.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
description: PropTypes.node,
|
||||||
|
details: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
ProfileInfoCard.displayName = "/src/widgets/cards/profile-info-card.jsx";
|
||||||
|
|
||||||
|
export default ProfileInfoCard;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardBody,
|
||||||
|
CardFooter,
|
||||||
|
Typography,
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
export function StatisticsCard({ color, icon, title, value, footer }) {
|
||||||
|
return (
|
||||||
|
<Card className="border border-blue-gray-100 shadow-sm">
|
||||||
|
<CardHeader
|
||||||
|
variant="gradient"
|
||||||
|
color={color}
|
||||||
|
floated={false}
|
||||||
|
shadow={false}
|
||||||
|
className="absolute grid h-12 w-12 place-items-center"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="p-4 text-right">
|
||||||
|
<Typography variant="small" className="font-normal text-blue-gray-600">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" color="blue-gray">
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</CardBody>
|
||||||
|
{footer && (
|
||||||
|
<CardFooter className="border-t border-blue-gray-50 p-4">
|
||||||
|
{footer}
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StatisticsCard.defaultProps = {
|
||||||
|
color: "blue",
|
||||||
|
footer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
StatisticsCard.propTypes = {
|
||||||
|
color: PropTypes.oneOf([
|
||||||
|
"white",
|
||||||
|
"blue-gray",
|
||||||
|
"gray",
|
||||||
|
"brown",
|
||||||
|
"deep-orange",
|
||||||
|
"orange",
|
||||||
|
"amber",
|
||||||
|
"yellow",
|
||||||
|
"lime",
|
||||||
|
"light-green",
|
||||||
|
"green",
|
||||||
|
"teal",
|
||||||
|
"cyan",
|
||||||
|
"light-blue",
|
||||||
|
"blue",
|
||||||
|
"indigo",
|
||||||
|
"deep-purple",
|
||||||
|
"purple",
|
||||||
|
"pink",
|
||||||
|
"red",
|
||||||
|
]),
|
||||||
|
icon: PropTypes.node.isRequired,
|
||||||
|
title: PropTypes.node.isRequired,
|
||||||
|
value: PropTypes.node.isRequired,
|
||||||
|
footer: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
StatisticsCard.displayName = "/src/widgets/cards/statistics-card.jsx";
|
||||||
|
|
||||||
|
export default StatisticsCard;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "@/widgets/charts/statistics-chart";
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardBody,
|
||||||
|
CardFooter,
|
||||||
|
Typography,
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import Chart from "react-apexcharts";
|
||||||
|
|
||||||
|
export function StatisticsChart({ color, chart, title, description, footer }) {
|
||||||
|
return (
|
||||||
|
<Card className="border border-blue-gray-100 shadow-sm">
|
||||||
|
<CardHeader variant="gradient" color={color} floated={false} shadow={false}>
|
||||||
|
<Chart {...chart} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="px-6 pt-0">
|
||||||
|
<Typography variant="h6" color="blue-gray">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="small" className="font-normal text-blue-gray-600">
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
</CardBody>
|
||||||
|
{footer && (
|
||||||
|
<CardFooter className="border-t border-blue-gray-50 px-6 py-5">
|
||||||
|
{footer}
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StatisticsChart.defaultProps = {
|
||||||
|
color: "blue",
|
||||||
|
footer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
StatisticsChart.propTypes = {
|
||||||
|
color: PropTypes.oneOf([
|
||||||
|
"white",
|
||||||
|
"blue-gray",
|
||||||
|
"gray",
|
||||||
|
"brown",
|
||||||
|
"deep-orange",
|
||||||
|
"orange",
|
||||||
|
"amber",
|
||||||
|
"yellow",
|
||||||
|
"lime",
|
||||||
|
"light-green",
|
||||||
|
"green",
|
||||||
|
"teal",
|
||||||
|
"cyan",
|
||||||
|
"light-blue",
|
||||||
|
"blue",
|
||||||
|
"indigo",
|
||||||
|
"deep-purple",
|
||||||
|
"purple",
|
||||||
|
"pink",
|
||||||
|
"red",
|
||||||
|
]),
|
||||||
|
chart: PropTypes.object.isRequired,
|
||||||
|
title: PropTypes.node.isRequired,
|
||||||
|
description: PropTypes.node.isRequired,
|
||||||
|
footer: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
StatisticsChart.displayName = "/src/widgets/charts/statistics-chart.jsx";
|
||||||
|
|
||||||
|
export default StatisticsChart;
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import {
|
||||||
|
useMaterialTailwindController,
|
||||||
|
setOpenConfigurator,
|
||||||
|
setSidenavColor,
|
||||||
|
setSidenavType,
|
||||||
|
setFixedNavbar,
|
||||||
|
} from "@/components";
|
||||||
|
|
||||||
|
function formatNumber(number, decPlaces) {
|
||||||
|
decPlaces = Math.pow(10, decPlaces);
|
||||||
|
|
||||||
|
const abbrev = ["K", "M", "B", "T"];
|
||||||
|
|
||||||
|
for (let i = abbrev.length - 1; i >= 0; i--) {
|
||||||
|
var size = Math.pow(10, (i + 1) * 3);
|
||||||
|
|
||||||
|
if (size <= number) {
|
||||||
|
number = Math.round((number * decPlaces) / size) / decPlaces;
|
||||||
|
|
||||||
|
if (number == 1000 && i < abbrev.length - 1) {
|
||||||
|
number = 1;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
number += abbrev[i];
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Configurator() {
|
||||||
|
const [controller, dispatch] = useMaterialTailwindController();
|
||||||
|
const { openConfigurator, sidenavColor, sidenavType, fixedNavbar } =
|
||||||
|
controller;
|
||||||
|
const [stars, setStars] = React.useState(0);
|
||||||
|
|
||||||
|
const sidenavColors = {
|
||||||
|
dark: "from-black to-black border-gray-200",
|
||||||
|
green: "from-green-400 to-green-600",
|
||||||
|
orange: "from-orange-400 to-orange-600",
|
||||||
|
red: "from-red-400 to-red-600",
|
||||||
|
pink: "from-pink-400 to-pink-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const stars = fetch(
|
||||||
|
"https://api.github.com/repos/creativetimofficial/material-tailwind-dashboard-react"
|
||||||
|
)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => setStars(formatNumber(data.stargazers_count, 1)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`fixed top-0 right-0 z-50 h-screen w-96 bg-white px-2.5 shadow-lg transition-transform duration-300 ${
|
||||||
|
openConfigurator ? "translate-x-0" : "translate-x-96"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between px-6 pt-8 pb-6">
|
||||||
|
<div>
|
||||||
|
<Typography variant="h5" color="blue-gray">
|
||||||
|
Dashboard Configurator
|
||||||
|
</Typography>
|
||||||
|
<Typography className="font-normal text-blue-gray-600">
|
||||||
|
See our dashboard options.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
variant="text"
|
||||||
|
color="blue-gray"
|
||||||
|
onClick={() => setOpenConfigurator(dispatch, false)}
|
||||||
|
>
|
||||||
|
<XMarkIcon strokeWidth={2.5} className="h-5 w-5" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="py-4 px-6">
|
||||||
|
<div className="mb-12">
|
||||||
|
<Typography variant="h6" color="blue-gray">
|
||||||
|
Sidenav Colors
|
||||||
|
</Typography>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
{Object.keys(sidenavColors).map((color) => (
|
||||||
|
<span
|
||||||
|
key={color}
|
||||||
|
className={`h-6 w-6 cursor-pointer rounded-full border bg-gradient-to-br transition-transform hover:scale-105 ${
|
||||||
|
sidenavColors[color]
|
||||||
|
} ${
|
||||||
|
sidenavColor === color ? "border-black" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSidenavColor(dispatch, color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-12">
|
||||||
|
<hr />
|
||||||
|
<div className="flex items-center justify-between py-5">
|
||||||
|
<Typography variant="h6" color="blue-gray">
|
||||||
|
Navbar Fixed
|
||||||
|
</Typography>
|
||||||
|
<Switch defaultChecked
|
||||||
|
id="navbar-fixed"
|
||||||
|
value={fixedNavbar}
|
||||||
|
onChange={() => setFixedNavbar(dispatch, !fixedNavbar)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Configurator.displayName = "/src/widgets/layout/configurator.jsx";
|
||||||
|
|
||||||
|
export default Configurator;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useLocation, Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Navbar,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Breadcrumbs,
|
||||||
|
Menu,
|
||||||
|
MenuHandler,
|
||||||
|
MenuList,
|
||||||
|
MenuItem,
|
||||||
|
Avatar,
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import {
|
||||||
|
UserCircleIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
BellIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
Bars3Icon,
|
||||||
|
} from "@heroicons/react/24/solid";
|
||||||
|
import {
|
||||||
|
useMaterialTailwindController,
|
||||||
|
setOpenConfigurator,
|
||||||
|
setOpenSidenav,
|
||||||
|
} from "@/components";
|
||||||
|
|
||||||
|
export function DashboardNavbar() {
|
||||||
|
const [controller, dispatch] = useMaterialTailwindController();
|
||||||
|
const { fixedNavbar, openSidenav } = controller;
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [layout, page] = pathname.split("/").filter((el) => el !== "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar
|
||||||
|
color={fixedNavbar ? "white" : "transparent"}
|
||||||
|
className={`rounded-xl transition-all ${
|
||||||
|
fixedNavbar
|
||||||
|
? "sticky top-4 z-40 py-3 shadow-md shadow-blue-gray-500/5"
|
||||||
|
: "px-0 py-1"
|
||||||
|
}`}
|
||||||
|
fullWidth
|
||||||
|
blurred={fixedNavbar}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between gap-6 flex-row items-center">
|
||||||
|
<div className="capitalize">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant="text"
|
||||||
|
color="blue-gray"
|
||||||
|
className="grid xl:hidden"
|
||||||
|
onClick={() => setOpenSidenav(dispatch, !openSidenav)}
|
||||||
|
>
|
||||||
|
<Bars3Icon strokeWidth={3} className="h-6 w-6 text-blue-gray-500" />
|
||||||
|
</IconButton>
|
||||||
|
{/* <Link to="/auth/sign-in">
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="blue-gray"
|
||||||
|
className="hidden items-center gap-1 px-4 xl:flex normal-case"
|
||||||
|
>
|
||||||
|
<UserCircleIcon className="h-5 w-5 text-blue-gray-500" />
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
variant="text"
|
||||||
|
color="blue-gray"
|
||||||
|
className="grid xl:hidden"
|
||||||
|
>
|
||||||
|
<UserCircleIcon className="h-5 w-5 text-blue-gray-500" />
|
||||||
|
</IconButton>
|
||||||
|
</Link>
|
||||||
|
<Menu>
|
||||||
|
<MenuHandler>
|
||||||
|
<IconButton variant="text" color="blue-gray">
|
||||||
|
<BellIcon className="h-5 w-5 text-blue-gray-500" />
|
||||||
|
</IconButton>
|
||||||
|
</MenuHandler>
|
||||||
|
<MenuList className="w-max border-0">
|
||||||
|
<MenuItem className="flex items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
src="https://demos.creative-tim.com/material-dashboard/assets/img/team-2.jpg"
|
||||||
|
alt="item-1"
|
||||||
|
size="sm"
|
||||||
|
variant="circular"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
color="blue-gray"
|
||||||
|
className="mb-1 font-normal"
|
||||||
|
>
|
||||||
|
<strong>New message</strong> from Laur
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
color="blue-gray"
|
||||||
|
className="flex items-center gap-1 text-xs font-normal opacity-60"
|
||||||
|
>
|
||||||
|
<ClockIcon className="h-3.5 w-3.5" /> 13 minutes ago
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem className="flex items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
src="https://demos.creative-tim.com/material-dashboard/assets/img/small-logos/logo-spotify.svg"
|
||||||
|
alt="item-1"
|
||||||
|
size="sm"
|
||||||
|
variant="circular"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
color="blue-gray"
|
||||||
|
className="mb-1 font-normal"
|
||||||
|
>
|
||||||
|
<strong>New album</strong> by Travis Scott
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
color="blue-gray"
|
||||||
|
className="flex items-center gap-1 text-xs font-normal opacity-60"
|
||||||
|
>
|
||||||
|
<ClockIcon className="h-3.5 w-3.5" /> 1 day ago
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem className="flex items-center gap-4">
|
||||||
|
<div className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-tr from-blue-gray-800 to-blue-gray-900">
|
||||||
|
<CreditCardIcon className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
color="blue-gray"
|
||||||
|
className="mb-1 font-normal"
|
||||||
|
>
|
||||||
|
Payment successfully completed
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
color="blue-gray"
|
||||||
|
className="flex items-center gap-1 text-xs font-normal opacity-60"
|
||||||
|
>
|
||||||
|
<ClockIcon className="h-3.5 w-3.5" /> 2 days ago
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</Menu> */}
|
||||||
|
<IconButton
|
||||||
|
variant="text"
|
||||||
|
color="blue-gray"
|
||||||
|
onClick={() => setOpenConfigurator(dispatch, true)}
|
||||||
|
>
|
||||||
|
<Cog6ToothIcon className="h-5 w-5 text-blue-gray-500 " />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardNavbar.displayName = "/src/widgets/layout/dashboard-navbar.jsx";
|
||||||
|
|
||||||
|
export default DashboardNavbar;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Typography } from "@material-tailwind/react";
|
||||||
|
import { HeartIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer>
|
||||||
|
<div className="flex w-full flex-wrap items-center justify-center md:justify-end">
|
||||||
|
<Typography variant="small" className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">
|
||||||
|
StackPulse: © {year} by Michael Böhmländer
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer.displayName = "/src/widgets/layout/footer.jsx";
|
||||||
|
export default Footer;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "@/widgets/layout/sidenav";
|
||||||
|
export * from "@/widgets/layout/dashboard-navbar";
|
||||||
|
export * from "@/widgets/layout/configurator";
|
||||||
|
export * from "@/widgets/layout/footer";
|
||||||
|
export * from "@/widgets/layout/navbar";
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Navbar as MTNavbar,
|
||||||
|
Collapse,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
export function Navbar({ brandName, routes, action }) {
|
||||||
|
const [openNav, setOpenNav] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.addEventListener(
|
||||||
|
"resize",
|
||||||
|
() => window.innerWidth >= 960 && setOpenNav(false)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navList = (
|
||||||
|
<ul className="mb-4 mt-2 flex flex-col gap-2 lg:mb-0 lg:mt-0 lg:flex-row lg:items-center lg:gap-6">
|
||||||
|
{routes.map(({ name, path, icon }) => (
|
||||||
|
<Typography
|
||||||
|
key={name}
|
||||||
|
as="li"
|
||||||
|
variant="small"
|
||||||
|
color="blue-gray"
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
<Link to={path} className="flex items-center gap-1 p-1 font-normal">
|
||||||
|
{icon &&
|
||||||
|
React.createElement(icon, {
|
||||||
|
className: "w-[18px] h-[18px] opacity-50 mr-1",
|
||||||
|
})}
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MTNavbar className="p-3">
|
||||||
|
<div className="container mx-auto flex items-center justify-between text-blue-gray-900">
|
||||||
|
<Link to="/">
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
className="mr-4 ml-2 cursor-pointer py-1.5 font-bold"
|
||||||
|
>
|
||||||
|
{brandName}
|
||||||
|
</Typography>
|
||||||
|
</Link>
|
||||||
|
<div className="hidden lg:block">{navList}</div>
|
||||||
|
{React.cloneElement(action, {
|
||||||
|
className: "hidden lg:inline-block",
|
||||||
|
})}
|
||||||
|
<IconButton
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="hml-auto text-inherit hover:bg-transparent focus:bg-transparent active:bg-transparent lg:hidden"
|
||||||
|
onClick={() => setOpenNav(!openNav)}
|
||||||
|
>
|
||||||
|
{openNav ? (
|
||||||
|
<XMarkIcon strokeWidth={2} className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<Bars3Icon strokeWidth={2} className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<Collapse open={openNav}>
|
||||||
|
<div className="container mx-auto">
|
||||||
|
{navList}
|
||||||
|
{React.cloneElement(action, {
|
||||||
|
className: "w-full block lg:hidden",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Collapse>
|
||||||
|
</MTNavbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Navbar.defaultProps = {
|
||||||
|
brandName: "Material Tailwind React",
|
||||||
|
action: (
|
||||||
|
<a
|
||||||
|
href="https://www.creative-tim.com/product/material-tailwind-dashboard-react"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Button variant="gradient" size="sm" fullWidth>
|
||||||
|
free download
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
Navbar.propTypes = {
|
||||||
|
brandName: PropTypes.string,
|
||||||
|
routes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
action: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
Navbar.displayName = "/src/widgets/layout/navbar.jsx";
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Link, NavLink } from "react-router-dom";
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { Avatar, Button, IconButton, Typography } from "@material-tailwind/react";
|
||||||
|
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
import logo from "@/assets/images/stackpulse.png";
|
||||||
|
|
||||||
|
export function Sidenav({ brandImg, brandName, routes }) {
|
||||||
|
const [controller, dispatch] = useMaterialTailwindController();
|
||||||
|
const { sidenavColor, sidenavType, openSidenav } = controller;
|
||||||
|
const { initialized: authInitialized, hasPermission } = useAuth();
|
||||||
|
const sidenavTypes = {
|
||||||
|
dark: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||||
|
white: "bg-white shadow-sm",
|
||||||
|
transparent: "bg-transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`${sidenavTypes[sidenavType]} ${openSidenav ? "translate-x-0" : "-translate-x-80"
|
||||||
|
} fixed inset-0 z-50 my-4 ml-4 h-[calc(100vh-32px)] w-72 rounded-xl transition-transform duration-300 xl:translate-x-0 border border-blue-gray-100`}
|
||||||
|
>
|
||||||
|
<div className="relative py-6 px-8 text-right">
|
||||||
|
<img src={logo} alt="StackPulse" className="w-auto" /><span className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">v0.5</span>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
ripple={false}
|
||||||
|
className="absolute right-0 top-0 grid rounded-br-none rounded-tl-none"
|
||||||
|
onClick={() => setOpenSidenav(dispatch, false)}
|
||||||
|
>
|
||||||
|
<XMarkIcon strokeWidth={2.5} className="h-5 w-5 text-black" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="m-4">
|
||||||
|
{routes.map(({ layout, title, pages }, key) => {
|
||||||
|
const visiblePages = pages.filter(({ permission, hidden }) => {
|
||||||
|
if (hidden) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!permission || !permission.key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!authInitialized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const requiredLevel = permission.requiredLevel || permission.level || "full";
|
||||||
|
return hasPermission(permission.key, requiredLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (layout !== "dashboard" && layout !== "auth") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visiblePages.length === 0 && !title) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul key={key} className="mb-4 flex flex-col gap-1">
|
||||||
|
{title && (
|
||||||
|
<li className="mx-3.5 mt-4 mb-2">
|
||||||
|
<Typography
|
||||||
|
variant="small"
|
||||||
|
color={sidenavType === "dark" ? "white" : "blue-gray"}
|
||||||
|
className="font-black uppercase opacity-75"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{visiblePages.map(({ icon, name, path }) => (
|
||||||
|
<li key={name}>
|
||||||
|
<NavLink to={`/${layout}${path}`}>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<Button
|
||||||
|
variant={isActive ? "gradient" : "text"}
|
||||||
|
color={
|
||||||
|
isActive
|
||||||
|
? sidenavColor
|
||||||
|
: sidenavType === "dark"
|
||||||
|
? "white"
|
||||||
|
: "blue-gray"
|
||||||
|
}
|
||||||
|
className="flex items-center gap-4 px-4 capitalize"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<Typography
|
||||||
|
color="inherit"
|
||||||
|
className="font-medium capitalize"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Typography>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Sidenav.defaultProps = {
|
||||||
|
brandImg: "/img/logo-ct.png",
|
||||||
|
brandName: "Material Tailwind React",
|
||||||
|
};
|
||||||
|
|
||||||
|
Sidenav.propTypes = {
|
||||||
|
brandImg: PropTypes.string,
|
||||||
|
brandName: PropTypes.string,
|
||||||
|
routes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
Sidenav.displayName = "/src/widgets/layout/sidnave.jsx";
|
||||||
|
|
||||||
|
export default Sidenav;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const withMT = require("@material-tailwind/react/utils/withMT");
|
||||||
|
const colors = require("tailwindcss/colors");
|
||||||
|
|
||||||
|
module.exports = withMT({
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
sky: colors.sky,
|
||||||
|
green: colors.green,
|
||||||
|
slate: colors.slate,
|
||||||
|
orange: colors.orange, // falls du z. B. text-orange-300 brauchst
|
||||||
|
auroraTeal: { 50: '#f0fafb', 100: '#e0f5f7', 200: '#c3ecef', 300: '#9de0e4', 400: '#6ad3d8', 500: '#38c6cc', 600: '#2aa9b0', 700: '#1f8d94', 800: '#166e76', 900: '#0c4f58' },
|
||||||
|
sunsetCoral: { 50: '#fff6f5', 100: '#ffece9', 200: '#ffd7d3', 300: '#ffb8b5', 400: '#ff9189', 500: '#ff6a5d', 600: '#e65048', 700: '#b93d38', 800: '#8a2c28', 900: '#5c1c1a' },
|
||||||
|
lavenderSmoke: { 50: '#faf8fc', 100: '#f5f1f9', 200: '#ece2f3', 300: '#dac4e6', 400: '#b88acf', 500: '#9670b8', 600: '#7a5894', 700: '#5d4373', 800: '#412f52', 900: '#291e38' },
|
||||||
|
mossGreen: { 50: '#f5faf2', 100: '#ebf5e5', 200: '#d9ebca', 300: '#b8d99e', 400: '#8cc56f', 500: '#60b240', 600: '#4d8f33', 700: '#3b6b27', 800: '#2a491d', 900: '#1c3012' },
|
||||||
|
deepOceanBlue: { 50: '#f4faff', 100: '#e9f5ff', 200: '#cfe6ff', 300: '#add1ff', 400: '#7ab9ff', 500: '#479fff', 600: '#3c86e6', 700: '#305eaf', 800: '#23437f', 900: '#162a50' },
|
||||||
|
warmAmberGlow: { 50: '#fff9f2', 100: '#fff3e5', 200: '#ffe6cc', 300: '#ffcc99', 400: '#ffad4d', 500: '#ff8f00', 600: '#e67600', 700: '#b35a00', 800: '#804000', 900: '#4d2600' },
|
||||||
|
peachMist: { 50: '#fff8f7', 100: '#fff0ed', 200: '#ffe1db', 300: '#ffc2b7', 400: '#ff9a87', 500: '#ff7257', 600: '#e65f4b', 700: '#b34938', 800: '#803622', 900: '#4d230f' },
|
||||||
|
slatePurple: { 50: '#f7f5fa', 100: '#efe9f5', 200: '#ded1ea', 300: '#bdb3d5', 400: '#9584b5', 500: '#6d5795', 600: '#5c4a7c', 700: '#473861', 800: '#322648', 900: '#1f1530' },
|
||||||
|
mintTea: { 50: '#f2fcfa', 100: '#e5f9f6', 200: '#ccefee', 300: '#99dfdc', 400: '#66cfc9', 500: '#33bfb6', 600: '#2aa395', 700: '#228174', 800: '#195953', 900: '#103330' },
|
||||||
|
copperRust: { 50: '#faf7f6', 100: '#f5efee', 200: '#ebe0dc', 300: '#d6c1b8', 400: '#b28a86', 500: '#8d4d55', 600: '#7a444b', 700: '#5f3338', 800: '#442426', 900: '#2d1718' },
|
||||||
|
// Neue 10
|
||||||
|
arcticBlue: { 50: '#f2f9fd', 100: '#e6f3fb', 200: '#cce6f7', 300: '#99ceef', 400: '#66b6e7', 500: '#339ede', 600: '#1f86c3', 700: '#16699b', 800: '#0f4b6f', 900: '#082d43' },
|
||||||
|
roseQuartz: { 50: '#fdf7f9', 100: '#faeff3', 200: '#f5dfe6', 300: '#ecbfd0', 400: '#e08dad', 500: '#d45b89', 600: '#b94771', 700: '#8e3556', 800: '#63233b', 900: '#391324' },
|
||||||
|
citrusPunch: { 50: '#fffdf6', 100: '#fffbe9', 200: '#fff5cc', 300: '#ffeb99', 400: '#ffe066', 500: '#ffd633', 600: '#e6bd00', 700: '#b39300', 800: '#806900', 900: '#4d3f00' },
|
||||||
|
midnightPlum: { 50: '#faf7fb', 100: '#f4ecf6', 200: '#e4cfee', 300: '#cba0de', 400: '#a869cc', 500: '#8431b9', 600: '#6d299b', 700: '#521f74', 800: '#38154d', 900: '#1f0b28' },
|
||||||
|
sandstone: { 50: '#fbf9f7', 100: '#f6f2ee', 200: '#ece3da', 300: '#d8c6b3', 400: '#c0a27f', 500: '#a87e4b', 600: '#906a3e', 700: '#6b4f2f', 800: '#47341f', 900: '#241a10' },
|
||||||
|
emeraldMist: { 50: '#f3faf7', 100: '#e7f6ef', 200: '#ccecdc', 300: '#9ddabf', 400: '#6ec8a3', 500: '#3fb586', 600: '#359771', 700: '#2a765a', 800: '#1f5442', 900: '#13332a' },
|
||||||
|
velvetRed: { 50: '#fdf7f7', 100: '#faeeee', 200: '#f4d6d6', 300: '#e7a8a8', 400: '#d56666', 500: '#c32424', 600: '#a61e1e', 700: '#801717', 800: '#590f0f', 900: '#330808' },
|
||||||
|
stormGrey: { 50: '#f7f8fa', 100: '#eef0f3', 200: '#d9dce2', 300: '#b6bbc8', 400: '#8b91a4', 500: '#606780', 600: '#4f556b', 700: '#3e4456', 800: '#2c313f', 900: '#1b1e29' },
|
||||||
|
neonLime: { 50: '#f7ffe6', 100: '#eeffc9', 200: '#dbff96', 300: '#c2ff52', 400: '#a8ff0e', 500: '#8de600', 600: '#74bf00', 700: '#5b9900', 800: '#426f00', 900: '#294600' },
|
||||||
|
skyMauve: { 50: '#faf8fc', 100: '#f4f0f9', 200: '#e5dbf2', 300: '#c9b2e6', 400: '#a27dd4', 500: '#7b48c2', 600: '#633aa0', 700: '#4b2b7c', 800: '#341d58', 900: '#1e1035' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
});
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,jsx,ts,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
darkMode: 'class', // Aktiviert Dark Mode via class="dark"
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const React = require('react');
|
||||||
|
const { renderToString } = require('react-dom/server');
|
||||||
|
const { Select, Option } = require('@material-tailwind/react');
|
||||||
|
const element = React.createElement(Select, { value: 'a', onChange: ()=>{} },
|
||||||
|
React.createElement(Option, { value: 'a' }, 'Alpha'),
|
||||||
|
React.createElement(Option, { value: 'b' }, 'Beta')
|
||||||
|
);
|
||||||
|
console.log(renderToString(element));
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user