Merge dev into master

This commit is contained in:
michael committed 2025-11-12 11:01:36 +00:00
commit 0a9dbbba9f
74 files changed
+12853 -4834

No files matched your search

+40 -46
View File
@@ -1,6 +1,6 @@
![StackPulse Logo](assets/images/stackpulse.png)
# 📦 StackPulse ![Release](https://img.shields.io/badge/release-v0.2-blue.svg)
# 📦 StackPulse ![Release](https://img.shields.io/badge/release-v0.5-blue.svg)
**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!
@@ -67,7 +67,7 @@ Ziel:
</details>
<details open>
<details>
<summary>✅ v0.4 – Release</summary>
### Backend
@@ -86,27 +86,35 @@ Ziel:
</details>
<details open>
<summary>🟡 v0.5 – In Entwicklung</summary>
<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
- [x] Neue API-Endpunkte für Logging, Benutzer- und Gruppenverwaltung und Superuser-Registrierung
- [x] Authentifizierung und Log-In
- [x] erweitertes Logging
- [x] Datenbank erweitert und Migration erweitert
- [ ] Feinsteuerung der globalen Userrechte am Benutzer
### Frontend
- [x] Log-In-Seite
- [ ] Benutzer- und Gruppenverwaltung
- [x] Erweiterte Log-Ausgabe
- [x] Konsolidierung der Ansichten und Filter im Frontend
- [ ] Benutzerverwaltung mit granularer Steuerung von Einzelrechten
### Features
- [ ] Authentifizierungssystem und Benutzerverwaltung mit Gruppenrechten
- [x] Erweiterung der Logs auf alle Bereiche
- [x] Speichern der Server- und Superusereinstellungen in die Datenbank
</details>
@@ -117,10 +125,8 @@ Ziel:
- Monitoring (Status, CPU/RAM)
- Verbesserte UI/UX
- Erweiterte Filterungen udn Sortierungen
- Multi-Server/Endpoint Verwaltung
- Multi-Server Verwaltung
- Integration Community Edition
- Integration Community Edition
</details>
---
@@ -158,41 +164,29 @@ stackpulse/
### Mit Compose starten
```bash
version: "2.4"
version: '3.8'
services:
stackpulse:
container_name: stackpulse
image: ghcr.io/mboehmlaender/stackpulse
app:
build:
context: .
dockerfile: Dockerfile
ports:
- '4001:4001'
- "4001:4001"
volumes:
- stackpulse_data:/app/backend/data
environment:
PORTAINER_URL: "Your_Portainer_Server_Address"
PORTAINER_API_KEY: "Your_Portainer_API_Key"
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
SELF_STACK_ID: "Stackpulse ID"
restart: unless-stopped
environment:
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
volumes:
stackpulse_data:
```
Die PORTAINER_ENDPOINT_ID erhältst du, wenn du die die URL im Browser ansiehst, wenn du das Dashboard in Portainer öffnest:
![PORTAINER_ENDPOINT_ID](assets/images/ENDPOINT_ID.png)
Die 3 wäre in diesem Fall Endpoint-ID.
Die STACK_SELF_ID findest du, wenn du das Frontend von StackPulse öffnest:
![SELF_STACK_ID](assets/images/SELF_STACK_ID.png)
Diese ID kann erst nach dem Deploy von Stackpulse ausgelesen werden. Vergiss daher nicht, nach dem Hinterlegen der ID in den Variablen das Stack noch einmal zu redeployen!
---
## 📋 Voraussetzungen
- Node.js >= 20
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

+6 -3
View File
@@ -1,3 +1,6 @@
PORTAINER_URL=Your_Portainer_Server_Adress
PORTAINER_API_KEY=Your_Portainer_API_Key
PORTAINER_ENDPOINT_ID=Your_Portainer_Endpoint_ID
PORTAINER_URL=Your_Portainer_Server_Adress (optional)
PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
SUPERUSER_USERNAME=Your_Superuser_Username (optional)
SUPERUSER_EMAIL=Your_Superuser_Email (optional)
SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
+300
View File
@@ -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
};
}
+200
View File
@@ -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"])
+14
View File
@@ -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();
-46
View File
@@ -1,46 +0,0 @@
import { db } from './index.js';
const createRedeployLogsTable = `
CREATE TABLE IF NOT EXISTS redeploy_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
stack_id TEXT NOT NULL,
stack_name TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
endpoint INTEGER,
redeploy_type TEXT
);
`;
db.exec(createRedeployLogsTable);
try {
const columns = db.prepare('PRAGMA table_info(redeploy_logs)').all();
const hasRedeployType = columns.some((column) => column.name === 'redeploy_type');
if (!hasRedeployType) {
db.exec('ALTER TABLE redeploy_logs ADD COLUMN redeploy_type TEXT');
console.log('ℹ️ redeploy_type column hinzugefügt');
}
} catch (err) {
console.error('⚠️ Konnte redeploy_type Spalte nicht prüfen/erstellen:', err.message);
}
console.log('✅ redeploy_logs table ready');
const createSettingsTable = `
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`;
db.exec(createSettingsTable);
console.log('✅ settings table ready');
db.close();
-181
View File
@@ -1,181 +0,0 @@
import { db } from './index.js';
const valueToArray = (value) => {
if (!value && value !== 0) return [];
const base = Array.isArray(value) ? value : [value];
return base
.flatMap((entry) => String(entry).split(','))
.map((entry) => entry.trim())
.filter(Boolean);
};
const singleValue = (value) => {
if (value === undefined || value === null) return undefined;
return Array.isArray(value) ? value[0] : value;
};
export function buildLogFilter(queryParams = {}) {
const filters = [];
const params = {};
const ids = valueToArray(queryParams.ids ?? queryParams.id)
.map((entry) => Number(entry))
.filter((value) => !Number.isNaN(value));
if (ids.length) {
const placeholders = ids.map((_, idx) => `@id${idx}`);
filters.push(`id IN (${placeholders.join(', ')})`);
ids.forEach((value, idx) => {
params[`id${idx}`] = value;
});
}
const stackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
if (stackIds.length) {
const placeholders = stackIds.map((_, idx) => `@stackId${idx}`);
filters.push(`stack_id IN (${placeholders.join(', ')})`);
stackIds.forEach((stack, idx) => {
params[`stackId${idx}`] = stack;
});
}
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
if (statuses.length) {
const placeholders = statuses.map((_, idx) => `@status${idx}`);
filters.push(`status IN (${placeholders.join(', ')})`);
statuses.forEach((entry, idx) => {
params[`status${idx}`] = entry;
});
}
const endpoints = valueToArray(queryParams.endpoints ?? queryParams.endpoint);
if (endpoints.length) {
const placeholders = endpoints.map((_, idx) => `@endpoint${idx}`);
filters.push(`endpoint IN (${placeholders.join(', ')})`);
endpoints.forEach((entry, idx) => {
const numeric = Number(entry);
params[`endpoint${idx}`] = Number.isNaN(numeric) ? entry : numeric;
});
}
const redeployTypes = valueToArray(queryParams.redeployTypes ?? queryParams.redeployType);
if (redeployTypes.length) {
const placeholders = redeployTypes.map((_, idx) => `@redeployType${idx}`);
filters.push(`redeploy_type IN (${placeholders.join(', ')})`);
redeployTypes.forEach((entry, idx) => {
params[`redeployType${idx}`] = entry;
});
}
const messageQuery = singleValue(queryParams.message);
if (messageQuery && String(messageQuery).trim()) {
filters.push('message LIKE @message');
params.message = `%${String(messageQuery).trim()}%`;
}
const from = singleValue(queryParams.from);
if (from) {
filters.push('timestamp >= @from');
params.from = from;
}
const to = singleValue(queryParams.to);
if (to) {
filters.push('timestamp <= @to');
params.to = to;
}
return {
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
params,
};
}
const insertRedeployLogStmt = db.prepare(`
INSERT INTO redeploy_logs (stack_id, stack_name, status, message, endpoint, redeploy_type)
VALUES (@stackId, @stackName, @status, @message, @endpoint, @redeployType)
`);
export function logRedeployEvent({ stackId, stackName, status, message = null, endpoint = null, redeployType = null }) {
try {
insertRedeployLogStmt.run({
stackId: String(stackId),
stackName: stackName ?? 'Unknown',
status,
message,
endpoint,
redeployType: redeployType ?? null
});
} catch (err) {
console.error('❌ Fehler beim Speichern des Redeploy-Logs:', err.message);
}
}
export function deleteLogById(id) {
const stmt = db.prepare('DELETE FROM redeploy_logs WHERE id = ?');
const info = stmt.run(id);
return info.changes || 0;
}
export function deleteLogsByFilters(queryParams = {}) {
const { whereClause, params } = buildLogFilter(queryParams);
const stmt = db.prepare(`DELETE FROM redeploy_logs ${whereClause}`);
const info = stmt.run(params);
return info.changes || 0;
}
export function exportLogsByFilters(queryParams = {}, format = 'txt') {
const { whereClause, params } = buildLogFilter(queryParams);
const rows = db.prepare(`
SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint, redeploy_type AS redeployType
FROM redeploy_logs
${whereClause}
ORDER BY datetime(timestamp) DESC
`).all(params);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
if (format === 'sql') {
const statements = rows.map((row) => {
const columns = ['id', 'timestamp', 'stack_id', 'stack_name', 'status', 'message', 'endpoint', 'redeploy_type'];
const values = [
row.id,
row.timestamp,
row.stackId,
row.stackName,
row.status,
row.message,
row.endpoint,
row.redeployType
].map((value) => {
if (value === null || value === undefined) return 'NULL';
return `'${String(value).replace(/'/g, "''")}'`;
});
return `INSERT INTO redeploy_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
});
return {
filename: `redeploy-logs-${timestamp}.sql`,
contentType: 'application/sql; charset=utf-8',
content: statements.join('\n')
};
}
const lines = rows.map((row) => {
const parts = [
`[${row.id}]`,
row.timestamp,
`Stack: ${row.stackName ?? 'Unbekannt'} (ID: ${row.stackId})`,
`Status: ${row.status}`,
`Endpoint: ${row.endpoint ?? '-'}`,
`Nachricht: ${row.message ?? '-'}`,
`Redeploy: ${row.redeployType ?? '-'}`
];
return parts.join(' | ');
});
return {
filename: `redeploy-logs-${timestamp}.txt`,
contentType: 'text/plain; charset=utf-8',
content: lines.join('\n')
};
}
+738
View File
@@ -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);
}
};
+3 -3
View File
@@ -1,8 +1,8 @@
#!/bin/sh
set -e
echo "🔧 Starte Migration (idempotent)..."
node db/migrate.js
echo "🔧 Überprüfe Datenbankschema..."
node db/ensure.js
echo "✅ Migration abgeschlossen. Starte Anwendung..."
echo "✅ Schema-Abgleich abgeschlossen. Starte Anwendung..."
exec "$@"
+342
View File
@@ -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)
}
});
}
+1924 -168
View File
File diff suppressed because it is too large. Load diff
+331
View File
@@ -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')
};
}
+27
View File
@@ -1,4 +1,5 @@
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
import { logEvent } from '../logging/eventLogs.js';
const MAINTENANCE_KEY = 'maintenance_mode';
@@ -44,6 +45,19 @@ export function isMaintenanceModeActive() {
}
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
logEvent({
category: 'wartung',
eventType: 'Wartungsmodus',
action: 'aktivieren',
status: 'gestartet',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'System',
contextId: 'System',
message: 'Wartungsmodus aktiviert',
source: 'system'
});
const now = new Date().toISOString();
return persistState({
active: true,
@@ -54,6 +68,19 @@ export function activateMaintenanceMode({ message = null, extra = null } = {}) {
}
export function deactivateMaintenanceMode({ message = null } = {}) {
logEvent({
category: 'wartung',
eventType: 'wartungsmodus',
action: 'deaktivieren',
status: 'erfolgreich',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'System',
contextId: 'System',
message: 'Wartungsmodus deaktiviert',
source: 'system'
});
return persistState({
active: false,
message,
+32
View File
@@ -8,6 +8,7 @@
"name": "backend",
"version": "1.0.0",
"dependencies": {
"@scure/bip39": "^2.0.0",
"axios": "^1.7.0",
"better-sqlite3": "^9.6.0",
"dockerode": "^4.0.7",
@@ -59,6 +60,17 @@
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -113,6 +125,26 @@
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+3 -2
View File
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"start": "node index.js",
"migrate": "node db/migrate.js"
"migrate": "node db/ensure.js"
},
"dependencies": {
"axios": "^1.7.0",
@@ -12,6 +12,7 @@
"dockerode": "^4.0.7",
"dotenv": "^16.0.0",
"express": "^4.18.0",
"socket.io": "^4.8.1"
"socket.io": "^4.8.1",
"@scure/bip39": "^2.0.0"
}
}
+375
View File
@@ -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);
}
-90
View File
@@ -1,90 +0,0 @@
function PN(e,t){for(var r=0;r<t.length;r++){const n=t[r];if(typeof n!="string"&&!Array.isArray(n)){for(const o in n)if(o!=="default"&&!(o in e)){const a=Object.getOwnPropertyDescriptor(n,o);a&&Object.defineProperty(e,o,a.get?a:{enumerable:!0,get:()=>n[o]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))n(o);new MutationObserver(o=>{for(const a of o)if(a.type==="childList")for(const i of a.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&n(i)}).observe(document,{childList:!0,subtree:!0});function r(o){const a={};return o.integrity&&(a.integrity=o.integrity),o.referrerPolicy&&(a.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?a.credentials="include":o.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function n(o){if(o.ep)return;o.ep=!0;const a=r(o);fetch(o.href,a)}})();function xx(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}function Ts(e){if(e.__esModule)return e;var t=e.default;if(typeof t=="function"){var r=function n(){return this instanceof n?Reflect.construct(t,arguments,this.constructor):t.apply(this,arguments)};r.prototype=t.prototype}else r={};return Object.defineProperty(r,"__esModule",{value:!0}),Object.keys(e).forEach(function(n){var o=Object.getOwnPropertyDescriptor(e,n);Object.defineProperty(r,n,o.get?o:{enumerable:!0,get:function(){return e[n]}})}),r}var _x={exports:{}},sd={},Tx={exports:{}},it={};/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Cs=Symbol.for("react.element"),kN=Symbol.for("react.portal"),EN=Symbol.for("react.fragment"),RN=Symbol.for("react.strict_mode"),NN=Symbol.for("react.profiler"),jN=Symbol.for("react.provider"),AN=Symbol.for("react.context"),MN=Symbol.for("react.forward_ref"),IN=Symbol.for("react.suspense"),DN=Symbol.for("react.memo"),LN=Symbol.for("react.lazy"),jm=Symbol.iterator;function FN(e){return e===null||typeof e!="object"?null:(e=jm&&e[jm]||e["@@iterator"],typeof e=="function"?e:null)}var Cx={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Ox=Object.assign,Sx={};function Vi(e,t,r){this.props=e,this.context=t,this.refs=Sx,this.updater=r||Cx}Vi.prototype.isReactComponent={};Vi.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};Vi.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Px(){}Px.prototype=Vi.prototype;function Ch(e,t,r){this.props=e,this.context=t,this.refs=Sx,this.updater=r||Cx}var Oh=Ch.prototype=new Px;Oh.constructor=Ch;Ox(Oh,Vi.prototype);Oh.isPureReactComponent=!0;var Am=Array.isArray,kx=Object.prototype.hasOwnProperty,Sh={current:null},Ex={key:!0,ref:!0,__self:!0,__source:!0};function Rx(e,t,r){var n,o={},a=null,i=null;if(t!=null)for(n in t.ref!==void 0&&(i=t.ref),t.key!==void 0&&(a=""+t.key),t)kx.call(t,n)&&!Ex.hasOwnProperty(n)&&(o[n]=t[n]);var c=arguments.length-2;if(c===1)o.children=r;else if(1<c){for(var p=Array(c),g=0;g<c;g++)p[g]=arguments[g+2];o.children=p}if(e&&e.defaultProps)for(n in c=e.defaultProps,c)o[n]===void 0&&(o[n]=c[n]);return{$$typeof:Cs,type:e,key:a,ref:i,props:o,_owner:Sh.current}}function VN(e,t){return{$$typeof:Cs,type:e.type,key:t,ref:e.ref,props:e.props,_owner:e._owner}}function Ph(e){return typeof e=="object"&&e!==null&&e.$$typeof===Cs}function zN(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(r){return t[r]})}var Mm=/\/+/g;function Hf(e,t){return typeof e=="object"&&e!==null&&e.key!=null?zN(""+e.key):t.toString(36)}function Iu(e,t,r,n,o){var a=typeof e;(a==="undefined"||a==="boolean")&&(e=null);var i=!1;if(e===null)i=!0;else switch(a){case"string":case"number":i=!0;break;case"object":switch(e.$$typeof){case Cs:case kN:i=!0}}if(i)return i=e,o=o(i),e=n===""?"."+Hf(i,0):n,Am(o)?(r="",e!=null&&(r=e.replace(Mm,"$&/")+"/"),Iu(o,t,r,"",function(g){return g})):o!=null&&(Ph(o)&&(o=VN(o,r+(!o.key||i&&i.key===o.key?"":(""+o.key).replace(Mm,"$&/")+"/")+e)),t.push(o)),1;if(i=0,n=n===""?".":n+":",Am(e))for(var c=0;c<e.length;c++){a=e[c];var p=n+Hf(a,c);i+=Iu(a,t,r,p,o)}else if(p=FN(e),typeof p=="function")for(e=p.call(e),c=0;!(a=e.next()).done;)a=a.value,p=n+Hf(a,c++),i+=Iu(a,t,r,p,o);else if(a==="object")throw t=String(e),Error("Objects are not valid as a React child (found: "+(t==="[object Object]"?"object with keys {"+Object.keys(e).join(", ")+"}":t)+"). If you meant to render a collection of children, use an array instead.");return i}function iu(e,t,r){if(e==null)return e;var n=[],o=0;return Iu(e,n,"","",function(a){return t.call(r,a,o++)}),n}function BN(e){if(e._status===-1){var t=e._result;t=t(),t.then(function(r){(e._status===0||e._status===-1)&&(e._status=1,e._result=r)},function(r){(e._status===0||e._status===-1)&&(e._status=2,e._result=r)}),e._status===-1&&(e._status=0,e._result=t)}if(e._status===1)return e._result.default;throw e._result}var Tr={current:null},Du={transition:null},WN={ReactCurrentDispatcher:Tr,ReactCurrentBatchConfig:Du,ReactCurrentOwner:Sh};it.Children={map:iu,forEach:function(e,t,r){iu(e,function(){t.apply(this,arguments)},r)},count:function(e){var t=0;return iu(e,function(){t++}),t},toArray:function(e){return iu(e,function(t){return t})||[]},only:function(e){if(!Ph(e))throw Error("React.Children.only expected to receive a single React element child.");return e}};it.Component=Vi;it.Fragment=EN;it.Profiler=NN;it.PureComponent=Ch;it.StrictMode=RN;it.Suspense=IN;it.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=WN;it.cloneElement=function(e,t,r){if(e==null)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+e+".");var n=Ox({},e.props),o=e.key,a=e.ref,i=e._owner;if(t!=null){if(t.ref!==void 0&&(a=t.ref,i=Sh.current),t.key!==void 0&&(o=""+t.key),e.type&&e.type.defaultProps)var c=e.type.defaultProps;for(p in t)kx.call(t,p)&&!Ex.hasOwnProperty(p)&&(n[p]=t[p]===void 0&&c!==void 0?c[p]:t[p])}var p=arguments.length-2;if(p===1)n.children=r;else if(1<p){c=Array(p);for(var g=0;g<p;g++)c[g]=arguments[g+2];n.children=c}return{$$typeof:Cs,type:e.type,key:o,ref:a,props:n,_owner:i}};it.createContext=function(e){return e={$$typeof:AN,_currentValue:e,_currentValue2:e,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null},e.Provider={$$typeof:jN,_conLine truncated
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var UN=A,HN=Symbol.for("react.element"),$N=Symbol.for("react.fragment"),qN=Object.prototype.hasOwnProperty,GN=UN.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,KN={key:!0,ref:!0,__self:!0,__source:!0};function Nx(e,t,r){var n,o={},a=null,i=null;r!==void 0&&(a=""+r),t.key!==void 0&&(a=""+t.key),t.ref!==void 0&&(i=t.ref);for(n in t)qN.call(t,n)&&!KN.hasOwnProperty(n)&&(o[n]=t[n]);if(e&&e.defaultProps)for(n in t=e.defaultProps,t)o[n]===void 0&&(o[n]=t[n]);return{$$typeof:HN,type:e,key:a,ref:i,props:o,_owner:GN.current}}sd.Fragment=$N;sd.jsx=Nx;sd.jsxs=Nx;_x.exports=sd;var D=_x.exports,Hp={},jx={exports:{}},qr={},Ax={exports:{}},Mx={};/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/(function(e){function t(H,B){var U=H.length;H.push(B);e:for(;0<U;){var K=U-1>>>1,Q=H[K];if(0<o(Q,B))H[K]=B,H[U]=Q,U=K;else break e}}function r(H){return H.length===0?null:H[0]}function n(H){if(H.length===0)return null;var B=H[0],U=H.pop();if(U!==B){H[0]=U;e:for(var K=0,Q=H.length,ne=Q>>>1;K<ne;){var ue=2*(K+1)-1,le=H[ue],re=ue+1,be=H[re];if(0>o(le,U))re<Q&&0>o(be,le)?(H[K]=be,H[re]=U,K=re):(H[K]=le,H[ue]=U,K=ue);else if(re<Q&&0>o(be,U))H[K]=be,H[re]=U,K=re;else break e}}return B}function o(H,B){var U=H.sortIndex-B.sortIndex;return U!==0?U:H.id-B.id}if(typeof performance=="object"&&typeof performance.now=="function"){var a=performance;e.unstable_now=function(){return a.now()}}else{var i=Date,c=i.now();e.unstable_now=function(){return i.now()-c}}var p=[],g=[],v=1,_=null,O=3,C=!1,T=!1,x=!1,b=typeof setTimeout=="function"?setTimeout:null,d=typeof clearTimeout=="function"?clearTimeout:null,l=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function u(H){for(var B=r(g);B!==null;){if(B.callback===null)n(g);else if(B.startTime<=H)n(g),B.sortIndex=B.expirationTime,t(p,B);else break;B=r(g)}}function s(H){if(x=!1,u(H),!T)if(r(p)!==null)T=!0,G(h);else{var B=r(g);B!==null&&X(s,B.startTime-H)}}function h(H,B){T=!1,x&&(x=!1,d(E),E=-1),C=!0;var U=O;try{for(u(B),_=r(p);_!==null&&(!(_.expirationTime>B)||H&&!N());){var K=_.callback;if(typeof K=="function"){_.callback=null,O=_.priorityLevel;var Q=K(_.expirationTime<=B);B=e.unstable_now(),typeof Q=="function"?_.callback=Q:_===r(p)&&n(p),u(B)}else n(p);_=r(p)}if(_!==null)var ne=!0;else{var ue=r(g);ue!==null&&X(s,ue.startTime-B),ne=!1}return ne}finally{_=null,O=U,C=!1}}var m=!1,w=null,E=-1,k=5,P=-1;function N(){return!(e.unstable_now()-P<k)}function j(){if(w!==null){var H=e.unstable_now();P=H;var B=!0;try{B=w(!0,H)}finally{B?I():(m=!1,w=null)}}else m=!1}var I;if(typeof l=="function")I=function(){l(j)};else if(typeof MessageChannel<"u"){var F=new MessageChannel,W=F.port2;F.port1.onmessage=j,I=function(){W.postMessage(null)}}else I=function(){b(j,0)};function G(H){w=H,m||(m=!0,I())}function X(H,B){E=b(function(){H(e.unstable_now())},B)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(H){H.callback=null},e.unstable_continueExecution=function(){T||C||(T=!0,G(h))},e.unstable_forceFrameRate=function(H){0>H||125<H?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):k=0<H?Math.floor(1e3/H):5},e.unstable_getCurrentPriorityLevel=function(){return O},e.unstable_getFirstCallbackNode=function(){return r(p)},e.unstable_next=function(H){switch(O){case 1:case 2:case 3:var B=3;break;default:B=O}var U=O;O=B;try{return H()}finally{O=U}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(H,B){switch(H){case 1:case 2:case 3:case 4:case 5:break;default:H=3}var U=O;O=H;try{return B()}finally{O=U}},e.unstable_scheduleCallback=function(H,B,U){var K=e.unstable_now();switch(typeof U=="object"&&U!==null?(U=U.delay,U=typeof U=="number"&&0<U?K+U:K):U=K,H){case 1:var Q=-1;break;case 2:Q=250;break;case 5:Q=1073741823;break;case 4:Q=1e4;break;default:Q=5e3}return Q=U+Q,H={id:v++,callback:B,priorityLevel:H,startTime:U,expirationTime:Q,sortIndex:-1},U>K?(H.sortIndex=U,t(g,H),r(p)===null&&H===r(g)&&(x?(d(E),E=-1):x=!0,X(s,U-K))):(H.sortIndex=Q,t(p,H),T||C||(T=!0,G(h))),H},e.unstable_shouldYield=N,e.unstable_wrapCallback=function(H){var B=O;return function(){var U=O;O=B;try{return H.apply(this,arguments)}finally{O=U}}}})(Mx);Ax.exports=Mx;var YN=Ax.exports;/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Ix=A,Hr=YN;function me(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,r=1;r<arguments.length;r++)t+="&args[]="+encodeURIComponent(arguments[r]);return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var Dx=new Set,Yl={};function Ra(e,t){Ci(e,t),Ci(e+"Capture",t)}function Ci(e,t){for(Yl[e]=t,e=0;e<t.length;e++)Dx.add(t[e])}var to=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),$p=Object.prototype.hasOwnProperty,XN=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Im={},Dm={};function QN(e){return $p.call(Dm,e)?!0:$p.call(Im,e)?!1:XN.test(e)?Dm[e]=!0:(Im[e]=!0,!1)}function JN(e,t,r,n){if(r!==null&&r.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return n?!1:r!==null?!r.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function ZN(e,t,r,n){if(t===null||typeof t>"u"||JN(e,t,r,n))return!0;if(n)return!1;if(r!==null)switch(r.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function Cr(e,t,r,n,o,a,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=n,this.attributeNamespace=o,this.mustUseProperty=r,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=i}var cr={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){cr[e]=new Cr(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];cr[t]=new Cr(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){cr[e]=new Cr(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){cr[e]=new Cr(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){cr[e]=new Cr(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){cr[e]=new Cr(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){cr[e]=new Cr(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){cr[e]=new Cr(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){cr[e]=new Cr(e,5,!1,e.toLowerCase(),null,!1,!1)});var Eh=/[\-:]([a-z])/g;function Rh(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Eh,Rh);cr[t]=new Cr(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Eh,Rh);cr[t]=new Cr(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Eh,Rh);cr[t]=new Cr(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){cr[e]=new Cr(e,1,!1,e.toLowerCase(),null,!1,!1)});cr.xlinkHref=new Cr("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){cr[e]=new Cr(e,1,!1,e.toLowerCase(),null,!0,!0)});function Nh(e,t,r,n){var o=cr.hasOwnProperty(t)?cr[t]:null;(o!==null?o.type!==0:n||!(2<t.length)||t[0]!=="o"&&t[0]!== Line truncated
`+$f+e}var qf=!1;function Gf(e,t){if(!e||qf)return"";qf=!0;var r=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(t)if(t=function(){throw Error()},Object.defineProperty(t.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(t,[])}catch(g){var n=g}Reflect.construct(e,[],t)}else{try{t.call()}catch(g){n=g}e.call(t.prototype)}else{try{throw Error()}catch(g){n=g}e()}}catch(g){if(g&&n&&typeof g.stack=="string"){for(var o=g.stack.split(`
`),a=n.stack.split(`
`),i=o.length-1,c=a.length-1;1<=i&&0<=c&&o[i]!==a[c];)c--;for(;1<=i&&0<=c;i--,c--)if(o[i]!==a[c]){if(i!==1||c!==1)do if(i--,c--,0>c||o[i]!==a[c]){var p=`
`+o[i].replace(" at new "," at ");return e.displayName&&p.includes("<anonymous>")&&(p=p.replace("<anonymous>",e.displayName)),p}while(1<=i&&0<=c);break}}}finally{qf=!1,Error.prepareStackTrace=r}return(e=e?e.displayName||e.name:"")?Pl(e):""}function e2(e){switch(e.tag){case 5:return Pl(e.type);case 16:return Pl("Lazy");case 13:return Pl("Suspense");case 19:return Pl("SuspenseList");case 0:case 2:case 15:return e=Gf(e.type,!1),e;case 11:return e=Gf(e.type.render,!1),e;case 1:return e=Gf(e.type,!0),e;default:return""}}function Yp(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Za:return"Fragment";case Ja:return"Portal";case qp:return"Profiler";case jh:return"StrictMode";case Gp:return"Suspense";case Kp:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Fx:return(e.displayName||"Context")+".Consumer";case Lx:return(e._context.displayName||"Context")+".Provider";case Ah:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Mh:return t=e.displayName||null,t!==null?t:Yp(e.type)||"Memo";case So:t=e._payload,e=e._init;try{return Yp(e(t))}catch{}}return null}function t2(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Yp(t);case 8:return t===jh?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function $o(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function zx(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function r2(e){var t=zx(e)?"checked":"value",r=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),n=""+e[t];if(!e.hasOwnProperty(t)&&typeof r<"u"&&typeof r.get=="function"&&typeof r.set=="function"){var o=r.get,a=r.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(i){n=""+i,a.call(this,i)}}),Object.defineProperty(e,t,{enumerable:r.enumerable}),{getValue:function(){return n},setValue:function(i){n=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function su(e){e._valueTracker||(e._valueTracker=r2(e))}function Bx(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var r=t.getValue(),n="";return e&&(n=zx(e)?e.checked?"true":"false":e.value),e=n,e!==r?(t.setValue(e),!0):!1}function fc(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Xp(e,t){var r=t.checked;return Vt({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:r??e._wrapperState.initialChecked})}function Fm(e,t){var r=t.defaultValue==null?"":t.defaultValue,n=t.checked!=null?t.checked:t.defaultChecked;r=$o(t.value!=null?t.value:r),e._wrapperState={initialChecked:n,initialValue:r,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Wx(e,t){t=t.checked,t!=null&&Nh(e,"checked",t,!1)}function Qp(e,t){Wx(e,t);var r=$o(t.value),n=t.type;if(r!=null)n==="number"?(r===0&&e.value===""||e.value!=r)&&(e.value=""+r):e.value!==""+r&&(e.value=""+r);else if(n==="submit"||n==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Jp(e,t.type,r):t.hasOwnProperty("defaultValue")&&Jp(e,t.type,$o(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Vm(e,t,r){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var n=t.type;if(!(n!=="submit"&&n!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,r||t===e.value||(e.value=t),e.defaultValue=t}r=e.name,r!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,r!==""&&(e.name=r)}function Jp(e,t,r){(t!=="number"||fc(e.ownerDocument)!==e)&&(r==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+r&&(e.defaultValue=""+r))}var kl=Array.isArray;function fi(e,t,r,n){if(e=e.options,t){t={};for(var o=0;o<r.length;o++)t["$"+r[o]]=!0;for(r=0;r<e.length;r++)o=t.hasOwnProperty("$"+e[r].value),e[r].selected!==o&&(e[r].selected=o),o&&n&&(e[r].defaultSelected=!0)}else{for(r=""+$o(r),t=null,o=0;o<e.length;o++){if(e[o].value===r){e[o].selected=!0,n&&(e[o].defaultSelected=!0);return}t!==null||e[o].disabled||(t=e[o])}t!==null&&(t.selected=!0)}}function Zp(e,t){if(t.dan Line truncated
`).replace(fj,"")}function bu(e,t,r){if(t=sy(t),sy(e)!==t&&r)throw Error(me(425))}function wc(){}var f0=null,p0=null;function h0(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var g0=typeof setTimeout=="function"?setTimeout:void 0,pj=typeof clearTimeout=="function"?clearTimeout:void 0,uy=typeof Promise=="function"?Promise:void 0,hj=typeof queueMicrotask=="function"?queueMicrotask:typeof uy<"u"?function(e){return uy.resolve(null).then(e).catch(gj)}:g0;function gj(e){setTimeout(function(){throw e})}function ip(e,t){var r=t,n=0;do{var o=r.nextSibling;if(e.removeChild(r),o&&o.nodeType===8)if(r=o.data,r==="/$"){if(n===0){e.removeChild(o),es(t);return}n--}else r!=="$"&&r!=="$?"&&r!=="$!"||n++;r=o}while(r);es(t)}function Lo(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?")break;if(t==="/$")return null}}return e}function cy(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var r=e.data;if(r==="$"||r==="$!"||r==="$?"){if(t===0)return e;t--}else r==="/$"&&t++}e=e.previousSibling}return null}var Bi=Math.random().toString(36).slice(2),Ln="__reactFiber$"+Bi,as="__reactProps$"+Bi,ro="__reactContainer$"+Bi,b0="__reactEvents$"+Bi,bj="__reactListeners$"+Bi,vj="__reactHandles$"+Bi;function fa(e){var t=e[Ln];if(t)return t;for(var r=e.parentNode;r;){if(t=r[ro]||r[Ln]){if(r=t.alternate,t.child!==null||r!==null&&r.child!==null)for(e=cy(e);e!==null;){if(r=e[Ln])return r;e=cy(e)}return t}e=r,r=e.parentNode}return null}function Ps(e){return e=e[Ln]||e[ro],!e||e.tag!==5&&e.tag!==6&&e.tag!==13&&e.tag!==3?null:e}function ni(e){if(e.tag===5||e.tag===6)return e.stateNode;throw Error(me(33))}function pd(e){return e[as]||null}var v0=[],oi=-1;function Yo(e){return{current:e}}function St(e){0>oi||(e.current=v0[oi],v0[oi]=null,oi--)}function _t(e,t){oi++,v0[oi]=e.current,e.current=t}var qo={},mr=Yo(qo),Ar=Yo(!1),wa=qo;function Oi(e,t){var r=e.type.contextTypes;if(!r)return qo;var n=e.stateNode;if(n&&n.__reactInternalMemoizedUnmaskedChildContext===t)return n.__reactInternalMemoizedMaskedChildContext;var o={},a;for(a in r)o[a]=t[a];return n&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=o),o}function Mr(e){return e=e.childContextTypes,e!=null}function xc(){St(Ar),St(mr)}function dy(e,t,r){if(mr.current!==qo)throw Error(me(168));_t(mr,t),_t(Ar,r)}function R_(e,t,r){var n=e.stateNode;if(t=t.childContextTypes,typeof n.getChildContext!="function")return r;n=n.getChildContext();for(var o in n)if(!(o in t))throw Error(me(108,t2(e)||"Unknown",o));return Vt({},r,n)}function _c(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||qo,wa=mr.current,_t(mr,e),_t(Ar,Ar.current),!0}function fy(e,t,r){var n=e.stateNode;if(!n)throw Error(me(169));r?(e=R_(e,t,wa),n.__reactInternalMemoizedMergedChildContext=e,St(Ar),St(mr),_t(mr,e)):St(Ar),_t(Ar,r)}var Kn=null,hd=!1,lp=!1;function N_(e){Kn===null?Kn=[e]:Kn.push(e)}function mj(e){hd=!0,N_(e)}function Xo(){if(!lp&&Kn!==null){lp=!0;var e=0,t=mt;try{var r=Kn;for(mt=1;e<r.length;e++){var n=r[e];do n=n(!0);while(n!==null)}Kn=null,hd=!1}catch(o){throw Kn!==null&&(Kn=Kn.slice(e+1)),r_(Dh,Xo),o}finally{mt=t,lp=!1}}return null}var ai=[],ii=0,Tc=null,Cc=0,rn=[],nn=0,xa=null,Yn=1,Xn="";function ca(e,t){ai[ii++]=Cc,ai[ii++]=Tc,Tc=e,Cc=t}function j_(e,t,r){rn[nn++]=Yn,rn[nn++]=Xn,rn[nn++]=xa,xa=e;var n=Yn;e=Xn;var o=32-_n(n)-1;n&=~(1<<o),r+=1;var a=32-_n(t)+o;if(30<a){var i=o-o%5;a=(n&(1<<i)-1).toString(32),n>>=i,o-=i,Yn=1<<32-_n(t)+o|r<<o|n,Xn=a+e}else Yn=1<<a|r<<o|n,Xn=e}function $h(e){e.return!==null&&(ca(e,1),j_(e,1,0))}function qh(e){for(;e===Tc;)Tc=ai[--ii],ai[ii]=null,Cc=ai[--ii],ai[ii]=null;for(;e===xa;)xa=rn[--nn],rn[nn]=null,Xn=rn[--nn],rn[nn]=null,Yn=rn[--nn],rn[nn]=null}var zr=null,Vr=null,Rt=!1,yn=null;function A_(e,t){var r=an(5,null,null,0);r.elementType="DELETED",r.stateNode=t,r.return=e,t=e.deletions,t===null?(e.deletions=[r],e.flags|=16):t.push(r)}function py(e,t){switch(e.tag){case 5:var r=e.type;return t=t.nodeType!==1||r.toLowerCase()!==t.nodeName.toLowerCase()?null:t,t!==null?(e.stateNode=t,zr=e,Vr=Lo(t.firstChild),!0):!1;case 6:return t=e.pendingProps===""||t.nodeType!==3?null:t,t!==null?(e.stateNode=t,zr=e,Vr=null,!0):!1;case 13:return t=t.nodeType!==8?null:t,t!==null?(r=xa!==null?{id:Yn,overflow:Xn}:null,e.memoizedState={dehydrated:t,treeContext:r,retryLane:1073741824},r=an(18,null,null,0),r.stateNode=t,r.return=e,e.child=r,zr=e,Vr=null,!0):!1;default:return!1}}function m0(e){return(e.mode&1)!==0&&(e.flags&128)===0}function y0(e){if(Rt){var t=Vr;if(t){var r=t;if(!py(e,t)){if(m0(e))throw Error(me(418));t=Lo(r.nextSibling);var n=zr;t&&py(e,t)?A_(n,r):(e.flags=e.flags&-4097|2,Rt=!1,zr=e)}}else{if(m0(e))throw Error(me(418));e.flags=e.flags&-4097|2,Rt=!1,zr=e}}}function hy(e){ Line truncated
Error generating stack: `+a.message+`
`+a.stack}return{value:e,source:t,stack:o,digest:null}}function fp(e,t,r){return{value:e,source:null,stack:r??null,digest:t??null}}function T0(e,t){try{console.error(t.value)}catch(r){setTimeout(function(){throw r})}}var Pj=typeof WeakMap=="function"?WeakMap:Map;function lT(e,t,r){r=Jn(-1,r),r.tag=3,r.payload={element:null};var n=t.value;return r.callback=function(){jc||(jc=!0,A0=n),T0(e,t)},r}function sT(e,t,r){r=Jn(-1,r),r.tag=3;var n=e.type.getDerivedStateFromError;if(typeof n=="function"){var o=t.value;r.payload=function(){return n(o)},r.callback=function(){T0(e,t)}}var a=e.stateNode;return a!==null&&typeof a.componentDidCatch=="function"&&(r.callback=function(){T0(e,t),typeof n!="function"&&(Vo===null?Vo=new Set([this]):Vo.add(this));var i=t.stack;this.componentDidCatch(t.value,{componentStack:i!==null?i:""})}),r}function _y(e,t,r){var n=e.pingCache;if(n===null){n=e.pingCache=new Pj;var o=new Set;n.set(t,o)}else o=n.get(t),o===void 0&&(o=new Set,n.set(t,o));o.has(r)||(o.add(r),e=Bj.bind(null,e,t,r),t.then(e,e))}function Ty(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function Cy(e,t,r,n,o){return e.mode&1?(e.flags|=65536,e.lanes=o,e):(e===t?e.flags|=65536:(e.flags|=128,r.flags|=131072,r.flags&=-52805,r.tag===1&&(r.alternate===null?r.tag=17:(t=Jn(-1,1),t.tag=2,Fo(r,t,1))),r.lanes|=1),e)}var kj=lo.ReactCurrentOwner,Nr=!1;function xr(e,t,r,n){t.child=e===null?z_(t,null,r,n):Pi(t,e.child,r,n)}function Oy(e,t,r,n,o){r=r.render;var a=t.ref;return bi(t,o),n=ng(e,t,r,n,a,o),r=og(),e!==null&&!Nr?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~o,oo(e,t,o)):(Rt&&r&&$h(t),t.flags|=1,xr(e,t,n,o),t.child)}function Sy(e,t,r,n,o){if(e===null){var a=r.type;return typeof a=="function"&&!hg(a)&&a.defaultProps===void 0&&r.compare===null&&r.defaultProps===void 0?(t.tag=15,t.type=a,uT(e,t,a,n,o)):(e=qu(r.type,null,n,t,t.mode,o),e.ref=t.ref,e.return=t,t.child=e)}if(a=e.child,!(e.lanes&o)){var i=a.memoizedProps;if(r=r.compare,r=r!==null?r:rs,r(i,n)&&e.ref===t.ref)return oo(e,t,o)}return t.flags|=1,e=Bo(a,n),e.ref=t.ref,e.return=t,t.child=e}function uT(e,t,r,n,o){if(e!==null){var a=e.memoizedProps;if(rs(a,n)&&e.ref===t.ref)if(Nr=!1,t.pendingProps=n=a,(e.lanes&o)!==0)e.flags&131072&&(Nr=!0);else return t.lanes=e.lanes,oo(e,t,o)}return C0(e,t,r,n,o)}function cT(e,t,r){var n=t.pendingProps,o=n.children,a=e!==null?e.memoizedState:null;if(n.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},_t(ui,Fr),Fr|=r;else{if(!(r&1073741824))return e=a!==null?a.baseLanes|r:r,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,_t(ui,Fr),Fr|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},n=a!==null?a.baseLanes:r,_t(ui,Fr),Fr|=n}else a!==null?(n=a.baseLanes|r,t.memoizedState=null):n=r,_t(ui,Fr),Fr|=n;return xr(e,t,o,r),t.child}function dT(e,t){var r=t.ref;(e===null&&r!==null||e!==null&&e.ref!==r)&&(t.flags|=512,t.flags|=2097152)}function C0(e,t,r,n,o){var a=Mr(r)?wa:mr.current;return a=Oi(t,a),bi(t,o),r=ng(e,t,r,n,a,o),n=og(),e!==null&&!Nr?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~o,oo(e,t,o)):(Rt&&n&&$h(t),t.flags|=1,xr(e,t,r,o),t.child)}function Py(e,t,r,n,o){if(Mr(r)){var a=!0;_c(t)}else a=!1;if(bi(t,o),t.stateNode===null)Uu(e,t),F_(t,r,n),_0(t,r,n,o),n=!0;else if(e===null){var i=t.stateNode,c=t.memoizedProps;i.props=c;var p=i.context,g=r.contextType;typeof g=="object"&&g!==null?g=cn(g):(g=Mr(r)?wa:mr.current,g=Oi(t,g));var v=r.getDerivedStateFromProps,_=typeof v=="function"||typeof i.getSnapshotBeforeUpdate=="function";_||typeof i.UNSAFE_componentWillReceiveProps!="function"&&typeof i.componentWillReceiveProps!="function"||(c!==n||p!==g)&&my(t,i,n,g),Po=!1;var O=t.memoizedState;i.state=O,Pc(t,n,i,o),p=t.memoizedState,c!==n||O!==p||Ar.current||Po?(typeof v=="function"&&(x0(t,r,v,n),p=t.memoizedState),(c=Po||vy(t,r,c,n,O,p,g))?(_||typeof i.UNSAFE_componentWillMount!="function"&&typeof i.componentWillMount!="function"||(typeof i.componentWillMount=="function"&&i.componentWillMount(),typeof i.UNSAFE_componentWillMount=="function"&&i.UNSAFE_componentWillMount()),typeof i.componentDidMount=="function"&&(t.flags|=4194308)):(typeof i.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=n,t.memoizedState=p),i.props=n,i.state=p,i.context=g,n=c):(typeof i.componentDidMount=="function"&&(t.flags|=4194308),n=!1)}else{i=t.stateNode,D_(e,t),c=t.memoizedProps,g=t.type===t.elementType?c:bn(t.type,c),i.props=g,_=t.pendingProps,O=i.context,p=r.contextType,typeof p=="object"&&p!==null?p=cn(p):(p=Mr(r)?wa:mr.current,p=Oi(t,p));var C=r.getDerivedStateFromProps;(v=typeof C=="function"||typeof i.getSnapshotBeforeUpdate=="function")||typeof i.UNSAFE_componentWillReceiveProps!="function"&&typeof i.componentWillReceiveProps!="function"||(c!==_||O!==p)&&my(t,i,n,p),Po=!1,O=t.memoizedState,i.state=O,Pc(t,n,i,o);var T=t.memoizedS Line truncated
* @remix-run/router v1.10.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function fs(){return fs=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(e[n]=r[n])}return e},fs.apply(this,arguments)}var Ao;(function(e){e.Pop="POP",e.Push="PUSH",e.Replace="REPLACE"})(Ao||(Ao={}));const By="popstate";function Jj(e){e===void 0&&(e={});function t(n,o){let{pathname:a,search:i,hash:c}=n.location;return L0("",{pathname:a,search:i,hash:c},o.state&&o.state.usr||null,o.state&&o.state.key||"default")}function r(n,o){return typeof o=="string"?o:Dc(o)}return eA(t,r,null,e)}function Ft(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function yg(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function Zj(){return Math.random().toString(36).substr(2,8)}function Wy(e,t){return{usr:e.state,key:e.key,idx:t}}function L0(e,t,r,n){return r===void 0&&(r=null),fs({pathname:typeof e=="string"?e:e.pathname,search:"",hash:""},typeof t=="string"?Wi(t):t,{state:r,key:t&&t.key||n||Zj()})}function Dc(e){let{pathname:t="/",search:r="",hash:n=""}=e;return r&&r!=="?"&&(t+=r.charAt(0)==="?"?r:"?"+r),n&&n!=="#"&&(t+=n.charAt(0)==="#"?n:"#"+n),t}function Wi(e){let t={};if(e){let r=e.indexOf("#");r>=0&&(t.hash=e.substr(r),e=e.substr(0,r));let n=e.indexOf("?");n>=0&&(t.search=e.substr(n),e=e.substr(0,n)),e&&(t.pathname=e)}return t}function eA(e,t,r,n){n===void 0&&(n={});let{window:o=document.defaultView,v5Compat:a=!1}=n,i=o.history,c=Ao.Pop,p=null,g=v();g==null&&(g=0,i.replaceState(fs({},i.state,{idx:g}),""));function v(){return(i.state||{idx:null}).idx}function _(){c=Ao.Pop;let b=v(),d=b==null?null:b-g;g=b,p&&p({action:c,location:x.location,delta:d})}function O(b,d){c=Ao.Push;let l=L0(x.location,b,d);r&&r(l,b),g=v()+1;let u=Wy(l,g),s=x.createHref(l);try{i.pushState(u,"",s)}catch(h){if(h instanceof DOMException&&h.name==="DataCloneError")throw h;o.location.assign(s)}a&&p&&p({action:c,location:x.location,delta:1})}function C(b,d){c=Ao.Replace;let l=L0(x.location,b,d);r&&r(l,b),g=v();let u=Wy(l,g),s=x.createHref(l);i.replaceState(u,"",s),a&&p&&p({action:c,location:x.location,delta:0})}function T(b){let d=o.location.origin!=="null"?o.location.origin:o.location.href,l=typeof b=="string"?b:Dc(b);return Ft(d,"No window.location.(origin|href) available to create URL for href: "+l),new URL(l,d)}let x={get action(){return c},get location(){return e(o,i)},listen(b){if(p)throw new Error("A history only accepts one active listener");return o.addEventListener(By,_),p=b,()=>{o.removeEventListener(By,_),p=null}},createHref(b){return t(o,b)},createURL:T,encodeLocation(b){let d=T(b);return{pathname:d.pathname,search:d.search,hash:d.hash}},push:O,replace:C,go(b){return i.go(b)}};return x}var Uy;(function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"})(Uy||(Uy={}));function tA(e,t,r){r===void 0&&(r="/");let n=typeof t=="string"?Wi(t):t,o=ps(n.pathname||"/",r);if(o==null)return null;let a=IT(e);rA(a);let i=null;for(let c=0;i==null&&c<a.length;++c)i=dA(a[c],pA(o));return i}function IT(e,t,r,n){t===void 0&&(t=[]),r===void 0&&(r=[]),n===void 0&&(n="");let o=(a,i,c)=>{let p={relativePath:c===void 0?a.path||"":c,caseSensitive:a.caseSensitive===!0,childrenIndex:i,route:a};p.relativePath.startsWith("/")&&(Ft(p.relativePath.startsWith(n),'Absolute route path "'+p.relativePath+'" nested under path '+('"'+n+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),p.relativePath=p.relativePath.slice(n.length));let g=Wo([n,p.relativePath]),v=r.concat(p);a.children&&a.children.length>0&&(Ft(a.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+g+'".')),IT(a.children,t,v,g)),!(a.path==null&&!a.index)&&t.push({path:g,score:uA(g,a.index),routesMeta:v})};return e.forEach((a,i)=>{var c;if(a.path===""||!((c=a.path)!=null&&c.includes("?")))o(a,i);else for(let p of DT(a.path))o(a,i,p)}),t}function DT(e){let t=e.split("/");if(t.length===0)return[];let[r,...n]=t,o=r.endsWith("?"),a=r.replace(/\?$/,"");if(n.length===0)return o?[a,""]:[a];let i=DT(n.join("/")),c=[];return c.push(...i.map(p=>p===""?a:[a,p].join("/"))),o&&c.push(...i),c.map(p=>e.startsWith("/")&&p===""?"/":p)}function rA(e){e.sort((t,r)=>t.score!==r.score?r.score-t.score:cA(t.routesMeta.map(n=>n.childrenIndex),r.routesMeta.map(n=>n.childrenIndex)))}const nA=/^:\w+$/,oA=3,aA=2,iA=1,lA=10,sA=-2,Hy=e=>e==="*";function uA(e,t){let r=e.split("/"),n=r.length;return r.some(Hy)&&(n+=sA),t&&(n+=aA),r.filter(o=>!Hy(o)).reduce((o,a)=>o+(nA.test(a)?oA:a===""?iA:lA),n)}function cA(e,t){return e.length===t.length&&e.slice(0,-1).every((n,o)=>n===t[o])?e[e.length-1]-t[t.length-1]:0}function dA(e,t){let{routesMeta:r}=e,n={},o="/",a=[];for(let i=0;i<r.length;++i){let c=r[i],p=i===r.length-1,g=o==="/"?t:t.slice(o.length)||"/",v=F0({path:c.relativePath,caseSensitive:c.caseSensitive,end:p},g);if(!v)return null;Object.assign(n,v.paLine truncated
* React Router v6.17.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function Lc(){return Lc=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(e[n]=r[n])}return e},Lc.apply(this,arguments)}const Cd=A.createContext(null),FT=A.createContext(null),ja=A.createContext(null),Od=A.createContext(null),Qo=A.createContext({outlet:null,matches:[],isDataRoute:!1}),VT=A.createContext(null);function _A(e,t){let{relative:r}=t===void 0?{}:t;Ui()||Ft(!1);let{basename:n,navigator:o}=A.useContext(ja),{hash:a,pathname:i,search:c}=Sd(e,{relative:r}),p=i;return n!=="/"&&(p=i==="/"?n:Wo([n,i])),o.createHref({pathname:p,search:c,hash:a})}function Ui(){return A.useContext(Od)!=null}function Aa(){return Ui()||Ft(!1),A.useContext(Od).location}function zT(e){A.useContext(ja).static||A.useLayoutEffect(e)}function BT(){let{isDataRoute:e}=A.useContext(Qo);return e?IA():TA()}function TA(){Ui()||Ft(!1);let e=A.useContext(Cd),{basename:t,navigator:r}=A.useContext(ja),{matches:n}=A.useContext(Qo),{pathname:o}=Aa(),a=JSON.stringify(wg(n).map(p=>p.pathnameBase)),i=A.useRef(!1);return zT(()=>{i.current=!0}),A.useCallback(function(p,g){if(g===void 0&&(g={}),!i.current)return;if(typeof p=="number"){r.go(p);return}let v=xg(p,JSON.parse(a),o,g.relative==="path");e==null&&t!=="/"&&(v.pathname=v.pathname==="/"?t:Wo([t,v.pathname])),(g.replace?r.replace:r.push)(v,g.state,g)},[t,r,a,o,e])}function Sd(e,t){let{relative:r}=t===void 0?{}:t,{matches:n}=A.useContext(Qo),{pathname:o}=Aa(),a=JSON.stringify(wg(n).map(i=>i.pathnameBase));return A.useMemo(()=>xg(e,JSON.parse(a),o,r==="path"),[e,a,o,r])}function CA(e,t){return OA(e,t)}function OA(e,t,r){Ui()||Ft(!1);let{navigator:n}=A.useContext(ja),{matches:o}=A.useContext(Qo),a=o[o.length-1],i=a?a.params:{};a&&a.pathname;let c=a?a.pathnameBase:"/";a&&a.route;let p=Aa(),g;if(t){var v;let x=typeof t=="string"?Wi(t):t;c==="/"||(v=x.pathname)!=null&&v.startsWith(c)||Ft(!1),g=x}else g=p;let _=g.pathname||"/",O=c==="/"?_:_.slice(c.length)||"/",C=tA(e,{pathname:O}),T=RA(C&&C.map(x=>Object.assign({},x,{params:Object.assign({},i,x.params),pathname:Wo([c,n.encodeLocation?n.encodeLocation(x.pathname).pathname:x.pathname]),pathnameBase:x.pathnameBase==="/"?c:Wo([c,n.encodeLocation?n.encodeLocation(x.pathnameBase).pathname:x.pathnameBase])})),o,r);return t&&T?A.createElement(Od.Provider,{value:{location:Lc({pathname:"/",search:"",hash:"",state:null,key:"default"},g),navigationType:Ao.Pop}},T):T}function SA(){let e=MA(),t=wA(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),r=e instanceof Error?e.stack:null,o={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"},a=null;return A.createElement(A.Fragment,null,A.createElement("h2",null,"Unexpected Application Error!"),A.createElement("h3",{style:{fontStyle:"italic"}},t),r?A.createElement("pre",{style:o},r):null,a)}const PA=A.createElement(SA,null);class kA extends A.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,r){return r.location!==t.location||r.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error||r.error,location:r.location,revalidation:t.revalidation||r.revalidation}}componentDidCatch(t,r){console.error("React Router caught the following error during render",t,r)}render(){return this.state.error?A.createElement(Qo.Provider,{value:this.props.routeContext},A.createElement(VT.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function EA(e){let{routeContext:t,match:r,children:n}=e,o=A.useContext(Cd);return o&&o.static&&o.staticContext&&(r.route.errorElement||r.route.ErrorBoundary)&&(o.staticContext._deepestRenderedBoundaryId=r.route.id),A.createElement(Qo.Provider,{value:t},n)}function RA(e,t,r){var n;if(t===void 0&&(t=[]),r===void 0&&(r=null),e==null){var o;if((o=r)!=null&&o.errors)e=r.matches;else return null}let a=e,i=(n=r)==null?void 0:n.errors;if(i!=null){let c=a.findIndex(p=>p.route.id&&(i==null?void 0:i[p.route.id]));c>=0||Ft(!1),a=a.slice(0,Math.min(a.length,c+1))}return a.reduceRight((c,p,g)=>{let v=p.route.id?i==null?void 0:i[p.route.id]:null,_=null;r&&(_=p.route.errorElement||PA);let O=t.concat(a.slice(0,g+1)),C=()=>{let T;return v?T=_:p.route.Component?T=A.createElement(p.route.Component,null):p.route.element?T=p.route.element:T=c,A.createElement(EA,{match:p,routeContext:{outlet:c,matches:O,isDataRoute:r!=null},children:T})};return r&&(p.route.ErrorBoundary||p.route.errorElement||g===0)?A.createElement(kA,{location:r.location,revalidation:r.revalidation,component:_,error:v,children:C(),routeContext:{outlet:null,matches:O,isDataRoute:!0}}):C()},null)}var WT=function(e){return e.UseBlocker="useBlocker",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e}(WT||{}),Fc=function(e){return e.UseBlocker="useBlocker",e.UseLoadeLine truncated
* React Router DOM v6.17.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function Vc(){return Vc=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(e[n]=r[n])}return e},Vc.apply(this,arguments)}function HT(e,t){if(e==null)return{};var r={},n=Object.keys(e),o,a;for(a=0;a<n.length;a++)o=n[a],!(t.indexOf(o)>=0)&&(r[o]=e[o]);return r}function FA(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function VA(e,t){return e.button===0&&(!t||t==="_self")&&!FA(e)}const zA=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","unstable_viewTransition"],BA=["aria-current","caseSensitive","className","end","style","to","unstable_viewTransition","children"],WA=A.createContext({isTransitioning:!1}),UA="startTransition",$y=kh[UA];function HA(e){let{basename:t,children:r,future:n,window:o}=e,a=A.useRef();a.current==null&&(a.current=Jj({window:o,v5Compat:!0}));let i=a.current,[c,p]=A.useState({action:i.action,location:i.location}),{v7_startTransition:g}=n||{},v=A.useCallback(_=>{g&&$y?$y(()=>p(_)):p(_)},[p,g]);return A.useLayoutEffect(()=>i.listen(v),[i,v]),A.createElement(LA,{basename:t,children:r,location:c.location,navigationType:c.action,navigator:i})}const $A=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",qA=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,GA=A.forwardRef(function(t,r){let{onClick:n,relative:o,reloadDocument:a,replace:i,state:c,target:p,to:g,preventScrollReset:v,unstable_viewTransition:_}=t,O=HT(t,zA),{basename:C}=A.useContext(ja),T,x=!1;if(typeof g=="string"&&qA.test(g)&&(T=g,$A))try{let u=new URL(window.location.href),s=g.startsWith("//")?new URL(u.protocol+g):new URL(g),h=ps(s.pathname,C);s.origin===u.origin&&h!=null?g=h+s.search+s.hash:x=!0}catch{}let b=_A(g,{relative:o}),d=XA(g,{replace:i,state:c,target:p,preventScrollReset:v,relative:o,unstable_viewTransition:_});function l(u){n&&n(u),u.defaultPrevented||d(u)}return A.createElement("a",Vc({},O,{href:T||b,onClick:x||a?n:l,ref:r,target:p}))}),KA=A.forwardRef(function(t,r){let{"aria-current":n="page",caseSensitive:o=!1,className:a="",end:i=!1,style:c,to:p,unstable_viewTransition:g,children:v}=t,_=HT(t,BA),O=Sd(p,{relative:_.relative}),C=Aa(),T=A.useContext(FT),{navigator:x}=A.useContext(ja),b=T!=null&&QA(O)&&g===!0,d=x.encodeLocation?x.encodeLocation(O).pathname:O.pathname,l=C.pathname,u=T&&T.navigation&&T.navigation.location?T.navigation.location.pathname:null;o||(l=l.toLowerCase(),u=u?u.toLowerCase():null,d=d.toLowerCase());let s=l===d||!i&&l.startsWith(d)&&l.charAt(d.length)==="/",h=u!=null&&(u===d||!i&&u.startsWith(d)&&u.charAt(d.length)==="/"),m={isActive:s,isPending:h,isTransitioning:b},w=s?n:void 0,E;typeof a=="function"?E=a(m):E=[a,s?"active":null,h?"pending":null,b?"transitioning":null].filter(Boolean).join(" ");let k=typeof c=="function"?c(m):c;return A.createElement(GA,Vc({},_,{"aria-current":w,className:E,ref:r,style:k,to:p,unstable_viewTransition:g}),typeof v=="function"?v(m):v)});var z0;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(z0||(z0={}));var qy;(function(e){e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(qy||(qy={}));function YA(e){let t=A.useContext(Cd);return t||Ft(!1),t}function XA(e,t){let{target:r,replace:n,state:o,preventScrollReset:a,relative:i,unstable_viewTransition:c}=t===void 0?{}:t,p=BT(),g=Aa(),v=Sd(e,{relative:i});return A.useCallback(_=>{if(VA(_,r)){_.preventDefault();let O=n!==void 0?n:Dc(g)===Dc(v);p(e,{replace:O,state:o,preventScrollReset:a,relative:i,unstable_viewTransition:c})}},[g,p,v,n,o,r,e,a,i,c])}function QA(e,t){t===void 0&&(t={});let r=A.useContext(WA);r==null&&Ft(!1);let{basename:n}=YA(z0.useViewTransitionState),o=Sd(e,{relative:t.relative});if(!r.isTransitioning)return!1;let a=ps(r.currentLocation.pathname,n)||r.currentLocation.pathname,i=ps(r.nextLocation.pathname,n)||r.nextLocation.pathname;return F0(o.pathname,i)!=null||F0(o.pathname,a)!=null}function JA({title:e,titleId:t,...r},n){return A.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor","aria-hidden":"true",ref:n,"aria-labelledby":t},r),e?A.createElement("title",{id:t},e):null,A.createElement("path",{fillRule:"evenodd",d:"M3 6.75A.75.75 0 013.75 6h16.5a.75.75 0 010 1.5H3.75A.75.75 0 013 6.75zM3 12a.75.75 0 01.75-.75h16.5a.75.75 0 010 1.5H3.75A.75.75 0 013 12zm0 5.25a.75.75 0 01.75-.75h16.5a.75.75 0 010 1.5H3.75a.75.75 0 01-.75-.75z",clipRule:"evenodd"}))}const ZA=A.forwardRef(JA),eM=ZA;function tM({title:e,titleId:t,...r},n){return A.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor","aria-hidden":"true",ref:n,"aria-labelledby":t},r),e?A.createElement("title",{id:t},e):null,A.createElement("path",{fillRule:"evenodd",d:"M11.078 2.25c-.917 0-1.69Line truncated
Copyright (c) 2018 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/(function(e){(function(){var t={}.hasOwnProperty;function r(){for(var n=[],o=0;o<arguments.length;o++){var a=arguments[o];if(a){var i=typeof a;if(i==="string"||i==="number")n.push(a);else if(Array.isArray(a)){if(a.length){var c=r.apply(null,a);c&&n.push(c)}}else if(i==="object"){if(a.toString!==Object.prototype.toString&&!a.toString.toString().includes("[native code]")){n.push(a.toString());continue}for(var p in a)t.call(a,p)&&a[p]&&n.push(p)}}}return n.join(" ")}e.exports?(r.default=r,e.exports=r):window.classNames=r})()})(GT);var He=GT.exports,KT={exports:{}},gn={};function Tg(){for(var e,t,r=0,n="";r<arguments.length;)(e=arguments[r++])&&(t=YT(e))&&(n&&(n+=" "),n+=t);return n}function YT(e){if(typeof e=="string")return e;for(var t,r="",n=0;n<e.length;n++)e[n]&&(t=YT(e[n]))&&(r&&(r+=" "),r+=t);return r}function B0(){return B0=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(e[n]=r[n])}return e},B0.apply(this,arguments)}function fM(e){var t=function(r){var n=r.theme,o=r.prefix,a={nextPart:new Map,validators:[]};return function(i,c){return c?i.map(function(p){return[p[0],p[1].map(function(g){return typeof g=="string"?c+g:typeof g=="object"?Object.fromEntries(Object.entries(g).map(function(v){return[c+v[0],v[1]]})):g})]}):i}(Object.entries(r.classGroups),o).forEach(function(i){W0(i[1],a,i[0],n)}),a}(e);return{getClassGroupId:function(r){var n=r.split("-");return n[0]===""&&n.length!==1&&n.shift(),XT(n,t)||function(o){if(Gy.test(o)){var a=Gy.exec(o)[1],i=a==null?void 0:a.substring(0,a.indexOf(":"));if(i)return"arbitrary.."+i}}(r)},getConflictingClassGroupIds:function(r){return e.conflictingClassGroups[r]||[]}}}function XT(e,t){var r;if(e.length===0)return t.classGroupId;var n=t.nextPart.get(e[0]),o=n?XT(e.slice(1),n):void 0;if(o)return o;if(t.validators.length!==0){var a=e.join("-");return(r=t.validators.find(function(i){return(0,i.validator)(a)}))==null?void 0:r.classGroupId}}Object.defineProperty(gn,"__esModule",{value:!0});var Gy=/^\[(.+)\]$/;function W0(e,t,r,n){e.forEach(function(o){if(typeof o!="string"){if(typeof o=="function")return o.isThemeGetter?void W0(o(n),t,r,n):void t.validators.push({validator:o,classGroupId:r});Object.entries(o).forEach(function(a){W0(a[1],Ky(t,a[0]),r,n)})}else(o===""?t:Ky(t,o)).classGroupId=r})}function Ky(e,t){var r=e;return t.split("-").forEach(function(n){r.nextPart.has(n)||r.nextPart.set(n,{nextPart:new Map,validators:[]}),r=r.nextPart.get(n)}),r}function pM(e){if(e<1)return{get:function(){},set:function(){}};var t=0,r=new Map,n=new Map;function o(a,i){r.set(a,i),++t>e&&(t=0,n=r,r=new Map)}return{get:function(a){var i=r.get(a);return i!==void 0?i:(i=n.get(a))!==void 0?(o(a,i),i):void 0},set:function(a,i){r.has(a)?r.set(a,i):o(a,i)}}}function hM(e){var t=e.separator||":";return function(r){for(var n=0,o=[],a=0,i=0;i<r.length;i++){var c=r[i];n===0&&c===t[0]&&(t.length!==1&&r.slice(i,i+t.length)!==t||(o.push(r.slice(a,i)),a=i+t.length)),c==="["?n++:c==="]"&&n--}var p=o.length===0?r:r.substring(a),g=p.startsWith("!");return{modifiers:o,hasImportantModifier:g,baseClassName:g?p.substring(1):p}}}function gM(e){return B0({cache:pM(e.cacheSize),splitModifiers:hM(e)},fM(e))}var bM=/\s+/;function vM(e,t){var r=t.splitModifiers,n=t.getClassGroupId,o=t.getConflictingClassGroupIds,a=new Set;return e.trim().split(bM).map(function(i){var c=r(i),p=c.modifiers,g=c.hasImportantModifier,v=n(c.baseClassName);if(!v)return{isTailwindClass:!1,originalClassName:i};var _=function(O){if(O.length<=1)return O;var C=[],T=[];return O.forEach(function(x){x[0]==="["?(C.push.apply(C,T.sort().concat([x])),T=[]):T.push(x)}),C.push.apply(C,T.sort()),C}(p).join(":");return{isTailwindClass:!0,modifierId:g?_+"!":_,classGroupId:v,originalClassName:i}}).reverse().filter(function(i){if(!i.isTailwindClass)return!0;var c=i.modifierId,p=i.classGroupId,g=c+p;return!a.has(g)&&(a.add(g),o(p).forEach(function(v){return a.add(c+v)}),!0)}).reverse().map(function(i){return i.originalClassName}).join(" ")}function U0(){for(var e=arguments.length,t=new Array(e),r=0;r<e;r++)t[r]=arguments[r];var n,o,a,i=c;function c(g){var v=t[0],_=t.slice(1).reduce(function(O,C){return C(O)},v());return n=gM(_),o=n.cache.get,a=n.cache.set,i=p,p(g)}function p(g){var v=o(g);if(v)return v;var _=vM(g,n);return a(g,_),_}return function(){return i(Tg.apply(null,arguments))}}function Ot(e){var t=function(r){return r[e]||[]};return t.isThemeGetter=!0,t}var Jo=/^\[(.+)\]$/,mM=/^\d+\/\d+$/,yM=new Set(["px","full","screen"]),wM=/^(\d+)?(xs|sm|md|lg|xl)$/,xM=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh)/,_M=/^-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/;function en(e){return!Number.isNaN(Number(e))||yM.has(e)||mM.test(e)||ko(e)}function ko(e){var t,r=(t=Jo.exec(e))==null?void 0:t[1];return!!r&&(r.startsWith("length:")||xM.test(r))}function QT(e){var t,r=(t=Jo.exec(e))==null?void 0:t[1];return!!r&&r.startsWLine truncated
* tabbable 6.2.0
* @license MIT, https://github.com/focus-trap/tabbable/blob/master/LICENSE
*/var f4=["input:not([inert])","select:not([inert])","textarea:not([inert])","a[href]:not([inert])","button:not([inert])","[tabindex]:not(slot):not([inert])","audio[controls]:not([inert])","video[controls]:not([inert])",'[contenteditable]:not([contenteditable="false"]):not([inert])',"details>summary:first-of-type:not([inert])","details:not([inert])"],oh=f4.join(","),oS=typeof Element>"u",ys=oS?function(){}:Element.prototype.matches||Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector,Yc=!oS&&Element.prototype.getRootNode?function(e){var t;return e==null||(t=e.getRootNode)===null||t===void 0?void 0:t.call(e)}:function(e){return e==null?void 0:e.ownerDocument},Xc=function e(t,r){var n;r===void 0&&(r=!0);var o=t==null||(n=t.getAttribute)===null||n===void 0?void 0:n.call(t,"inert"),a=o===""||o==="true",i=a||r&&t&&e(t.parentNode);return i},p4=function(t){var r,n=t==null||(r=t.getAttribute)===null||r===void 0?void 0:r.call(t,"contenteditable");return n===""||n==="true"},h4=function(t,r,n){if(Xc(t))return[];var o=Array.prototype.slice.apply(t.querySelectorAll(oh));return r&&ys.call(t,oh)&&o.unshift(t),o=o.filter(n),o},g4=function e(t,r,n){for(var o=[],a=Array.from(t);a.length;){var i=a.shift();if(!Xc(i,!1))if(i.tagName==="SLOT"){var c=i.assignedElements(),p=c.length?c:i.children,g=e(p,!0,n);n.flatten?o.push.apply(o,g):o.push({scopeParent:i,candidates:g})}else{var v=ys.call(i,oh);v&&n.filter(i)&&(r||!t.includes(i))&&o.push(i);var _=i.shadowRoot||typeof n.getShadowRoot=="function"&&n.getShadowRoot(i),O=!Xc(_,!1)&&(!n.shadowRootFilter||n.shadowRootFilter(i));if(_&&O){var C=e(_===!0?i.children:_.children,!0,n);n.flatten?o.push.apply(o,C):o.push({scopeParent:i,candidates:C})}else a.unshift.apply(a,i.children)}}return o},aS=function(t){return!isNaN(parseInt(t.getAttribute("tabindex"),10))},iS=function(t){if(!t)throw new Error("No node provided");return t.tabIndex<0&&(/^(AUDIO|VIDEO|DETAILS)$/.test(t.tagName)||p4(t))&&!aS(t)?0:t.tabIndex},b4=function(t,r){var n=iS(t);return n<0&&r&&!aS(t)?0:n},v4=function(t,r){return t.tabIndex===r.tabIndex?t.documentOrder-r.documentOrder:t.tabIndex-r.tabIndex},lS=function(t){return t.tagName==="INPUT"},m4=function(t){return lS(t)&&t.type==="hidden"},y4=function(t){var r=t.tagName==="DETAILS"&&Array.prototype.slice.apply(t.children).some(function(n){return n.tagName==="SUMMARY"});return r},w4=function(t,r){for(var n=0;n<t.length;n++)if(t[n].checked&&t[n].form===r)return t[n]},x4=function(t){if(!t.name)return!0;var r=t.form||Yc(t),n=function(c){return r.querySelectorAll('input[type="radio"][name="'+c+'"]')},o;if(typeof window<"u"&&typeof window.CSS<"u"&&typeof window.CSS.escape=="function")o=n(window.CSS.escape(t.name));else try{o=n(t.name)}catch(i){return console.error("Looks like you have a radio button with a name attribute containing invalid CSS selector characters and need the CSS.escape polyfill: %s",i.message),!1}var a=w4(o,t.form);return!a||a===t},_4=function(t){return lS(t)&&t.type==="radio"},T4=function(t){return _4(t)&&!x4(t)},C4=function(t){var r,n=t&&Yc(t),o=(r=n)===null||r===void 0?void 0:r.host,a=!1;if(n&&n!==t){var i,c,p;for(a=!!((i=o)!==null&&i!==void 0&&(c=i.ownerDocument)!==null&&c!==void 0&&c.contains(o)||t!=null&&(p=t.ownerDocument)!==null&&p!==void 0&&p.contains(t));!a&&o;){var g,v,_;n=Yc(o),o=(g=n)===null||g===void 0?void 0:g.host,a=!!((v=o)!==null&&v!==void 0&&(_=v.ownerDocument)!==null&&_!==void 0&&_.contains(o))}}return a},_w=function(t){var r=t.getBoundingClientRect(),n=r.width,o=r.height;return n===0&&o===0},O4=function(t,r){var n=r.displayCheck,o=r.getShadowRoot;if(getComputedStyle(t).visibility==="hidden")return!0;var a=ys.call(t,"details>summary:first-of-type"),i=a?t.parentElement:t;if(ys.call(i,"details:not([open]) *"))return!0;if(!n||n==="full"||n==="legacy-full"){if(typeof o=="function"){for(var c=t;t;){var p=t.parentElement,g=Yc(t);if(p&&!p.shadowRoot&&o(p)===!0)return _w(t);t.assignedSlot?t=t.assignedSlot:!p&&g!==t.ownerDocument?t=g.host:t=p}t=c}if(C4(t))return!t.getClientRects().length;if(n!=="legacy-full")return!0}else if(n==="non-zero-area")return _w(t);return!1},S4=function(t){if(/^(INPUT|BUTTON|SELECT|TEXTAREA)$/.test(t.tagName))for(var r=t.parentElement;r;){if(r.tagName==="FIELDSET"&&r.disabled){for(var n=0;n<r.children.length;n++){var o=r.children.item(n);if(o.tagName==="LEGEND")return ys.call(r,"fieldset[disabled] *")?!0:!o.contains(t)}return!0}r=r.parentElement}return!1},P4=function(t,r){return!(r.disabled||Xc(r)||m4(r)||O4(r,t)||y4(r)||S4(r))},Tw=function(t,r){return!(T4(r)||iS(r)<0||!P4(t,r))},k4=function(t){var r=parseInt(t.getAttribute("tabindex"),10);return!!(isNaN(r)||r>=0)},E4=function e(t){var r=[],n=[];return t.forEach(function(o,a){var i=!!o.scopeParent,c=i?o.scopeParent:o,p=b4(c,i),g=i?e(o.candidates):c;p===0?i?r.push.apply(r,g):r.push(c):n.push({documentOrder:a,tabIndex:p,item:o,isScope:i,content:g})}),n.sort(v4).reduce(function(o,a){return a.isScope?o.push.apply(o,a.content):o.push(a.content),o},[]).concatLine truncated
More details: https://www.material-tailwind.com/docs/react/collapse
`);var k=r.default.useRef(null),P=(0,g.useTheme)().navbar,N=P.styles,j=N.base.mobileNav;s=s??{},h=h??"";var I=(0,c.twMerge)((0,i.default)((0,p.default)(j)),h),F={unmount:{height:0,opacity:0,transition:{duration:.3,times:"[0.4, 0, 0.2, 1]"}},mount:{opacity:1,height:"".concat((E=k.current)===null||E===void 0?void 0:E.scrollHeight,"px"),transition:{duration:.3,times:"[0.4, 0, 0.2, 1]"}}},W=(0,a.default)(F,s),G=n.AnimatePresence,X=(0,o.useMergeRefs)([l,k]);return r.default.createElement(n.LazyMotion,{features:n.domAnimation},r.default.createElement(G,null,r.default.createElement(n.m.div,_({},w,{ref:X,className:I,initial:"unmount",exit:"unmount",animate:u?"mount":"unmount",variants:W}),m)))});x.displayName="MaterialTailwind.MobileNav",x.propTypes={open:v.propTypesOpen,animate:v.propTypesAnimate,className:v.propTypesClassName,children:v.propTypesChildren};var b=x})(XS);(function(e){Object.defineProperty(e,"__esModule",{value:!0});function t(l,u){for(var s in u)Object.defineProperty(l,s,{enumerable:!0,get:u[s]})}t(e,{Navbar:function(){return b},MobileNav:function(){return g.MobileNav},default:function(){return d}});var r=C(A),n=C(Fe),o=C(He),a=Le,i=C(Nt),c=C(Ie),p=Ne,g=XS,v=Pd;function _(l,u,s){return u in l?Object.defineProperty(l,u,{value:s,enumerable:!0,configurable:!0,writable:!0}):l[u]=s,l}function O(){return O=Object.assign||function(l){for(var u=1;u<arguments.length;u++){var s=arguments[u];for(var h in s)Object.prototype.hasOwnProperty.call(s,h)&&(l[h]=s[h])}return l},O.apply(this,arguments)}function C(l){return l&&l.__esModule?l:{default:l}}function T(l,u){if(l==null)return{};var s=x(l,u),h,m;if(Object.getOwnPropertySymbols){var w=Object.getOwnPropertySymbols(l);for(m=0;m<w.length;m++)h=w[m],!(u.indexOf(h)>=0)&&Object.prototype.propertyIsEnumerable.call(l,h)&&(s[h]=l[h])}return s}function x(l,u){if(l==null)return{};var s={},h=Object.keys(l),m,w;for(w=0;w<h.length;w++)m=h[w],!(u.indexOf(m)>=0)&&(s[m]=l[m]);return s}var b=r.default.forwardRef(function(l,u){var s=l.variant,h=l.color,m=l.shadow,w=l.blurred,E=l.fullWidth,k=l.className,P=l.children,N=T(l,["variant","color","shadow","blurred","fullWidth","className","children"]),j=(0,p.useTheme)().navbar,I=j.defaultProps,F=j.valid,W=j.styles,G=W.base,X=W.variants;s=s??I.variant,h=h??I.color,m=m??I.shadow,w=w??I.blurred,E=E??I.fullWidth,k=k??I.className;var H,B=(0,o.default)((0,c.default)(G.navbar.initial),(H={},_(H,(0,c.default)(G.navbar.shadow),m),_(H,(0,c.default)(G.navbar.blurred),w&&h==="white"),_(H,(0,c.default)(G.navbar.fullWidth),E),H)),U=(0,o.default)((0,c.default)(X[(0,i.default)(F.variants,s,"filled")][(0,i.default)(F.colors,h,"white")])),K=(0,a.twMerge)((0,o.default)(B,U),k);return r.default.createElement("nav",O({},N,{ref:u,className:K}),P)});b.propTypes={variant:n.default.oneOf(v.propTypesVariant),color:n.default.oneOf(v.propTypesColor),shadow:v.propTypesShadow,blurred:v.propTypesBlurred,fullWidth:v.propTypesFullWidth,className:v.propTypesClassName,children:v.propTypesChildren},b.displayName="MaterialTailwind.Navbar";var d=Object.assign(b,{MobileNav:g.MobileNav})})(YS);var QS={},Kd={},Qi={};(function(e){Object.defineProperty(e,"__esModule",{value:!0});function t(b,d){for(var l in d)Object.defineProperty(b,l,{enumerable:!0,get:d[l]})}t(e,{propTypesOpen:function(){return a},propTypesHandler:function(){return i},propTypesPlacement:function(){return c},propTypesOffset:function(){return p},propTypesDismiss:function(){return g},propTypesAnimate:function(){return v},propTypesContent:function(){return _},propTypesInteractive:function(){return O},propTypesClassName:function(){return C},propTypesChildren:function(){return T},propTypesContextValue:function(){return x}});var r=o(Fe),n=Pt;function o(b){return b&&b.__esModule?b:{default:b}}var a=r.default.bool,i=r.default.func,c=n.propTypesPlacements,p=n.propTypesOffsetType,g=n.propTypesDismissType,v=n.propTypesAnimation,_=r.default.node,O=r.default.bool,C=r.default.string,T=r.default.node.isRequired,x=r.default.shape({open:r.default.bool.isRequired,strategy:r.default.oneOf(["fixed","absolute"]).isRequired,x:r.default.number,y:r.default.number,context:r.default.instanceOf(Object).isRequired,reference:r.default.func.isRequired,floating:r.default.func.isRequired,getReferenceProps:r.default.func.isRequired,getFloatingProps:r.default.func.isRequired,appliedAnimation:v.isRequired,labelId:r.default.string.isRequired,descriptionId:r.default.string.isRequired}).isRequired})(Qi);(function(e){Object.defineProperty(e,"__esModule",{value:!0});function t(p,g){for(var v in g)Object.defineProperty(p,v,{enumerable:!0,get:g[v]})}t(e,{PopoverContext:function(){return a},usePopover:function(){return i},PopoverContextProvider:function(){return c}});var r=o(A),n=Qi;function o(p){return p&&p.__esModule?p:{default:p}}var a=r.default.createContext(null);a.displayName="MaterialTailwind.PopoverContext";function i(){var p=r.default.useContext(a);if(!p)throw new Error("usePopover() must be used within a Popover. It happens when you us Line truncated
`).forEach(function(i){o=i.indexOf(":"),r=i.substring(0,o).trim().toLowerCase(),n=i.substring(o+1).trim(),!(!r||t[r]&&jV[r])&&(r==="set-cookie"?t[r]?t[r].push(n):t[r]=[n]:t[r]=t[r]?t[r]+", "+n:n)}),t},rx=Symbol("internals");function Cl(e){return e&&String(e).trim().toLowerCase()}function ac(e){return e===!1||e==null?e:de.isArray(e)?e.map(ac):String(e)}function MV(e){const t=Object.create(null),r=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let n;for(;n=r.exec(e);)t[n[1]]=n[2];return t}const IV=e=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim());function Mp(e,t,r,n,o){if(de.isFunction(n))return n.call(this,t,r);if(o&&(t=r),!!de.isString(t)){if(de.isString(n))return t.indexOf(n)!==-1;if(de.isRegExp(n))return n.test(t)}}function DV(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(t,r,n)=>r.toUpperCase()+n)}function LV(e,t){const r=de.toCamelCase(" "+t);["get","set","has"].forEach(n=>{Object.defineProperty(e,n+r,{value:function(o,a,i){return this[n].call(this,t,o,a,i)},configurable:!0})})}class tf{constructor(t){t&&this.set(t)}set(t,r,n){const o=this;function a(c,p,g){const v=Cl(p);if(!v)throw new Error("header name must be a non-empty string");const _=de.findKey(o,v);(!_||o[_]===void 0||g===!0||g===void 0&&o[_]!==!1)&&(o[_||p]=ac(c))}const i=(c,p)=>de.forEach(c,(g,v)=>a(g,v,p));if(de.isPlainObject(t)||t instanceof this.constructor)i(t,r);else if(de.isString(t)&&(t=t.trim())&&!IV(t))i(AV(t),r);else if(de.isHeaders(t))for(const[c,p]of t.entries())a(p,c,n);else t!=null&&a(r,t,n);return this}get(t,r){if(t=Cl(t),t){const n=de.findKey(this,t);if(n){const o=this[n];if(!r)return o;if(r===!0)return MV(o);if(de.isFunction(r))return r.call(this,o,n);if(de.isRegExp(r))return r.exec(o);throw new TypeError("parser must be boolean|regexp|function")}}}has(t,r){if(t=Cl(t),t){const n=de.findKey(this,t);return!!(n&&this[n]!==void 0&&(!r||Mp(this,this[n],n,r)))}return!1}delete(t,r){const n=this;let o=!1;function a(i){if(i=Cl(i),i){const c=de.findKey(n,i);c&&(!r||Mp(n,n[c],c,r))&&(delete n[c],o=!0)}}return de.isArray(t)?t.forEach(a):a(t),o}clear(t){const r=Object.keys(this);let n=r.length,o=!1;for(;n--;){const a=r[n];(!t||Mp(this,this[a],a,t,!0))&&(delete this[a],o=!0)}return o}normalize(t){const r=this,n={};return de.forEach(this,(o,a)=>{const i=de.findKey(n,a);if(i){r[i]=ac(o),delete r[a];return}const c=t?DV(a):String(a).trim();c!==a&&delete r[a],r[c]=ac(o),n[c]=!0}),this}concat(...t){return this.constructor.concat(this,...t)}toJSON(t){const r=Object.create(null);return de.forEach(this,(n,o)=>{n!=null&&n!==!1&&(r[o]=t&&de.isArray(n)?n.join(", "):n)}),r}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([t,r])=>t+": "+r).join(`
`)}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(t){return t instanceof this?t:new this(t)}static concat(t,...r){const n=new this(t);return r.forEach(o=>n.set(o)),n}static accessor(t){const n=(this[rx]=this[rx]={accessors:{}}).accessors,o=this.prototype;function a(i){const c=Cl(i);n[c]||(LV(o,i),n[c]=!0)}return de.isArray(t)?t.forEach(a):a(t),this}}tf.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);de.reduceDescriptors(tf.prototype,({value:e},t)=>{let r=t[0].toUpperCase()+t.slice(1);return{get:()=>e,set(n){this[r]=n}}});de.freezeMethods(tf);const On=tf;function Ip(e,t){const r=this||jb,n=t||r,o=On.from(n.headers);let a=n.data;return de.forEach(e,function(c){a=c.call(r,a,o.normalize(),t?t.status:void 0)}),o.normalize(),a}function ek(e){return!!(e&&e.__CANCEL__)}function el(e,t,r){Qe.call(this,e??"canceled",Qe.ERR_CANCELED,t,r),this.name="CanceledError"}de.inherits(el,Qe,{__CANCEL__:!0});function tk(e,t,r){const n=r.config.validateStatus;!r.status||!n||n(r.status)?e(r):t(new Qe("Request failed with status code "+r.status,[Qe.ERR_BAD_REQUEST,Qe.ERR_BAD_RESPONSE][Math.floor(r.status/100)-4],r.config,r.request,r))}function FV(e){const t=/^([-+\w]{1,25})(:?\/\/|:)/.exec(e);return t&&t[1]||""}function VV(e,t){e=e||10;const r=new Array(e),n=new Array(e);let o=0,a=0,i;return t=t!==void 0?t:1e3,function(p){const g=Date.now(),v=n[a];i||(i=g),r[o]=p,n[o]=g;let _=a,O=0;for(;_!==o;)O+=r[_++],_=_%e;if(o=(o+1)%e,o===a&&(a=(a+1)%e),g-i<t)return;const C=v&&g-v;return C?Math.round(O*1e3/C):void 0}}function zV(e,t){let r=0,n=1e3/t,o,a;const i=(g,v=Date.now())=>{r=v,o=null,a&&(clearTimeout(a),a=null),e.apply(null,g)};return[(...g)=>{const v=Date.now(),_=v-r;_>=n?i(g,v):(o=g,a||(a=setTimeout(()=>{a=null,i(o)},n-_)))},()=>o&&i(o)]}const ad=(e,t,r=3)=>{let n=0;const o=VV(50,250);return zV(a=>{const i=a.loaded,c=a.lengthComputable?a.total:void 0,p=i-n,g=o(p),v=i<=c;n=i;const _={loaded:i,total:c,progress:c?i/c:void 0,bytes:p,rate:g||void 0,estimated:g&&c&&v?(c-i)/g:void 0,event:a,lengthComputable:c!=null,[t?"download":"upload"]:!0};e(_)},r)},nx=(e,t)=>{const r=e!=null;return[n=>t[0]({lengthComputable:r,total:e,loaded:n}),t[1]]},ox=e=>(...t)=>de.asap(()=>e(...t)),BV=Ur.hasStandardBrowserEnv?function(){const t=Ur.navigator&&/(msie|trident)/i.test(Ur.navigator.userAgent),r=document.createElement("a");let n;function o(a){let i=a;return t&&(r.setAttribute("href",i),i=r.href),r.setAttribute("href",i),{href:r.href,protocol:r.protocol?r.protocol.replace(/:$/,""):"",host:r.host,search:r.search?r.search.replace(/^\?/,""):"",hash:r.hash?r.hash.replace(/^#/,""):"",hostname:r.hostname,port:r.port,pathname:r.pathname.charAt(0)==="/"?r.pathname:"/"+r.pathname}}return n=o(window.location.href),function(i){const c=de.isString(i)?o(i):i;return c.protocol===n.protocol&&c.host===n.host}}():function(){return function(){return!0}}(),WV=Ur.hasStandardBrowserEnv?{write(e,t,r,n,o,a){const i=[e+"="+encodeURIComponent(t)];de.isNumber(r)&&i.push("expires="+new Date(r).toGMTString()),de.isString(n)&&i.push("path="+n),de.isString(o)&&i.push("domain="+o),a===!0&&i.push("secure"),document.cookie=i.join("; ")},read(e){const t=document.cookie.match(new RegExp("(^|;\\s*)("+e+")=([^;]*)"));return t?decodeURIComponent(t[3]):null},remove(e){this.write(e,"",Date.now()-864e5)}}:{write(){},read(){return null},remove(){}};function UV(e){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(e)}function HV(e,t){return t?e.replace(/\/?\/$/,"")+"/"+t.replace(/^\/+/,""):e}function rk(e,t){return e&&!UV(t)?HV(e,t):t}const ax=e=>e instanceof On?{...e}:e;function Ea(e,t){t=t||{};const r={};function n(g,v,_){return de.isPlainObject(g)&&de.isPlainObject(v)?de.merge.call({caseless:_},g,v):de.isPlainObject(v)?de.merge({},v):de.isArray(v)?v.slice():v}function o(g,v,_){if(de.isUndefined(v)){if(!de.isUndefined(g))return n(void 0,g,_)}else return n(g,v,_)}function a(g,v){if(!de.isUndefined(v))return n(void 0,v)}function i(g,v){if(de.isUndefined(v)){if(!de.isUndefined(g))return n(void 0,g)}else return n(void 0,v)}function c(g,v,_){if(_ in t)return n(g,v);if(_ in e)return n(void 0,g)}const p={url:a,method:a,data:a,baseURL:i,transformRequest:i,transformResponse:i,paramsSerializer:i,timeout:i,timeoutMessage:i,withCredentials:i,withXSRFToken:i,adapter:i,responseType:i,xsrfCookieName:i,xsrfHeaderName:i,onUploadProgress:i,onDownloadProgress:i,decompress:i,maxContentLength:i,maxBodyLength:i,beforeRedirect:i,transport:i,httpAgent:i,httpsAgent:i,cancelToken:i,socketPath:i,responseEncoding:i,validateStatus:c,headers:(g,v)=>o(ax(g),ax(v),!0)};return de.forEach(Object.keys(Object.assign({},e,t)),function(v){const _=p[v]||o,O=_(e[v],t[v],v);de.isUndefined(O)&&_!==c||(r[v]=O)}),r}const nk=e=>{const t=Ea({},e);let{data:r,withXSRFToken:n,xsrfHeaderName:o,xsrfCookieName:a,headers:i,auth:c}=t;t.headers=i=On.from(i),t.url=QP(rk(t.baseURL,t.url),e.params,e.paramsSerializer),c&&i.set("Authorization","Basic "+btoa((c.username||"")+":"+(c.password?unescape(enco Line truncated
`+a.map(sx).join(`
`):" "+sx(a[0]):"as no adapter specified";throw new Qe("There is no suitable adapter to dispatch the request "+i,"ERR_NOT_SUPPORT")}return n},adapters:hh};function Dp(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new el(null,e)}function ux(e){return Dp(e),e.headers=On.from(e.headers),e.data=Ip.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),ik.getAdapter(e.adapter||jb.adapter)(e).then(function(n){return Dp(e),n.data=Ip.call(e,e.transformResponse,n),n.headers=On.from(n.headers),n},function(n){return ek(n)||(Dp(e),n&&n.response&&(n.response.data=Ip.call(e,e.transformResponse,n.response),n.response.headers=On.from(n.response.headers))),Promise.reject(n)})}const lk="1.7.5",Ab={};["object","boolean","number","function","string","symbol"].forEach((e,t)=>{Ab[e]=function(n){return typeof n===e||"a"+(t<1?"n ":" ")+e}});const cx={};Ab.transitional=function(t,r,n){function o(a,i){return"[Axios v"+lk+"] Transitional option '"+a+"'"+i+(n?". "+n:"")}return(a,i,c)=>{if(t===!1)throw new Qe(o(i," has been removed"+(r?" in "+r:"")),Qe.ERR_DEPRECATED);return r&&!cx[i]&&(cx[i]=!0,console.warn(o(i," has been deprecated since v"+r+" and will be removed in the near future"))),t?t(a,i,c):!0}};function rz(e,t,r){if(typeof e!="object")throw new Qe("options must be an object",Qe.ERR_BAD_OPTION_VALUE);const n=Object.keys(e);let o=n.length;for(;o-- >0;){const a=n[o],i=t[a];if(i){const c=e[a],p=c===void 0||i(c,a,e);if(p!==!0)throw new Qe("option "+a+" must be "+p,Qe.ERR_BAD_OPTION_VALUE);continue}if(r!==!0)throw new Qe("Unknown option "+a,Qe.ERR_BAD_OPTION)}}const gh={assertOptions:rz,validators:Ab},To=gh.validators;class ld{constructor(t){this.defaults=t,this.interceptors={request:new tx,response:new tx}}async request(t,r){try{return await this._request(t,r)}catch(n){if(n instanceof Error){let o;Error.captureStackTrace?Error.captureStackTrace(o={}):o=new Error;const a=o.stack?o.stack.replace(/^.+\n/,""):"";try{n.stack?a&&!String(n.stack).endsWith(a.replace(/^.+\n.+\n/,""))&&(n.stack+=`
`+a):n.stack=a}catch{}}throw n}}_request(t,r){typeof t=="string"?(r=r||{},r.url=t):r=t||{},r=Ea(this.defaults,r);const{transitional:n,paramsSerializer:o,headers:a}=r;n!==void 0&&gh.assertOptions(n,{silentJSONParsing:To.transitional(To.boolean),forcedJSONParsing:To.transitional(To.boolean),clarifyTimeoutError:To.transitional(To.boolean)},!1),o!=null&&(de.isFunction(o)?r.paramsSerializer={serialize:o}:gh.assertOptions(o,{encode:To.function,serialize:To.function},!0)),r.method=(r.method||this.defaults.method||"get").toLowerCase();let i=a&&de.merge(a.common,a[r.method]);a&&de.forEach(["delete","get","head","post","put","patch","common"],T=>{delete a[T]}),r.headers=On.concat(i,a);const c=[];let p=!0;this.interceptors.request.forEach(function(x){typeof x.runWhen=="function"&&x.runWhen(r)===!1||(p=p&&x.synchronous,c.unshift(x.fulfilled,x.rejected))});const g=[];this.interceptors.response.forEach(function(x){g.push(x.fulfilled,x.rejected)});let v,_=0,O;if(!p){const T=[ux.bind(this),void 0];for(T.unshift.apply(T,c),T.push.apply(T,g),O=T.length,v=Promise.resolve(r);_<O;)v=v.then(T[_++],T[_++]);return v}O=c.length;let C=r;for(_=0;_<O;){const T=c[_++],x=c[_++];try{C=T(C)}catch(b){x.call(this,b);break}}try{v=ux.call(this,C)}catch(T){return Promise.reject(T)}for(_=0,O=g.length;_<O;)v=v.then(g[_++],g[_++]);return v}getUri(t){t=Ea(this.defaults,t);const r=rk(t.baseURL,t.url);return QP(r,t.params,t.paramsSerializer)}}de.forEach(["delete","get","head","options"],function(t){ld.prototype[t]=function(r,n){return this.request(Ea(n||{},{method:t,url:r,data:(n||{}).data}))}});de.forEach(["post","put","patch"],function(t){function r(n){return function(a,i,c){return this.request(Ea(c||{},{method:t,headers:n?{"Content-Type":"multipart/form-data"}:{},url:a,data:i}))}}ld.prototype[t]=r(),ld.prototype[t+"Form"]=r(!0)});const ic=ld;class Mb{constructor(t){if(typeof t!="function")throw new TypeError("executor must be a function.");let r;this.promise=new Promise(function(a){r=a});const n=this;this.promise.then(o=>{if(!n._listeners)return;let a=n._listeners.length;for(;a-- >0;)n._listeners[a](o);n._listeners=null}),this.promise.then=o=>{let a;const i=new Promise(c=>{n.subscribe(c),a=c}).then(o);return i.cancel=function(){n.unsubscribe(a)},i},t(function(a,i,c){n.reason||(n.reason=new el(a,i,c),r(n.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(t){if(this.reason){t(this.reason);return}this._listeners?this._listeners.push(t):this._listeners=[t]}unsubscribe(t){if(!this._listeners)return;const r=this._listeners.indexOf(t);r!==-1&&this._listeners.splice(r,1)}static source(){let t;return{token:new Mb(function(o){t=o}),cancel:t}}}const nz=Mb;function oz(e){return function(r){return e.apply(null,r)}}function az(e){return de.isObject(e)&&e.isAxiosError===!0}const bh={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries(bh).forEach(([e,t])=>{bh[t]=e});const iz=bh;function sk(e){const t=new ic(e),r=VP(ic.prototype.request,t);return de.extend(r,ic.prototype,t,{allOwnKeys:!0}),de.extend(r,t,null,{allOwnKeys:!0}),r.create=function(o){return sk(Ea(e,o))},r}const Yt=sk(jb);Yt.Axios=ic;Yt.CanceledError=el;Yt.CancelToken=nz;Yt.isCancel=ek;Yt.VERSION=lk;Yt.toFormData=ef;Yt.AxiosError=Qe;Yt.Cancel=Yt.CanceledError;Yt.all=function(t){return Promise.all(t)};Yt.spread=oz;Yt.isAxiosError=az;Yt.mergeConfig=Ea;Yt.AxiosHeaders=On;Yt.formToJSON=e=>ZP(de.isHTMLForm(e)?new FormData(e):e);Yt.getAdapter=ik.getAdapter;Yt.HttpStatusCode=iz;Yt.default=Yt;const Mt=Yt,Un=Object.create(null);Un.open="0";Un.close="1";Un.ping="2";Un.pong="3";Un.message="4";Un.upgrade="5";Un.noop="6";const lc=Object.create(null);Object.keys(Un).forEach(e=>{lc[Un[e]]=e});const vh={type:"error",data:"parser error"},uk=typeof Blob=="function"||typeof Blob<"u"&&Object.prototype.toString.call(Blob)==="[object BlobConstructor]",ck=typeof Array Line truncated
${Te?"border-purple-500 ring-1 ring-purple-500/40":"border-transparent"}
${!ht||ve?"opacity-75 bg-stormGrey-200/20":""}`,children:[D.jsxs("div",{className:"flex items-center space-x-4",id:"left",children:[D.jsx("input",{type:"checkbox",checked:Te,onChange:()=>Oe(ae.Id,!ht),className:`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${ht?"":"opacity-40 cursor-not-allowed"}`,disabled:!ht}),D.jsx("div",{className:`w-12 h-12 flex items-center justify-center rounded-full
${ae.updateStatus==="✅"?"bg-mossGreen-600":ae.updateStatus==="⚠️"?"bg-warmAmberGlow-400":"bg-sunsetCoral-500"}`}),D.jsxs("div",{children:[D.jsx("p",{className:"text-lg antialiased font-sans font-semibold leading-normal text-inherit",children:ae.Name}),D.jsxs("p",{className:"text-sm antialiased font-sans font-light leading-normal text-inherit",children:["ID: ",ae.Id]}),ae.duplicateName&&D.jsx("p",{className:"text-xs text-amber-700",children:"⚠️ Doppelter Name erkannt"})]})]}),D.jsx("div",{className:"ml-auto flex flex-col items-end gap-1 text-sm",id:"right",children:Me?D.jsxs(D.Fragment,{children:[D.jsx("span",{className:"antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-warmAmberGlow-600",children:"Redeploy"}),D.jsx("span",{className:"text-warmAmberGlow-500",children:"läuft…"})]}):Ae?D.jsxs(D.Fragment,{children:[D.jsx("span",{className:"antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-warmAmberGlow-600",children:"Redeploy"}),D.jsx("span",{className:"text-warmAmberGlow-500",children:"in Warteliste…"})]}):lt?D.jsxs(D.Fragment,{children:[D.jsx("span",{className:"antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-stormGrey-500",children:"System"}),D.jsx("span",{className:"text-gray-300",children:"Redeploy deaktiviert"})]}):$e?D.jsxs(D.Fragment,{children:[D.jsx("span",{className:"antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-stormGrey-500",children:"Status"}),D.jsx("span",{className:"text-mossGreen-600",children:"Aktuell"})]}):D.jsx(xe.Button,{onClick:()=>Ge(ae.Id),disabled:ve,className:"disabled:opacity-50 disabled:cursor-not-allowed",children:"Redeploy"})})]})},ae.Id)})}),D.jsx(Ok,{disabled:r||!!o})]})}const p6={idle:"Bereit",running:"Läuft",success:"Erfolgreich",error:"Fehlgeschlagen"},Sl={idle:"bg-stormGrey-700 text-gray-200",running:"bg-arcticBlue-600 text-white",success:"bg-mossGreen-600 text-white",error:"bg-sunsetCoral-600 text-white"},h6={initializing:"Vorbereitung","activating-maintenance":"Wartungsmodus aktivieren","executing-script":"Skript wird ausgeführt",waiting:"Warte auf Portainer",completed:"Abgeschlossen",failed:"Fehlgeschlagen"},g6={info:"text-arcticBlue-700",success:"text-mossGreen-700",warning:"text-warmAmberGlow-700",error:"text-sunsetCoral-700",stdout:"text-stormGrey-700",stderr:"text-warmAmberGlow-700",debug:"text-lavenderSmoke-700"},b6=e=>{if(!e)return"-";const t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString("de-DE",{hour:"2-digit",minute:"2-digit",second:"2-digit"})},ua=e=>{if(!e&&e!==0)return"-";const r=(n=>{if(n instanceof Date)return n;if(typeof n=="number"){const o=n>1e12?n:n*1e3;return new Date(o)}if(typeof n=="string"){const o=Number(n);if(!Number.isNaN(o)){const a=o>1e12?o:o*1e3;return new Date(a)}return new Date(n)}return null})(e);return!r||Number.isNaN(r.getTime())?String(e):r.toLocaleString("de-DE",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})},mx=e=>e===1?"Git":e===2?"Compose":e??"-",Bp=()=>({host:"",port:"22",username:"",password:"",extraSshArgs:""});function v6(){var Wa,fo,po;const{showToast:e}=zb(),{maintenance:t,update:r,script:n,ssh:o,loading:a,error:i,setMaintenanceMode:c,triggerUpdate:p,saveScript:g,resetScript:v,saveSshConfig:_,deleteSshConfig:O,testSshConnection:C}=Bb(),T=!!(t!=null&&t.active),x=t==null?void 0:t.message,b=(Wa=t==null?void 0:t.extra)==null?void 0:Wa.type,d=!!(r!=null&&r.running),[l,u]=A.useState(""),[s,h]=A.useState(!1),[m,w]=A.useState(!1),[E,k]=A.useState(""),[P,N]=A.useState(!1),[j,I]=A.useState(()=>Bp()),[F,W]=A.useState(!1),[G,X]=A.useState(!1),[H,B]=A.useState(!1),[U,K]=A.useState(!1),[Q,ne]=A.useState(!1),[ue,le]=A.useState(null);A.useEffect(()=>{if(!n)return;const ye=n.custom??n.default??n.effective??"";u(ye)},[n]),A.useEffect(()=>{if(!o){I(Bp()),W(!1),X(!1),le(null);return}I({host:o.host??"",port:String(o.port??"22"),username:o.username??"",password:"",extraSshArgs:Array.isArray(o.extraSshArgs)?o.extraSshArgs.join(`
`):""}),W(!!o.passwordStored),X(!1),le(null)},[o]);const re=A.useMemo(()=>n?n.source==="custom"&&typeof n.custom=="string"?n.custom:n.default??n.effective??"":"",[n]),be=n?l!==re:!1,fe=(n==null?void 0:n.source)==="custom"?"Benutzerdefiniert":"Standard",[ie,pe]=A.useState([]),[ce,Oe]=A.useState(!0),[Se,Ce]=A.useState(""),[_e,je]=A.useState(!1),[Ge,nt]=A.useState(null),[ut,ot]=A.useState(null),pt=A.useRef(null),[We,xt]=A.useState(null),[Ke,ae]=A.useState(!0),[ge,Ae]=A.useState(""),[Me,ve]=A.useState(!1),[Te,$e]=A.useState(null),lt=A.useRef(null),ht=A.useCallback(async({silent:ye=!1}={})=>{if(lt.current)return lt.current;const Ve=(async()=>{var ze,tt;ye?ve(!0):ae(!0),Ae("");try{const $t=(await Mt.get("/api/maintenance/portainer-status")).data??{};xt($t),$e(new Date)}catch(bt){const $t=((tt=(ze=bt.response)==null?void 0:ze.data)==null?void 0:tt.error)||bt.message||"Fehler beim Prüfen des Portainer-Status";Ae($t),e({variant:"error",title:"Statusaktualisierung fehlgeschlagen",description:$t})}finally{ye?ve(!1):ae(!1),lt.current=null}})();return lt.current=Ve,Ve},[e]),zt=A.useCallback(async({silent:ye=!1}={})=>{if(T||d){pe([]),Ce("Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert."),Oe(!1),je(!1);return}if(pt.current)return pt.current;const Ve=(async()=>{var ze,tt,bt;ye?je(!0):Oe(!0),Ce("");try{const jt=(await Mt.get("/api/maintenance/duplicates")).data,Dr=Array.isArray(jt)?jt:(jt==null?void 0:jt.items)??[];pe(Dr),ot(new Date)}catch($t){if(((ze=$t.response)==null?void 0:ze.status)===423){const jt="Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert.";pe([]),Ce(jt),e({variant:"warning",title:"Duplikat-Verwaltung gesperrt",description:jt})}else{const jt=((bt=(tt=$t.response)==null?void 0:tt.data)==null?void 0:bt.error)||$t.message||"Fehler beim Laden der Wartungsdaten";Ce(jt),e({variant:"error",title:"Duplikate konnten nicht geladen werden",description:jt})}}finally{ye?je(!1):Oe(!1),pt.current=null}})();return pt.current=Ve,Ve},[T,d,e]);A.useEffect(()=>{ht()},[ht]),A.useEffect(()=>{zt()},[zt]);const Xe=A.useMemo(()=>{const ye=Array.isArray(ie)?ie.length:0,Ve=Array.isArray(ie)?ie.reduce((ze,tt)=>{var bt;return ze+(((bt=tt==null?void 0:tt.duplicates)==null?void 0:bt.length)||0)},0):0;return{groups:ye,duplicateCount:Ve}},[ie]),Wt=A.useCallback((ye,Ve)=>{I(ze=>({...ze,[ye]:Ve})),ye==="password"&&W(!1)},[]),lr=A.useMemo(()=>{const ye={host:j.host.trim(),port:Number.parseInt(j.port,10)||22,username:j.username.trim(),extraSshArgs:(j.extraSshArgs||"").split(/\r?\n/).map(ze=>ze.trim()).filter(Boolean)},Ve=j.password??"";return Ve?ye.password=Ve:F||(ye.password=""),ye},[j,F]),Ht=A.useCallback(async()=>{var ye,Ve;if(n)try{h(!0),await g(l),e({variant:"success",title:"Skript gespeichert",description:"Das benutzerdefinierte Portainer-Update-Skript wurde aktualisiert."})}catch(ze){const tt=((Ve=(ye=ze.response)==null?void 0:ye.data)==null?void 0:Ve.error)||ze.message||"Skript konnte nicht gespeichert werden";e({variant:"error",title:"Speichern fehlgeschlagen",description:tt})}finally{h(!1)}},[g,l,n,e]),Kr=A.useCallback(async()=>{var ye,Ve;try{h(!0),await v(),e({variant:"info",title:"Standardskript wiederhergestellt",description:"Es wird wieder das Standard-Update-Skript verwendet."})}catch(ze){const tt=((Ve=(ye=ze.response)==null?void 0:ye.data)==null?void 0:Ve.error)||ze.message||"Standardskript konnte nicht wiederhergestellt werden";e({variant:"error",title:"Zurücksetzen fehlgeschlagen",description:tt})}finally{h(!1)}},[v,e]),oe=A.useCallback(async()=>{var ye,Ve;try{B(!0),await _(lr),le(null),X(!1),e({variant:"success",title:"SSH-Konfiguration gespeichert",description:"Verbindungseinstellungen wurden aktualisiert."})}catch(ze){const tt=((Ve=(ye=ze.response)==null?void 0:ye.data)==null?void 0:Ve.error)||ze.message||"SSH-Konfiguration konnte nicht gespeichert werden";e({variant:"error",title:"Speichern fehlgeschlagen",description:tt})}finally{B(!1)}},[lr,_,e]),he=A.useCallback(async()=>{var ye,Ve;try{K(!0);const ze=await C(lr);le({success:!0,timestamp:new Date,details:ze==null?void 0:ze.result}),e({variant:"success",title:"SSH-Verbindung erfolgreich",description:"Verbindung zum Portainer-Host wurde hergestellt."})}catch(ze){const tt=((Ve=(ye=ze.response)==null?void 0:ye.data)==null?void 0:Ve.error)||ze.message||"SSH-Verbindung fehlgeschlagen";le({success:!1,timestamp:new Date,error:tt}),e({variant:"error",title:"SSH-Test fehlgeschlagen",description:tt})}finally{K(!1)}},[lr,C,e]),ke=A.useCallback(async()=>{var ye,Ve;try{ne(!0),await O(),I(Bp()),W(!1),X(!1),le(null),e({variant:"info",title:"SSH-Konfiguration gelöscht",description:"Die Verbindungseinstellungen wurden zurückgesetzt."})}catch(ze){const tt=((Ve=(ye=ze.response)==null?void 0:ye.data)==null?void 0:Ve.error)||ze.message||"SSH-Konfiguration konnte nicht gelöscht werden";e({variant:"error",title:"Löschen fehlgeschlagen",description:tt})}finally{ne(!1)}},[O,e]),Je=A.useCallback(async ye=>{var Ve,ze;if(!(a||P)&&ye!==T){if( Line truncated
Zielversion: ${ye}.
Während des Updates befindet sich StackPulse im Wartungsmodus.
Fortfahren?`)))try{k(""),w(!0),await p(),e({variant:"info",title:"Update gestartet",description:"Das Portainer-Update wurde gestartet."})}catch(tt){const bt=((ze=(Ve=tt.response)==null?void 0:Ve.data)==null?void 0:ze.error)||tt.message||"Portainer-Update konnte nicht gestartet werden";k(bt),e({variant:"error",title:"Update fehlgeschlagen",description:bt})}finally{w(!1)}},[T,d,We,p,e]),gt=A.useCallback(async ye=>{var bt,$t,jt,Dr;if(!ye)return;if(T||d){e({variant:"warning",title:"Aktion nicht möglich",description:"Während des Wartungsmodus sind Bereinigungen deaktiviert."});return}const Ve=(bt=ye.canonical)==null?void 0:bt.Id;if(!Ve)return;const ze=(ye.duplicates||[]).map(tr=>tr.Id).filter(Boolean);if(!ze.length)return;const tt=(($t=ye.canonical)==null?void 0:$t.Name)||ye.name||`Stack ${Ve}`;if(!(typeof window<"u"&&!window.confirm(`Bereinigung für "${tt}" starten?
Es werden ${ze.length} Duplikate entfernt: ${ze.join(", ")}`))){nt(String(Ve));try{const Xr=(await Mt.post("/api/maintenance/duplicates/cleanup",{canonicalId:Ve,duplicateIds:ze})).data??{};if(Xr.success===!1)throw new Error(Xr.error||"Bereinigung fehlgeschlagen");const ta=Array.isArray(Xr.results)?Xr.results.filter(fn=>fn.status==="deleted").map(fn=>fn.id):ze;e({variant:"success",title:"Bereinigung abgeschlossen",description:`${tt} – entfernte IDs: ${ta.join(", ")}`}),await zt({silent:!0})}catch(tr){const Xr=((Dr=(jt=tr.response)==null?void 0:jt.data)==null?void 0:Dr.error)||tr.message||"Bereinigung fehlgeschlagen";e({variant:"error",title:"Bereinigung fehlgeschlagen",description:Xr})}finally{nt(null)}}},[zt,T,e,d]);t!=null&&t.activatedAt&&ua(t.activatedAt),t!=null&&t.updatedAt&&ua(t.updatedAt);const Ze=T||d,Tt=(r==null?void 0:r.status)??(d?"running":"idle"),er=p6[Tt]??Tt,Zo=Sl[Tt]??Sl.idle,Or=r!=null&&r.stage?h6[r.stage]??r.stage:"–",$n=(r==null?void 0:r.logs)??[],uo=(r==null?void 0:r.targetVersion)??"-",za=(r==null?void 0:r.resultVersion)??"-",co=r!=null&&r.startedAt?ua(r.startedAt):"-",Yr=r!=null&&r.finishedAt?ua(r.finishedAt):"-",Ba=(r==null?void 0:r.message)??(d?"Update läuft…":""),ea=d||s||a;return D.jsxs("div",{className:"mt-12 mb-8 flex flex-col gap-12",children:[(T||d)&&D.jsx("div",{className:"rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100",children:D.jsxs("div",{className:"flex flex-col gap-1",children:[D.jsxs("span",{children:["Wartungsmodus aktiv",x?` – ${x}`:d?" – Portainer-Update läuft":"","."]}),d&&D.jsxs("span",{className:"text-xs text-indigo-900",children:["Phase: ",Or]})]})}),D.jsxs(xe.Card,{children:[D.jsx(xe.CardHeader,{variant:"gradient",color:"gray",className:"mb-5 p-4",children:D.jsx(xe.Typography,{variant:"h6",color:"white",className:"flex items-center justify-between",children:D.jsx("span",{children:"Wartungsmodus"})})}),D.jsxs(xe.CardBody,{className:"flex flex-col gap-4 p-4",children:[D.jsxs("div",{className:"flex items-start justify-between gap-4",children:[D.jsx("div",{className:"space-y-1",children:D.jsx("p",{className:"text-sm antialiased font-sans text-sm font-light leading-normal text-inherit",children:"Schaltet StackPulse für Benutzer in den Wartungsmodus."})}),D.jsx(xe.Switch,{checked:T,disabled:P||a||d,onChange:ye=>Je(ye.target.checked),ripple:!1,color:"amber"})]}),P&&D.jsx("p",{className:"text-xs text-stormGrey-500",children:"Wartungsmodus wird aktualisiert…"})]})]}),D.jsxs(xe.Card,{children:[D.jsx(xe.CardHeader,{variant:"gradient",color:"gray",className:"mb-5 p-4",children:D.jsx(xe.Typography,{variant:"h6",color:"white",className:"flex items-center justify-between",children:D.jsx("span",{children:"Portainer"})})}),D.jsxs(xe.CardBody,{className:"flex flex-col gap-4 p-4 pt-0",children:[D.jsxs("div",{className:"flex flex-col gap-4 md:flex-row md:items-center md:justify-between",children:[D.jsxs("div",{children:[D.jsx("h2",{className:"block antialiased font-sans text-lg font-semibold",children:"Portainer Status"}),D.jsx("p",{className:"block antialiased font-sans text-sm font-light leading-normal text-inherit",children:Ke?"Status wird geprüft…":ge||(d?"Portainer wird aktuell aktualisiert.":(We==null?void 0:We.updateAvailable)===!0?"Es ist ein Update für Portainer verfügbar.":"Portainer ist auf dem aktuellen Stand.")}),Te&&!Ke&&D.jsxs("p",{className:"mt-1 text-xs text-stormGrey-500 block antialiased font-sans",children:["Stand: ",Te.toLocaleString("de-DE",{hour:"2-digit",minute:"2-digit",second:"2-digit"})]})]}),D.jsx("div",{className:"flex items-center gap-3",children:D.jsx(xe.Button,{onClick:()=>ht({silent:!1}),disabled:Ke||Me,className:"w-full",children:"Status aktualisieren"})})]}),D.jsx("div",{className:"flex flex-col gap-4 mt-5 space-y-4 block antialiased font-sans text-sm font-light leading-normal text-inherit",children:!Ke&&!ge&&We&&D.jsxs("div",{className:"",children:[D.jsxs("div",{className:"flex items-center justify-between space-y-2",children:[D.jsx("span",{children:"Installierte Version"}),D.jsx("span",{className:"font-medium",children:We.currentVersion??"Unbekannt"})]}),D.jsxs("div",{className:"flex items-center justify-between space-y-2",children:[D.jsx("span",{children:"Neueste Version"}),D.jsx("span",{className:"font-medium",children:We.latestVersion??"Unbekannt"})]}),D.jsxs("div",{className:"flex items-center justify-between space-y-2",children:[D.jsx("span",{children:"Status"}),D.jsx("span",{className:`rounded-full px-3 py-0.5 text-xs font-semibold ${We.updateAvailable===!0?Sl.error:We.updateAvailable===!1?Sl.success:Sl.idle}`,children:We.updateAvailable===!0?"Update verfügbar":We.updateAvailable===!1?"Aktuell":"Unbekannt"})]}),We.edition&&D.jsxs("div",{className:"flex items-center justify-between",children:[D.jsx("span",{className:"text-stormGrey-400",children:"Edition"}),D.jsx("span",{className:"font-medium",children:We.edition})]}),We.build&&D.jsxs("div",{className:"flex items-center justify- Line truncated
+93
View File
@@ -0,0 +1,93 @@
function $j(e,t){for(var r=0;r<t.length;r++){const n=t[r];if(typeof n!="string"&&!Array.isArray(n)){for(const o in n)if(o!=="default"&&!(o in e)){const a=Object.getOwnPropertyDescriptor(n,o);a&&Object.defineProperty(e,o,a.get?a:{enumerable:!0,get:()=>n[o]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))n(o);new MutationObserver(o=>{for(const a of o)if(a.type==="childList")for(const i of a.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&n(i)}).observe(document,{childList:!0,subtree:!0});function r(o){const a={};return o.integrity&&(a.integrity=o.integrity),o.referrerPolicy&&(a.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?a.credentials="include":o.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function n(o){if(o.ep)return;o.ep=!0;const a=r(o);fetch(o.href,a)}})();function t_(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}function lu(e){if(e.__esModule)return e;var t=e.default;if(typeof t=="function"){var r=function n(){return this instanceof n?Reflect.construct(t,arguments,this.constructor):t.apply(this,arguments)};r.prototype=t.prototype}else r={};return Object.defineProperty(r,"__esModule",{value:!0}),Object.keys(e).forEach(function(n){var o=Object.getOwnPropertyDescriptor(e,n);Object.defineProperty(r,n,o.get?o:{enumerable:!0,get:function(){return e[n]}})}),r}var r_={exports:{}},tf={},n_={exports:{}},xt={};/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var uu=Symbol.for("react.element"),Hj=Symbol.for("react.portal"),qj=Symbol.for("react.fragment"),Gj=Symbol.for("react.strict_mode"),Kj=Symbol.for("react.profiler"),Yj=Symbol.for("react.provider"),Qj=Symbol.for("react.context"),Xj=Symbol.for("react.forward_ref"),Zj=Symbol.for("react.suspense"),Jj=Symbol.for("react.memo"),e2=Symbol.for("react.lazy"),uy=Symbol.iterator;function t2(e){return e===null||typeof e!="object"?null:(e=uy&&e[uy]||e["@@iterator"],typeof e=="function"?e:null)}var o_={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},a_=Object.assign,i_={};function _s(e,t,r){this.props=e,this.context=t,this.refs=i_,this.updater=r||o_}_s.prototype.isReactComponent={};_s.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};_s.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function s_(){}s_.prototype=_s.prototype;function bg(e,t,r){this.props=e,this.context=t,this.refs=i_,this.updater=r||o_}var vg=bg.prototype=new s_;vg.constructor=bg;a_(vg,_s.prototype);vg.isPureReactComponent=!0;var cy=Array.isArray,l_=Object.prototype.hasOwnProperty,yg={current:null},u_={key:!0,ref:!0,__self:!0,__source:!0};function c_(e,t,r){var n,o={},a=null,i=null;if(t!=null)for(n in t.ref!==void 0&&(i=t.ref),t.key!==void 0&&(a=""+t.key),t)l_.call(t,n)&&!u_.hasOwnProperty(n)&&(o[n]=t[n]);var c=arguments.length-2;if(c===1)o.children=r;else if(1<c){for(var f=Array(c),g=0;g<c;g++)f[g]=arguments[g+2];o.children=f}if(e&&e.defaultProps)for(n in c=e.defaultProps,c)o[n]===void 0&&(o[n]=c[n]);return{$$typeof:uu,type:e,key:a,ref:i,props:o,_owner:yg.current}}function r2(e,t){return{$$typeof:uu,type:e.type,key:t,ref:e.ref,props:e.props,_owner:e._owner}}function xg(e){return typeof e=="object"&&e!==null&&e.$$typeof===uu}function n2(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(r){return t[r]})}var dy=/\/+/g;function Dp(e,t){return typeof e=="object"&&e!==null&&e.key!=null?n2(""+e.key):t.toString(36)}function kc(e,t,r,n,o){var a=typeof e;(a==="undefined"||a==="boolean")&&(e=null);var i=!1;if(e===null)i=!0;else switch(a){case"string":case"number":i=!0;break;case"object":switch(e.$$typeof){case uu:case Hj:i=!0}}if(i)return i=e,o=o(i),e=n===""?"."+Dp(i,0):n,cy(o)?(r="",e!=null&&(r=e.replace(dy,"$&/")+"/"),kc(o,t,r,"",function(g){return g})):o!=null&&(xg(o)&&(o=r2(o,r+(!o.key||i&&i.key===o.key?"":(""+o.key).replace(dy,"$&/")+"/")+e)),t.push(o)),1;if(i=0,n=n===""?".":n+":",cy(e))for(var c=0;c<e.length;c++){a=e[c];var f=n+Dp(a,c);i+=kc(a,t,r,f,o)}else if(f=t2(e),typeof f=="function")for(e=f.call(e),c=0;!(a=e.next()).done;)a=a.value,f=n+Dp(a,c++),i+=kc(a,t,r,f,o);else if(a==="object")throw t=String(e),Error("Objects are not valid as a React child (found: "+(t==="[object Object]"?"object with keys {"+Object.keys(e).join(", ")+"}":t)+"). If you meant to render a collection of children, use an array instead.");return i}function Ju(e,t,r){if(e==null)return e;var n=[],o=0;return kc(e,n,"","",function(a){return t.call(r,a,o++)}),n}function o2(e){if(e._status===-1){var t=e._result;t=t(),t.then(function(r){(e._status===0||e._status===-1)&&(e._status=1,e._result=r)},function(r){(e._status===0||e._status===-1)&&(e._status=2,e._result=r)}),e._status===-1&&(e._status=0,e._result=t)}if(e._status===1)return e._result.default;throw e._result}var Br={current:null},Nc={transition:null},a2={ReactCurrentDispatcher:Br,ReactCurrentBatchConfig:Nc,ReactCurrentOwner:yg};xt.Children={map:Ju,forEach:function(e,t,r){Ju(e,function(){t.apply(this,arguments)},r)},count:function(e){var t=0;return Ju(e,function(){t++}),t},toArray:function(e){return Ju(e,function(t){return t})||[]},only:function(e){if(!xg(e))throw Error("React.Children.only expected to receive a single React element child.");return e}};xt.Component=_s;xt.Fragment=qj;xt.Profiler=Kj;xt.PureComponent=bg;xt.StrictMode=Gj;xt.Suspense=Zj;xt.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=a2;xt.cloneElement=function(e,t,r){if(e==null)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+e+".");var n=a_({},e.props),o=e.key,a=e.ref,i=e._owner;if(t!=null){if(t.ref!==void 0&&(a=t.ref,i=yg.current),t.key!==void 0&&(o=""+t.key),e.type&&e.type.defaultProps)var c=e.type.defaultProps;for(f in t)l_.call(t,f)&&!u_.hasOwnProperty(f)&&(n[f]=t[f]===void 0&&c!==void 0?c[f]:t[f])}var f=arguments.length-2;if(f===1)n.children=r;else if(1<f){c=Array(f);for(var g=0;g<f;g++)c[g]=arguments[g+2];n.children=c}return{$$typeof:uu,type:e.type,key:o,ref:a,props:n,_owner:i}};xt.createContext=function(e){return e={$$typeof:Qj,_currentValue:e,_currentValue2:e,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null},e.Provider={$$typeof:Yj,_conLine truncated
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var i2=P,s2=Symbol.for("react.element"),l2=Symbol.for("react.fragment"),u2=Object.prototype.hasOwnProperty,c2=i2.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,d2={key:!0,ref:!0,__self:!0,__source:!0};function d_(e,t,r){var n,o={},a=null,i=null;r!==void 0&&(a=""+r),t.key!==void 0&&(a=""+t.key),t.ref!==void 0&&(i=t.ref);for(n in t)u2.call(t,n)&&!d2.hasOwnProperty(n)&&(o[n]=t[n]);if(e&&e.defaultProps)for(n in t=e.defaultProps,t)o[n]===void 0&&(o[n]=t[n]);return{$$typeof:s2,type:e,key:a,ref:i,props:o,_owner:c2.current}}tf.Fragment=l2;tf.jsx=d_;tf.jsxs=d_;r_.exports=tf;var y=r_.exports;function f_(e,t){return function(){return e.apply(t,arguments)}}const{toString:f2}=Object.prototype,{getPrototypeOf:_g}=Object,rf=(e=>t=>{const r=f2.call(t);return e[r]||(e[r]=r.slice(8,-1).toLowerCase())})(Object.create(null)),ro=e=>(e=e.toLowerCase(),t=>rf(t)===e),nf=e=>t=>typeof t===e,{isArray:Ss}=Array,Ml=nf("undefined");function p2(e){return e!==null&&!Ml(e)&&e.constructor!==null&&!Ml(e.constructor)&&ln(e.constructor.isBuffer)&&e.constructor.isBuffer(e)}const p_=ro("ArrayBuffer");function h2(e){let t;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?t=ArrayBuffer.isView(e):t=e&&e.buffer&&p_(e.buffer),t}const g2=nf("string"),ln=nf("function"),h_=nf("number"),of=e=>e!==null&&typeof e=="object",m2=e=>e===!0||e===!1,Ec=e=>{if(rf(e)!=="object")return!1;const t=_g(e);return(t===null||t===Object.prototype||Object.getPrototypeOf(t)===null)&&!(Symbol.toStringTag in e)&&!(Symbol.iterator in e)},b2=ro("Date"),v2=ro("File"),y2=ro("Blob"),x2=ro("FileList"),w2=e=>of(e)&&ln(e.pipe),_2=e=>{let t;return e&&(typeof FormData=="function"&&e instanceof FormData||ln(e.append)&&((t=rf(e))==="formdata"||t==="object"&&ln(e.toString)&&e.toString()==="[object FormData]"))},S2=ro("URLSearchParams"),[C2,T2,O2,P2]=["ReadableStream","Request","Response","Headers"].map(ro),k2=e=>e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function cu(e,t,{allOwnKeys:r=!1}={}){if(e===null||typeof e>"u")return;let n,o;if(typeof e!="object"&&(e=[e]),Ss(e))for(n=0,o=e.length;n<o;n++)t.call(null,e[n],n,e);else{const a=r?Object.getOwnPropertyNames(e):Object.keys(e),i=a.length;let c;for(n=0;n<i;n++)c=a[n],t.call(null,e[c],c,e)}}function g_(e,t){t=t.toLowerCase();const r=Object.keys(e);let n=r.length,o;for(;n-- >0;)if(o=r[n],t===o.toLowerCase())return o;return null}const qa=(()=>typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global)(),m_=e=>!Ml(e)&&e!==qa;function Lh(){const{caseless:e}=m_(this)&&this||{},t={},r=(n,o)=>{const a=e&&g_(t,o)||o;Ec(t[a])&&Ec(n)?t[a]=Lh(t[a],n):Ec(n)?t[a]=Lh({},n):Ss(n)?t[a]=n.slice():t[a]=n};for(let n=0,o=arguments.length;n<o;n++)arguments[n]&&cu(arguments[n],r);return t}const N2=(e,t,r,{allOwnKeys:n}={})=>(cu(t,(o,a)=>{r&&ln(o)?e[a]=f_(o,r):e[a]=o},{allOwnKeys:n}),e),E2=e=>(e.charCodeAt(0)===65279&&(e=e.slice(1)),e),j2=(e,t,r,n)=>{e.prototype=Object.create(t.prototype,n),e.prototype.constructor=e,Object.defineProperty(e,"super",{value:t.prototype}),r&&Object.assign(e.prototype,r)},A2=(e,t,r,n)=>{let o,a,i;const c={};if(t=t||{},e==null)return t;do{for(o=Object.getOwnPropertyNames(e),a=o.length;a-- >0;)i=o[a],(!n||n(i,e,t))&&!c[i]&&(t[i]=e[i],c[i]=!0);e=r!==!1&&_g(e)}while(e&&(!r||r(e,t))&&e!==Object.prototype);return t},R2=(e,t,r)=>{e=String(e),(r===void 0||r>e.length)&&(r=e.length),r-=t.length;const n=e.indexOf(t,r);return n!==-1&&n===r},M2=e=>{if(!e)return null;if(Ss(e))return e;let t=e.length;if(!h_(t))return null;const r=new Array(t);for(;t-- >0;)r[t]=e[t];return r},I2=(e=>t=>e&&t instanceof e)(typeof Uint8Array<"u"&&_g(Uint8Array)),D2=(e,t)=>{const n=(e&&e[Symbol.iterator]).call(e);let o;for(;(o=n.next())&&!o.done;){const a=o.value;t.call(e,a[0],a[1])}},L2=(e,t)=>{let r;const n=[];for(;(r=e.exec(t))!==null;)n.push(r);return n},F2=ro("HTMLFormElement"),z2=e=>e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(r,n,o){return n.toUpperCase()+o}),fy=(({hasOwnProperty:e})=>(t,r)=>e.call(t,r))(Object.prototype),B2=ro("RegExp"),b_=(e,t)=>{const r=Object.getOwnPropertyDescriptors(e),n={};cu(r,(o,a)=>{let i;(i=t(o,a,e))!==!1&&(n[a]=i||o)}),Object.defineProperties(e,n)},V2=e=>{b_(e,(t,r)=>{if(ln(e)&&["arguments","caller","callee"].indexOf(r)!==-1)return!1;const n=e[r];if(ln(n)){if(t.enumerable=!1,"writable"in t){t.writable=!1;return}t.set||(t.set=()=>{throw Error("Can not rewrite read-only method '"+r+"'")})}})},U2=(e,t)=>{const r={},n=o=>{o.forEach(a=>{r[a]=!0})};return Ss(e)?n(e):n(String(e).split(t)),r},W2=()=>{},$2=(e,t)=>e!=null&&Number.isFinite(e=+e)?e:t,Lp="abcdefghijklmnopqrstuvwxyz",py="0123456789",v_={DIGIT:py,ALPHA:Lp,ALPHA_DIGIT:Lp+Lp.toUpperCase()+py},H2=(e=16,t=v_.ALPHA_DIGIT)=>{let r="";const{length:n}=t;for(;e--;)r+=t[Math.random()*n|0];return r};function q2(e){return!!(e&&ln(e.append)&&e[Symbol.toStringTag]==="FormData"&&e[Symbol.iterator])}const G2=e=>{const t=new Array(10),r=(n,o)=>{if(of(n)){if(t.indexOf(n)>=0)return;if(!("toJSON"in n)){t[o]=n;const a=SLine truncated
`).forEach(function(i){o=i.indexOf(":"),r=i.substring(0,o).trim().toLowerCase(),n=i.substring(o+1).trim(),!(!r||t[r]&&hA[r])&&(r==="set-cookie"?t[r]?t[r].push(n):t[r]=[n]:t[r]=t[r]?t[r]+", "+n:n)}),t},by=Symbol("internals");function Js(e){return e&&String(e).trim().toLowerCase()}function jc(e){return e===!1||e==null?e:_e.isArray(e)?e.map(jc):String(e)}function mA(e){const t=Object.create(null),r=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let n;for(;n=r.exec(e);)t[n[1]]=n[2];return t}const bA=e=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim());function Fp(e,t,r,n,o){if(_e.isFunction(n))return n.call(this,t,r);if(o&&(t=r),!!_e.isString(t)){if(_e.isString(n))return t.indexOf(n)!==-1;if(_e.isRegExp(n))return n.test(t)}}function vA(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(t,r,n)=>r.toUpperCase()+n)}function yA(e,t){const r=_e.toCamelCase(" "+t);["get","set","has"].forEach(n=>{Object.defineProperty(e,n+r,{value:function(o,a,i){return this[n].call(this,t,o,a,i)},configurable:!0})})}class sf{constructor(t){t&&this.set(t)}set(t,r,n){const o=this;function a(c,f,g){const b=Js(f);if(!b)throw new Error("header name must be a non-empty string");const S=_e.findKey(o,b);(!S||o[S]===void 0||g===!0||g===void 0&&o[S]!==!1)&&(o[S||f]=jc(c))}const i=(c,f)=>_e.forEach(c,(g,b)=>a(g,b,f));if(_e.isPlainObject(t)||t instanceof this.constructor)i(t,r);else if(_e.isString(t)&&(t=t.trim())&&!bA(t))i(gA(t),r);else if(_e.isHeaders(t))for(const[c,f]of t.entries())a(f,c,n);else t!=null&&a(r,t,n);return this}get(t,r){if(t=Js(t),t){const n=_e.findKey(this,t);if(n){const o=this[n];if(!r)return o;if(r===!0)return mA(o);if(_e.isFunction(r))return r.call(this,o,n);if(_e.isRegExp(r))return r.exec(o);throw new TypeError("parser must be boolean|regexp|function")}}}has(t,r){if(t=Js(t),t){const n=_e.findKey(this,t);return!!(n&&this[n]!==void 0&&(!r||Fp(this,this[n],n,r)))}return!1}delete(t,r){const n=this;let o=!1;function a(i){if(i=Js(i),i){const c=_e.findKey(n,i);c&&(!r||Fp(n,n[c],c,r))&&(delete n[c],o=!0)}}return _e.isArray(t)?t.forEach(a):a(t),o}clear(t){const r=Object.keys(this);let n=r.length,o=!1;for(;n--;){const a=r[n];(!t||Fp(this,this[a],a,t,!0))&&(delete this[a],o=!0)}return o}normalize(t){const r=this,n={};return _e.forEach(this,(o,a)=>{const i=_e.findKey(n,a);if(i){r[i]=jc(o),delete r[a];return}const c=t?vA(a):String(a).trim();c!==a&&delete r[a],r[c]=jc(o),n[c]=!0}),this}concat(...t){return this.constructor.concat(this,...t)}toJSON(t){const r=Object.create(null);return _e.forEach(this,(n,o)=>{n!=null&&n!==!1&&(r[o]=t&&_e.isArray(n)?n.join(", "):n)}),r}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([t,r])=>t+": "+r).join(`
`)}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(t){return t instanceof this?t:new this(t)}static concat(t,...r){const n=new this(t);return r.forEach(o=>n.set(o)),n}static accessor(t){const n=(this[by]=this[by]={accessors:{}}).accessors,o=this.prototype;function a(i){const c=Js(i);n[c]||(yA(o,i),n[c]=!0)}return _e.isArray(t)?t.forEach(a):a(t),this}}sf.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);_e.reduceDescriptors(sf.prototype,({value:e},t)=>{let r=t[0].toUpperCase()+t.slice(1);return{get:()=>e,set(n){this[r]=n}}});_e.freezeMethods(sf);const Gn=sf;function zp(e,t){const r=this||Og,n=t||r,o=Gn.from(n.headers);let a=n.data;return _e.forEach(e,function(c){a=c.call(r,a,o.normalize(),t?t.status:void 0)}),o.normalize(),a}function P_(e){return!!(e&&e.__CANCEL__)}function Cs(e,t,r){mt.call(this,e??"canceled",mt.ERR_CANCELED,t,r),this.name="CanceledError"}_e.inherits(Cs,mt,{__CANCEL__:!0});function k_(e,t,r){const n=r.config.validateStatus;!r.status||!n||n(r.status)?e(r):t(new mt("Request failed with status code "+r.status,[mt.ERR_BAD_REQUEST,mt.ERR_BAD_RESPONSE][Math.floor(r.status/100)-4],r.config,r.request,r))}function xA(e){const t=/^([-+\w]{1,25})(:?\/\/|:)/.exec(e);return t&&t[1]||""}function wA(e,t){e=e||10;const r=new Array(e),n=new Array(e);let o=0,a=0,i;return t=t!==void 0?t:1e3,function(f){const g=Date.now(),b=n[a];i||(i=g),r[o]=f,n[o]=g;let S=a,O=0;for(;S!==o;)O+=r[S++],S=S%e;if(o=(o+1)%e,o===a&&(a=(a+1)%e),g-i<t)return;const T=b&&g-b;return T?Math.round(O*1e3/T):void 0}}function _A(e,t){let r=0,n=1e3/t,o,a;const i=(g,b=Date.now())=>{r=b,o=null,a&&(clearTimeout(a),a=null),e.apply(null,g)};return[(...g)=>{const b=Date.now(),S=b-r;S>=n?i(g,b):(o=g,a||(a=setTimeout(()=>{a=null,i(o)},n-S)))},()=>o&&i(o)]}const ad=(e,t,r=3)=>{let n=0;const o=wA(50,250);return _A(a=>{const i=a.loaded,c=a.lengthComputable?a.total:void 0,f=i-n,g=o(f),b=i<=c;n=i;const S={loaded:i,total:c,progress:c?i/c:void 0,bytes:f,rate:g||void 0,estimated:g&&c&&b?(c-i)/g:void 0,event:a,lengthComputable:c!=null,[t?"download":"upload"]:!0};e(S)},r)},vy=(e,t)=>{const r=e!=null;return[n=>t[0]({lengthComputable:r,total:e,loaded:n}),t[1]]},yy=e=>(...t)=>_e.asap(()=>e(...t)),SA=un.hasStandardBrowserEnv?function(){const t=un.navigator&&/(msie|trident)/i.test(un.navigator.userAgent),r=document.createElement("a");let n;function o(a){let i=a;return t&&(r.setAttribute("href",i),i=r.href),r.setAttribute("href",i),{href:r.href,protocol:r.protocol?r.protocol.replace(/:$/,""):"",host:r.host,search:r.search?r.search.replace(/^\?/,""):"",hash:r.hash?r.hash.replace(/^#/,""):"",hostname:r.hostname,port:r.port,pathname:r.pathname.charAt(0)==="/"?r.pathname:"/"+r.pathname}}return n=o(window.location.href),function(i){const c=_e.isString(i)?o(i):i;return c.protocol===n.protocol&&c.host===n.host}}():function(){return function(){return!0}}(),CA=un.hasStandardBrowserEnv?{write(e,t,r,n,o,a){const i=[e+"="+encodeURIComponent(t)];_e.isNumber(r)&&i.push("expires="+new Date(r).toGMTString()),_e.isString(n)&&i.push("path="+n),_e.isString(o)&&i.push("domain="+o),a===!0&&i.push("secure"),document.cookie=i.join("; ")},read(e){const t=document.cookie.match(new RegExp("(^|;\\s*)("+e+")=([^;]*)"));return t?decodeURIComponent(t[3]):null},remove(e){this.write(e,"",Date.now()-864e5)}}:{write(){},read(){return null},remove(){}};function TA(e){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(e)}function OA(e,t){return t?e.replace(/\/?\/$/,"")+"/"+t.replace(/^\/+/,""):e}function N_(e,t){return e&&!TA(t)?OA(e,t):t}const xy=e=>e instanceof Gn?{...e}:e;function ti(e,t){t=t||{};const r={};function n(g,b,S){return _e.isPlainObject(g)&&_e.isPlainObject(b)?_e.merge.call({caseless:S},g,b):_e.isPlainObject(b)?_e.merge({},b):_e.isArray(b)?b.slice():b}function o(g,b,S){if(_e.isUndefined(b)){if(!_e.isUndefined(g))return n(void 0,g,S)}else return n(g,b,S)}function a(g,b){if(!_e.isUndefined(b))return n(void 0,b)}function i(g,b){if(_e.isUndefined(b)){if(!_e.isUndefined(g))return n(void 0,g)}else return n(void 0,b)}function c(g,b,S){if(S in t)return n(g,b);if(S in e)return n(void 0,g)}const f={url:a,method:a,data:a,baseURL:i,transformRequest:i,transformResponse:i,paramsSerializer:i,timeout:i,timeoutMessage:i,withCredentials:i,withXSRFToken:i,adapter:i,responseType:i,xsrfCookieName:i,xsrfHeaderName:i,onUploadProgress:i,onDownloadProgress:i,decompress:i,maxContentLength:i,maxBodyLength:i,beforeRedirect:i,transport:i,httpAgent:i,httpsAgent:i,cancelToken:i,socketPath:i,responseEncoding:i,validateStatus:c,headers:(g,b)=>o(xy(g),xy(b),!0)};return _e.forEach(Object.keys(Object.assign({},e,t)),function(b){const S=f[b]||o,O=S(e[b],t[b],b);_e.isUndefined(O)&&S!==c||(r[b]=O)}),r}const E_=e=>{const t=ti({},e);let{data:r,withXSRFToken:n,xsrfHeaderName:o,xsrfCookieName:a,headers:i,auth:c}=t;t.headers=i=Gn.from(i),t.url=C_(N_(t.baseURL,t.url),e.params,e.paramsSerializer),c&&i.set("Authorization","Basic "+btoa((c.username||"")+":"+(c.password?unescape(enco Line truncated
`+a.map(Sy).join(`
`):" "+Sy(a[0]):"as no adapter specified";throw new mt("There is no suitable adapter to dispatch the request "+i,"ERR_NOT_SUPPORT")}return n},adapters:Uh};function Bp(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new Cs(null,e)}function Cy(e){return Bp(e),e.headers=Gn.from(e.headers),e.data=zp.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),R_.getAdapter(e.adapter||Og.adapter)(e).then(function(n){return Bp(e),n.data=zp.call(e,e.transformResponse,n),n.headers=Gn.from(n.headers),n},function(n){return P_(n)||(Bp(e),n&&n.response&&(n.response.data=zp.call(e,e.transformResponse,n.response),n.response.headers=Gn.from(n.response.headers))),Promise.reject(n)})}const M_="1.7.5",Pg={};["object","boolean","number","function","string","symbol"].forEach((e,t)=>{Pg[e]=function(n){return typeof n===e||"a"+(t<1?"n ":" ")+e}});const Ty={};Pg.transitional=function(t,r,n){function o(a,i){return"[Axios v"+M_+"] Transitional option '"+a+"'"+i+(n?". "+n:"")}return(a,i,c)=>{if(t===!1)throw new mt(o(i," has been removed"+(r?" in "+r:"")),mt.ERR_DEPRECATED);return r&&!Ty[i]&&(Ty[i]=!0,console.warn(o(i," has been deprecated since v"+r+" and will be removed in the near future"))),t?t(a,i,c):!0}};function FA(e,t,r){if(typeof e!="object")throw new mt("options must be an object",mt.ERR_BAD_OPTION_VALUE);const n=Object.keys(e);let o=n.length;for(;o-- >0;){const a=n[o],i=t[a];if(i){const c=e[a],f=c===void 0||i(c,a,e);if(f!==!0)throw new mt("option "+a+" must be "+f,mt.ERR_BAD_OPTION_VALUE);continue}if(r!==!0)throw new mt("Unknown option "+a,mt.ERR_BAD_OPTION)}}const Wh={assertOptions:FA,validators:Pg},Jo=Wh.validators;class sd{constructor(t){this.defaults=t,this.interceptors={request:new my,response:new my}}async request(t,r){try{return await this._request(t,r)}catch(n){if(n instanceof Error){let o;Error.captureStackTrace?Error.captureStackTrace(o={}):o=new Error;const a=o.stack?o.stack.replace(/^.+\n/,""):"";try{n.stack?a&&!String(n.stack).endsWith(a.replace(/^.+\n.+\n/,""))&&(n.stack+=`
`+a):n.stack=a}catch{}}throw n}}_request(t,r){typeof t=="string"?(r=r||{},r.url=t):r=t||{},r=ti(this.defaults,r);const{transitional:n,paramsSerializer:o,headers:a}=r;n!==void 0&&Wh.assertOptions(n,{silentJSONParsing:Jo.transitional(Jo.boolean),forcedJSONParsing:Jo.transitional(Jo.boolean),clarifyTimeoutError:Jo.transitional(Jo.boolean)},!1),o!=null&&(_e.isFunction(o)?r.paramsSerializer={serialize:o}:Wh.assertOptions(o,{encode:Jo.function,serialize:Jo.function},!0)),r.method=(r.method||this.defaults.method||"get").toLowerCase();let i=a&&_e.merge(a.common,a[r.method]);a&&_e.forEach(["delete","get","head","post","put","patch","common"],C=>{delete a[C]}),r.headers=Gn.concat(i,a);const c=[];let f=!0;this.interceptors.request.forEach(function(x){typeof x.runWhen=="function"&&x.runWhen(r)===!1||(f=f&&x.synchronous,c.unshift(x.fulfilled,x.rejected))});const g=[];this.interceptors.response.forEach(function(x){g.push(x.fulfilled,x.rejected)});let b,S=0,O;if(!f){const C=[Cy.bind(this),void 0];for(C.unshift.apply(C,c),C.push.apply(C,g),O=C.length,b=Promise.resolve(r);S<O;)b=b.then(C[S++],C[S++]);return b}O=c.length;let T=r;for(S=0;S<O;){const C=c[S++],x=c[S++];try{T=C(T)}catch(m){x.call(this,m);break}}try{b=Cy.call(this,T)}catch(C){return Promise.reject(C)}for(S=0,O=g.length;S<O;)b=b.then(g[S++],g[S++]);return b}getUri(t){t=ti(this.defaults,t);const r=N_(t.baseURL,t.url);return C_(r,t.params,t.paramsSerializer)}}_e.forEach(["delete","get","head","options"],function(t){sd.prototype[t]=function(r,n){return this.request(ti(n||{},{method:t,url:r,data:(n||{}).data}))}});_e.forEach(["post","put","patch"],function(t){function r(n){return function(a,i,c){return this.request(ti(c||{},{method:t,headers:n?{"Content-Type":"multipart/form-data"}:{},url:a,data:i}))}}sd.prototype[t]=r(),sd.prototype[t+"Form"]=r(!0)});const Ac=sd;class kg{constructor(t){if(typeof t!="function")throw new TypeError("executor must be a function.");let r;this.promise=new Promise(function(a){r=a});const n=this;this.promise.then(o=>{if(!n._listeners)return;let a=n._listeners.length;for(;a-- >0;)n._listeners[a](o);n._listeners=null}),this.promise.then=o=>{let a;const i=new Promise(c=>{n.subscribe(c),a=c}).then(o);return i.cancel=function(){n.unsubscribe(a)},i},t(function(a,i,c){n.reason||(n.reason=new Cs(a,i,c),r(n.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(t){if(this.reason){t(this.reason);return}this._listeners?this._listeners.push(t):this._listeners=[t]}unsubscribe(t){if(!this._listeners)return;const r=this._listeners.indexOf(t);r!==-1&&this._listeners.splice(r,1)}static source(){let t;return{token:new kg(function(o){t=o}),cancel:t}}}const zA=kg;function BA(e){return function(r){return e.apply(null,r)}}function VA(e){return _e.isObject(e)&&e.isAxiosError===!0}const $h={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries($h).forEach(([e,t])=>{$h[t]=e});const UA=$h;function I_(e){const t=new Ac(e),r=f_(Ac.prototype.request,t);return _e.extend(r,Ac.prototype,t,{allOwnKeys:!0}),_e.extend(r,t,null,{allOwnKeys:!0}),r.create=function(o){return I_(ti(e,o))},r}const ur=I_(Og);ur.Axios=Ac;ur.CanceledError=Cs;ur.CancelToken=zA;ur.isCancel=P_;ur.VERSION=M_;ur.toFormData=af;ur.AxiosError=mt;ur.Cancel=ur.CanceledError;ur.all=function(t){return Promise.all(t)};ur.spread=BA;ur.isAxiosError=VA;ur.mergeConfig=ti;ur.AxiosHeaders=Gn;ur.formToJSON=e=>O_(_e.isHTMLForm(e)?new FormData(e):e);ur.getAdapter=R_.getAdapter;ur.HttpStatusCode=UA;ur.default=ur;const lt=ur;var Hh={},D_={exports:{}},hn={},L_={exports:{}},F_={};/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/(function(e){function t(q,z){var $=q.length;q.push(z);e:for(;0<$;){var K=$-1>>>1,X=q[K];if(0<o(X,z))q[K]=z,q[$]=X,$=K;else break e}}function r(q){return q.length===0?null:q[0]}function n(q){if(q.length===0)return null;var z=q[0],$=q.pop();if($!==z){q[0]=$;e:for(var K=0,X=q.length,te=X>>>1;K<te;){var me=2*(K+1)-1,de=q[me],ee=me+1,Se=q[ee];if(0>o(de,$))ee<X&&0>o(Se,de)?(q[K]=Se,q[ee]=$,K=ee):(q[K]=de,q[me]=$,K=me);else if(ee<X&&0>o(Se,$))q[K]=Se,q[ee]=$,K=ee;else break e}}return z}function o(q,z){var $=q.sortIndex-z.sortIndex;return $!==0?$:q.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var a=performance;e.unstable_now=function(){return a.now()}}else{var i=Date,c=i.now();e.unstable_now=function(){return i.now()-c}}var f=[],g=[],b=1,S=null,O=3,T=!1,C=!1,x=!1,m=typeof setTimeout=="function"?setTimeout:null,d=typeof clearTimeout=="function"?clearTimeout:null,s=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function u(q){for(var z=r(g);z!==null;){if(z.callback===null)n(g);else if(z.startTime<=q)n(g),z.sortIndex=z.expirationTime,t(f,z);else break;z=r(g)}}function l(q){if(x=!1,u(q),!C)if(r(f)!==null)C=!0,U(h);else{var z=r(g);z!==null&&Y(l,z.startTime-q)}}function h(q,z){C=!1,x&&(x=!1,d(j),j=-1),T=!0;var $=O;try{for(u(z),S=r(f);S!==null&&(!(S.expirationTime>z)||q&&!A());){var K=S.callback;if(typeof K=="function"){S.callback=null,O=S.priorityLevel;var X=K(S.expirationTime<=z);z=e.unstable_now(),typeof X=="function"?S.callback=X:S===r(f)&&n(f),u(z)}else n(f);S=r(f)}if(S!==null)var te=!0;else{var me=r(g);me!==null&&Y(l,me.startTime-z),te=!1}return te}finally{S=null,O=$,T=!1}}var v=!1,w=null,j=-1,N=5,k=-1;function A(){return!(e.unstable_now()-k<N)}function M(){if(w!==null){var q=e.unstable_now();k=q;var z=!0;try{z=w(!0,q)}finally{z?D():(v=!1,w=null)}}else v=!1}var D;if(typeof s=="function")D=function(){s(M)};else if(typeof MessageChannel<"u"){var L=new MessageChannel,B=L.port2;L.port1.onmessage=M,D=function(){B.postMessage(null)}}else D=function(){m(M,0)};function U(q){w=q,v||(v=!0,D())}function Y(q,z){j=m(function(){q(e.unstable_now())},z)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(q){q.callback=null},e.unstable_continueExecution=function(){C||T||(C=!0,U(h))},e.unstable_forceFrameRate=function(q){0>q||125<q?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):N=0<q?Math.floor(1e3/q):5},e.unstable_getCurrentPriorityLevel=function(){return O},e.unstable_getFirstCallbackNode=function(){return r(f)},e.unstable_next=function(q){switch(O){case 1:case 2:case 3:var z=3;break;default:z=O}var $=O;O=z;try{return q()}finally{O=$}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(q,z){switch(q){case 1:case 2:case 3:case 4:case 5:break;default:q=3}var $=O;O=q;try{return z()}finally{O=$}},e.unstable_scheduleCallback=function(q,z,$){var K=e.unstable_now();switch(typeof $=="object"&&$!==null?($=$.delay,$=typeof $=="number"&&0<$?K+$:K):$=K,q){case 1:var X=-1;break;case 2:X=250;break;case 5:X=1073741823;break;case 4:X=1e4;break;default:X=5e3}return X=$+X,q={id:b++,callback:z,priorityLevel:q,startTime:$,expirationTime:X,sortIndex:-1},$>K?(q.sortIndex=$,t(g,q),r(f)===null&&q===r(g)&&(x?(d(j),j=-1):x=!0,Y(l,$-K))):(q.sortIndex=X,t(f,q),C||T||(C=!0,U(h))),q},e.unstable_shouldYield=A,e.unstable_wrapCallback=function(q){var z=O;return function(){var $=O;O=z;try{return q.apply(this,arguments)}finally{O=$}}}})(F_);L_.exports=F_;var WA=L_.exports;/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var z_=P,fn=WA;function Re(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,r=1;r<arguments.length;r++)t+="&args[]="+encodeURIComponent(arguments[r]);return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var B_=new Set,Il={};function di(e,t){is(e,t),is(e+"Capture",t)}function is(e,t){for(Il[e]=t,e=0;e<t.length;e++)B_.add(t[e])}var Fo=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),qh=Object.prototype.hasOwnProperty,$A=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Oy={},Py={};function HA(e){return qh.call(Py,e)?!0:qh.call(Oy,e)?!1:$A.test(e)?Py[e]=!0:(Oy[e]=!0,!1)}function qA(e,t,r,n){if(r!==null&&r.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return n?!1:r!==null?!r.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function GA(e,t,r,n){if(t===null||typeof t>"u"||qA(e,t,r,n))return!0;if(n)return!1;if(r!==null)switch(r.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function Vr(e,t,r,n,o,a,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=n,this.attributeNamespace=o,this.mustUseProperty=r,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=i}var Cr={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Cr[e]=new Vr(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];Cr[t]=new Vr(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){Cr[e]=new Vr(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Cr[e]=new Vr(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Cr[e]=new Vr(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){Cr[e]=new Vr(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){Cr[e]=new Vr(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){Cr[e]=new Vr(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){Cr[e]=new Vr(e,5,!1,e.toLowerCase(),null,!1,!1)});var Ng=/[\-:]([a-z])/g;function Eg(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Ng,Eg);Cr[t]=new Vr(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Ng,Eg);Cr[t]=new Vr(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Ng,Eg);Cr[t]=new Vr(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){Cr[e]=new Vr(e,1,!1,e.toLowerCase(),null,!1,!1)});Cr.xlinkHref=new Vr("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){Cr[e]=new Vr(e,1,!1,e.toLowerCase(),null,!0,!0)});function jg(e,t,r,n){var o=Cr.hasOwnProperty(t)?Cr[t]:null;(o!==null?o.type!==0:n||!(2<t.length)||t[0]!=="o"&&t[0]!== Line truncated
`+Vp+e}var Up=!1;function Wp(e,t){if(!e||Up)return"";Up=!0;var r=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(t)if(t=function(){throw Error()},Object.defineProperty(t.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(t,[])}catch(g){var n=g}Reflect.construct(e,[],t)}else{try{t.call()}catch(g){n=g}e.call(t.prototype)}else{try{throw Error()}catch(g){n=g}e()}}catch(g){if(g&&n&&typeof g.stack=="string"){for(var o=g.stack.split(`
`),a=n.stack.split(`
`),i=o.length-1,c=a.length-1;1<=i&&0<=c&&o[i]!==a[c];)c--;for(;1<=i&&0<=c;i--,c--)if(o[i]!==a[c]){if(i!==1||c!==1)do if(i--,c--,0>c||o[i]!==a[c]){var f=`
`+o[i].replace(" at new "," at ");return e.displayName&&f.includes("<anonymous>")&&(f=f.replace("<anonymous>",e.displayName)),f}while(1<=i&&0<=c);break}}}finally{Up=!1,Error.prepareStackTrace=r}return(e=e?e.displayName||e.name:"")?pl(e):""}function KA(e){switch(e.tag){case 5:return pl(e.type);case 16:return pl("Lazy");case 13:return pl("Suspense");case 19:return pl("SuspenseList");case 0:case 2:case 15:return e=Wp(e.type,!1),e;case 11:return e=Wp(e.type.render,!1),e;case 1:return e=Wp(e.type,!0),e;default:return""}}function Qh(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Di:return"Fragment";case Ii:return"Portal";case Gh:return"Profiler";case Ag:return"StrictMode";case Kh:return"Suspense";case Yh:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case U_:return(e.displayName||"Context")+".Consumer";case V_:return(e._context.displayName||"Context")+".Provider";case Rg:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Mg:return t=e.displayName||null,t!==null?t:Qh(e.type)||"Memo";case aa:t=e._payload,e=e._init;try{return Qh(e(t))}catch{}}return null}function YA(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Qh(t);case 8:return t===Ag?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Ca(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function $_(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function QA(e){var t=$_(e)?"checked":"value",r=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),n=""+e[t];if(!e.hasOwnProperty(t)&&typeof r<"u"&&typeof r.get=="function"&&typeof r.set=="function"){var o=r.get,a=r.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(i){n=""+i,a.call(this,i)}}),Object.defineProperty(e,t,{enumerable:r.enumerable}),{getValue:function(){return n},setValue:function(i){n=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function tc(e){e._valueTracker||(e._valueTracker=QA(e))}function H_(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var r=t.getValue(),n="";return e&&(n=$_(e)?e.checked?"true":"false":e.value),e=n,e!==r?(t.setValue(e),!0):!1}function ld(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Xh(e,t){var r=t.checked;return rr({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:r??e._wrapperState.initialChecked})}function Ny(e,t){var r=t.defaultValue==null?"":t.defaultValue,n=t.checked!=null?t.checked:t.defaultChecked;r=Ca(t.value!=null?t.value:r),e._wrapperState={initialChecked:n,initialValue:r,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function q_(e,t){t=t.checked,t!=null&&jg(e,"checked",t,!1)}function Zh(e,t){q_(e,t);var r=Ca(t.value),n=t.type;if(r!=null)n==="number"?(r===0&&e.value===""||e.value!=r)&&(e.value=""+r):e.value!==""+r&&(e.value=""+r);else if(n==="submit"||n==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Jh(e,t.type,r):t.hasOwnProperty("defaultValue")&&Jh(e,t.type,Ca(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Ey(e,t,r){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var n=t.type;if(!(n!=="submit"&&n!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,r||t===e.value||(e.value=t),e.defaultValue=t}r=e.name,r!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,r!==""&&(e.name=r)}function Jh(e,t,r){(t!=="number"||ld(e.ownerDocument)!==e)&&(r==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+r&&(e.defaultValue=""+r))}var hl=Array.isArray;function Yi(e,t,r,n){if(e=e.options,t){t={};for(var o=0;o<r.length;o++)t["$"+r[o]]=!0;for(r=0;r<e.length;r++)o=t.hasOwnProperty("$"+e[r].value),e[r].selected!==o&&(e[r].selected=o),o&&n&&(e[r].defaultSelected=!0)}else{for(r=""+Ca(r),t=null,o=0;o<e.length;o++){if(e[o].value===r){e[o].selected=!0,n&&(e[o].defaultSelected=!0);return}t!==null||e[o].disabled||(t=e[o])}t!==null&&(t.selected=!0)}}function e0(e,t){if(t.dan Line truncated
`).replace(iM,"")}function uc(e,t,r){if(t=Jy(t),Jy(e)!==t&&r)throw Error(Re(425))}function md(){}var p0=null,h0=null;function g0(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var m0=typeof setTimeout=="function"?setTimeout:void 0,sM=typeof clearTimeout=="function"?clearTimeout:void 0,ex=typeof Promise=="function"?Promise:void 0,lM=typeof queueMicrotask=="function"?queueMicrotask:typeof ex<"u"?function(e){return ex.resolve(null).then(e).catch(uM)}:m0;function uM(e){setTimeout(function(){throw e})}function rh(e,t){var r=t,n=0;do{var o=r.nextSibling;if(e.removeChild(r),o&&o.nodeType===8)if(r=o.data,r==="/$"){if(n===0){e.removeChild(o),Bl(t);return}n--}else r!=="$"&&r!=="$?"&&r!=="$!"||n++;r=o}while(r);Bl(t)}function ma(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?")break;if(t==="/$")return null}}return e}function tx(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var r=e.data;if(r==="$"||r==="$!"||r==="$?"){if(t===0)return e;t--}else r==="/$"&&t++}e=e.previousSibling}return null}var Os=Math.random().toString(36).slice(2),go="__reactFiber$"+Os,Hl="__reactProps$"+Os,zo="__reactContainer$"+Os,b0="__reactEvents$"+Os,cM="__reactListeners$"+Os,dM="__reactHandles$"+Os;function Ga(e){var t=e[go];if(t)return t;for(var r=e.parentNode;r;){if(t=r[zo]||r[go]){if(r=t.alternate,t.child!==null||r!==null&&r.child!==null)for(e=tx(e);e!==null;){if(r=e[go])return r;e=tx(e)}return t}e=r,r=e.parentNode}return null}function pu(e){return e=e[go]||e[zo],!e||e.tag!==5&&e.tag!==6&&e.tag!==13&&e.tag!==3?null:e}function Bi(e){if(e.tag===5||e.tag===6)return e.stateNode;throw Error(Re(33))}function pf(e){return e[Hl]||null}var v0=[],Vi=-1;function ka(e){return{current:e}}function qt(e){0>Vi||(e.current=v0[Vi],v0[Vi]=null,Vi--)}function Ut(e,t){Vi++,v0[Vi]=e.current,e.current=t}var Ta={},Rr=ka(Ta),Qr=ka(!1),ri=Ta;function ss(e,t){var r=e.type.contextTypes;if(!r)return Ta;var n=e.stateNode;if(n&&n.__reactInternalMemoizedUnmaskedChildContext===t)return n.__reactInternalMemoizedMaskedChildContext;var o={},a;for(a in r)o[a]=t[a];return n&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=o),o}function Xr(e){return e=e.childContextTypes,e!=null}function bd(){qt(Qr),qt(Rr)}function rx(e,t,r){if(Rr.current!==Ta)throw Error(Re(168));Ut(Rr,t),Ut(Qr,r)}function MS(e,t,r){var n=e.stateNode;if(t=t.childContextTypes,typeof n.getChildContext!="function")return r;n=n.getChildContext();for(var o in n)if(!(o in t))throw Error(Re(108,YA(e)||"Unknown",o));return rr({},r,n)}function vd(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ta,ri=Rr.current,Ut(Rr,e),Ut(Qr,Qr.current),!0}function nx(e,t,r){var n=e.stateNode;if(!n)throw Error(Re(169));r?(e=MS(e,t,ri),n.__reactInternalMemoizedMergedChildContext=e,qt(Qr),qt(Rr),Ut(Rr,e)):qt(Qr),Ut(Qr,r)}var Ao=null,hf=!1,nh=!1;function IS(e){Ao===null?Ao=[e]:Ao.push(e)}function fM(e){hf=!0,IS(e)}function Na(){if(!nh&&Ao!==null){nh=!0;var e=0,t=Dt;try{var r=Ao;for(Dt=1;e<r.length;e++){var n=r[e];do n=n(!0);while(n!==null)}Ao=null,hf=!1}catch(o){throw Ao!==null&&(Ao=Ao.slice(e+1)),iS(Dg,Na),o}finally{Dt=t,nh=!1}}return null}var Ui=[],Wi=0,yd=null,xd=0,_n=[],Sn=0,ni=null,Ro=1,Mo="";function Wa(e,t){Ui[Wi++]=xd,Ui[Wi++]=yd,yd=e,xd=t}function DS(e,t,r){_n[Sn++]=Ro,_n[Sn++]=Mo,_n[Sn++]=ni,ni=e;var n=Ro;e=Mo;var o=32-Kn(n)-1;n&=~(1<<o),r+=1;var a=32-Kn(t)+o;if(30<a){var i=o-o%5;a=(n&(1<<i)-1).toString(32),n>>=i,o-=i,Ro=1<<32-Kn(t)+o|r<<o|n,Mo=a+e}else Ro=1<<a|r<<o|n,Mo=e}function Hg(e){e.return!==null&&(Wa(e,1),DS(e,1,0))}function qg(e){for(;e===yd;)yd=Ui[--Wi],Ui[Wi]=null,xd=Ui[--Wi],Ui[Wi]=null;for(;e===ni;)ni=_n[--Sn],_n[Sn]=null,Mo=_n[--Sn],_n[Sn]=null,Ro=_n[--Sn],_n[Sn]=null}var cn=null,an=null,Qt=!1,$n=null;function LS(e,t){var r=Tn(5,null,null,0);r.elementType="DELETED",r.stateNode=t,r.return=e,t=e.deletions,t===null?(e.deletions=[r],e.flags|=16):t.push(r)}function ox(e,t){switch(e.tag){case 5:var r=e.type;return t=t.nodeType!==1||r.toLowerCase()!==t.nodeName.toLowerCase()?null:t,t!==null?(e.stateNode=t,cn=e,an=ma(t.firstChild),!0):!1;case 6:return t=e.pendingProps===""||t.nodeType!==3?null:t,t!==null?(e.stateNode=t,cn=e,an=null,!0):!1;case 13:return t=t.nodeType!==8?null:t,t!==null?(r=ni!==null?{id:Ro,overflow:Mo}:null,e.memoizedState={dehydrated:t,treeContext:r,retryLane:1073741824},r=Tn(18,null,null,0),r.stateNode=t,r.return=e,e.child=r,cn=e,an=null,!0):!1;default:return!1}}function y0(e){return(e.mode&1)!==0&&(e.flags&128)===0}function x0(e){if(Qt){var t=an;if(t){var r=t;if(!ox(e,t)){if(y0(e))throw Error(Re(418));t=ma(r.nextSibling);var n=cn;t&&ox(e,t)?LS(n,r):(e.flags=e.flags&-4097|2,Qt=!1,cn=e)}}else{if(y0(e))throw Error(Re(418));e.flags=e.flags&-4097|2,Qt=!1,cn=e}}}function ax(e){ Line truncated
Error generating stack: `+a.message+`
`+a.stack}return{value:e,source:t,stack:o,digest:null}}function lh(e,t,r){return{value:e,source:null,stack:r??null,digest:t??null}}function C0(e,t){try{console.error(t.value)}catch(r){setTimeout(function(){throw r})}}var wM=typeof WeakMap=="function"?WeakMap:Map;function d5(e,t,r){r=Io(-1,r),r.tag=3,r.payload={element:null};var n=t.value;return r.callback=function(){kd||(kd=!0,M0=n),C0(e,t)},r}function f5(e,t,r){r=Io(-1,r),r.tag=3;var n=e.type.getDerivedStateFromError;if(typeof n=="function"){var o=t.value;r.payload=function(){return n(o)},r.callback=function(){C0(e,t)}}var a=e.stateNode;return a!==null&&typeof a.componentDidCatch=="function"&&(r.callback=function(){C0(e,t),typeof n!="function"&&(va===null?va=new Set([this]):va.add(this));var i=t.stack;this.componentDidCatch(t.value,{componentStack:i!==null?i:""})}),r}function px(e,t,r){var n=e.pingCache;if(n===null){n=e.pingCache=new wM;var o=new Set;n.set(t,o)}else o=n.get(t),o===void 0&&(o=new Set,n.set(t,o));o.has(r)||(o.add(r),e=IM.bind(null,e,t,r),t.then(e,e))}function hx(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function gx(e,t,r,n,o){return e.mode&1?(e.flags|=65536,e.lanes=o,e):(e===t?e.flags|=65536:(e.flags|=128,r.flags|=131072,r.flags&=-52805,r.tag===1&&(r.alternate===null?r.tag=17:(t=Io(-1,1),t.tag=2,ba(r,t,1))),r.lanes|=1),e)}var _M=$o.ReactCurrentOwner,Kr=!1;function Fr(e,t,r,n){t.child=e===null?$S(t,null,r,n):us(t,e.child,r,n)}function mx(e,t,r,n,o){r=r.render;var a=t.ref;return Ji(t,o),n=nm(e,t,r,n,a,o),r=om(),e!==null&&!Kr?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~o,Vo(e,t,o)):(Qt&&r&&Hg(t),t.flags|=1,Fr(e,t,n,o),t.child)}function bx(e,t,r,n,o){if(e===null){var a=r.type;return typeof a=="function"&&!hm(a)&&a.defaultProps===void 0&&r.compare===null&&r.defaultProps===void 0?(t.tag=15,t.type=a,p5(e,t,a,n,o)):(e=Uc(r.type,null,n,t,t.mode,o),e.ref=t.ref,e.return=t,t.child=e)}if(a=e.child,!(e.lanes&o)){var i=a.memoizedProps;if(r=r.compare,r=r!==null?r:Ul,r(i,n)&&e.ref===t.ref)return Vo(e,t,o)}return t.flags|=1,e=xa(a,n),e.ref=t.ref,e.return=t,t.child=e}function p5(e,t,r,n,o){if(e!==null){var a=e.memoizedProps;if(Ul(a,n)&&e.ref===t.ref)if(Kr=!1,t.pendingProps=n=a,(e.lanes&o)!==0)e.flags&131072&&(Kr=!0);else return t.lanes=e.lanes,Vo(e,t,o)}return T0(e,t,r,n,o)}function h5(e,t,r){var n=t.pendingProps,o=n.children,a=e!==null?e.memoizedState:null;if(n.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},Ut(qi,on),on|=r;else{if(!(r&1073741824))return e=a!==null?a.baseLanes|r:r,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,Ut(qi,on),on|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},n=a!==null?a.baseLanes:r,Ut(qi,on),on|=n}else a!==null?(n=a.baseLanes|r,t.memoizedState=null):n=r,Ut(qi,on),on|=n;return Fr(e,t,o,r),t.child}function g5(e,t){var r=t.ref;(e===null&&r!==null||e!==null&&e.ref!==r)&&(t.flags|=512,t.flags|=2097152)}function T0(e,t,r,n,o){var a=Xr(r)?ri:Rr.current;return a=ss(t,a),Ji(t,o),r=nm(e,t,r,n,a,o),n=om(),e!==null&&!Kr?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~o,Vo(e,t,o)):(Qt&&n&&Hg(t),t.flags|=1,Fr(e,t,r,o),t.child)}function vx(e,t,r,n,o){if(Xr(r)){var a=!0;vd(t)}else a=!1;if(Ji(t,o),t.stateNode===null)zc(e,t),US(t,r,n),S0(t,r,n,o),n=!0;else if(e===null){var i=t.stateNode,c=t.memoizedProps;i.props=c;var f=i.context,g=r.contextType;typeof g=="object"&&g!==null?g=Nn(g):(g=Xr(r)?ri:Rr.current,g=ss(t,g));var b=r.getDerivedStateFromProps,S=typeof b=="function"||typeof i.getSnapshotBeforeUpdate=="function";S||typeof i.UNSAFE_componentWillReceiveProps!="function"&&typeof i.componentWillReceiveProps!="function"||(c!==n||f!==g)&&ux(t,i,n,g),ia=!1;var O=t.memoizedState;i.state=O,Sd(t,n,i,o),f=t.memoizedState,c!==n||O!==f||Qr.current||ia?(typeof b=="function"&&(_0(t,r,b,n),f=t.memoizedState),(c=ia||lx(t,r,c,n,O,f,g))?(S||typeof i.UNSAFE_componentWillMount!="function"&&typeof i.componentWillMount!="function"||(typeof i.componentWillMount=="function"&&i.componentWillMount(),typeof i.UNSAFE_componentWillMount=="function"&&i.UNSAFE_componentWillMount()),typeof i.componentDidMount=="function"&&(t.flags|=4194308)):(typeof i.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=n,t.memoizedState=f),i.props=n,i.state=f,i.context=g,n=c):(typeof i.componentDidMount=="function"&&(t.flags|=4194308),n=!1)}else{i=t.stateNode,BS(e,t),c=t.memoizedProps,g=t.type===t.elementType?c:Vn(t.type,c),i.props=g,S=t.pendingProps,O=i.context,f=r.contextType,typeof f=="object"&&f!==null?f=Nn(f):(f=Xr(r)?ri:Rr.current,f=ss(t,f));var T=r.getDerivedStateFromProps;(b=typeof T=="function"||typeof i.getSnapshotBeforeUpdate=="function")||typeof i.UNSAFE_componentWillReceiveProps!="function"&&typeof i.componentWillReceiveProps!="function"||(c!==S||O!==f)&&ux(t,i,n,f),ia=!1,O=t.memoizedState,i.state=O,Sd(t,n,i,o);var C=t.memoizedS Line truncated
* @remix-run/router v1.10.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function Zl(){return Zl=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(e[n]=r[n])}return e},Zl.apply(this,arguments)}var fa;(function(e){e.Pop="POP",e.Push="PUSH",e.Replace="REPLACE"})(fa||(fa={}));const Ax="popstate";function qM(e){e===void 0&&(e={});function t(n,o){let{pathname:a,search:i,hash:c}=n.location;return F0("",{pathname:a,search:i,hash:c},o.state&&o.state.usr||null,o.state&&o.state.key||"default")}function r(n,o){return typeof o=="string"?o:Ad(o)}return KM(t,r,null,e)}function tr(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function ym(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function GM(){return Math.random().toString(36).substr(2,8)}function Rx(e,t){return{usr:e.state,key:e.key,idx:t}}function F0(e,t,r,n){return r===void 0&&(r=null),Zl({pathname:typeof e=="string"?e:e.pathname,search:"",hash:""},typeof t=="string"?Ps(t):t,{state:r,key:t&&t.key||n||GM()})}function Ad(e){let{pathname:t="/",search:r="",hash:n=""}=e;return r&&r!=="?"&&(t+=r.charAt(0)==="?"?r:"?"+r),n&&n!=="#"&&(t+=n.charAt(0)==="#"?n:"#"+n),t}function Ps(e){let t={};if(e){let r=e.indexOf("#");r>=0&&(t.hash=e.substr(r),e=e.substr(0,r));let n=e.indexOf("?");n>=0&&(t.search=e.substr(n),e=e.substr(0,n)),e&&(t.pathname=e)}return t}function KM(e,t,r,n){n===void 0&&(n={});let{window:o=document.defaultView,v5Compat:a=!1}=n,i=o.history,c=fa.Pop,f=null,g=b();g==null&&(g=0,i.replaceState(Zl({},i.state,{idx:g}),""));function b(){return(i.state||{idx:null}).idx}function S(){c=fa.Pop;let m=b(),d=m==null?null:m-g;g=m,f&&f({action:c,location:x.location,delta:d})}function O(m,d){c=fa.Push;let s=F0(x.location,m,d);r&&r(s,m),g=b()+1;let u=Rx(s,g),l=x.createHref(s);try{i.pushState(u,"",l)}catch(h){if(h instanceof DOMException&&h.name==="DataCloneError")throw h;o.location.assign(l)}a&&f&&f({action:c,location:x.location,delta:1})}function T(m,d){c=fa.Replace;let s=F0(x.location,m,d);r&&r(s,m),g=b();let u=Rx(s,g),l=x.createHref(s);i.replaceState(u,"",l),a&&f&&f({action:c,location:x.location,delta:0})}function C(m){let d=o.location.origin!=="null"?o.location.origin:o.location.href,s=typeof m=="string"?m:Ad(m);return tr(d,"No window.location.(origin|href) available to create URL for href: "+s),new URL(s,d)}let x={get action(){return c},get location(){return e(o,i)},listen(m){if(f)throw new Error("A history only accepts one active listener");return o.addEventListener(Ax,S),f=m,()=>{o.removeEventListener(Ax,S),f=null}},createHref(m){return t(o,m)},createURL:C,encodeLocation(m){let d=C(m);return{pathname:d.pathname,search:d.search,hash:d.hash}},push:O,replace:T,go(m){return i.go(m)}};return x}var Mx;(function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"})(Mx||(Mx={}));function YM(e,t,r){r===void 0&&(r="/");let n=typeof t=="string"?Ps(t):t,o=Jl(n.pathname||"/",r);if(o==null)return null;let a=z5(e);QM(a);let i=null;for(let c=0;i==null&&c<a.length;++c)i=aI(a[c],sI(o));return i}function z5(e,t,r,n){t===void 0&&(t=[]),r===void 0&&(r=[]),n===void 0&&(n="");let o=(a,i,c)=>{let f={relativePath:c===void 0?a.path||"":c,caseSensitive:a.caseSensitive===!0,childrenIndex:i,route:a};f.relativePath.startsWith("/")&&(tr(f.relativePath.startsWith(n),'Absolute route path "'+f.relativePath+'" nested under path '+('"'+n+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),f.relativePath=f.relativePath.slice(n.length));let g=wa([n,f.relativePath]),b=r.concat(f);a.children&&a.children.length>0&&(tr(a.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+g+'".')),z5(a.children,t,b,g)),!(a.path==null&&!a.index)&&t.push({path:g,score:nI(g,a.index),routesMeta:b})};return e.forEach((a,i)=>{var c;if(a.path===""||!((c=a.path)!=null&&c.includes("?")))o(a,i);else for(let f of B5(a.path))o(a,i,f)}),t}function B5(e){let t=e.split("/");if(t.length===0)return[];let[r,...n]=t,o=r.endsWith("?"),a=r.replace(/\?$/,"");if(n.length===0)return o?[a,""]:[a];let i=B5(n.join("/")),c=[];return c.push(...i.map(f=>f===""?a:[a,f].join("/"))),o&&c.push(...i),c.map(f=>e.startsWith("/")&&f===""?"/":f)}function QM(e){e.sort((t,r)=>t.score!==r.score?r.score-t.score:oI(t.routesMeta.map(n=>n.childrenIndex),r.routesMeta.map(n=>n.childrenIndex)))}const XM=/^:\w+$/,ZM=3,JM=2,eI=1,tI=10,rI=-2,Ix=e=>e==="*";function nI(e,t){let r=e.split("/"),n=r.length;return r.some(Ix)&&(n+=rI),t&&(n+=JM),r.filter(o=>!Ix(o)).reduce((o,a)=>o+(XM.test(a)?ZM:a===""?eI:tI),n)}function oI(e,t){return e.length===t.length&&e.slice(0,-1).every((n,o)=>n===t[o])?e[e.length-1]-t[t.length-1]:0}function aI(e,t){let{routesMeta:r}=e,n={},o="/",a=[];for(let i=0;i<r.length;++i){let c=r[i],f=i===r.length-1,g=o==="/"?t:t.slice(o.length)||"/",b=z0({path:c.relativePath,caseSensitive:c.caseSensitive,end:f},g);if(!b)return null;Object.assign(n,b.paLine truncated
* React Router v6.17.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function Rd(){return Rd=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(e[n]=r[n])}return e},Rd.apply(this,arguments)}const Cf=P.createContext(null),U5=P.createContext(null),pi=P.createContext(null),Tf=P.createContext(null),Ho=P.createContext({outlet:null,matches:[],isDataRoute:!1}),W5=P.createContext(null);function mI(e,t){let{relative:r}=t===void 0?{}:t;ks()||tr(!1);let{basename:n,navigator:o}=P.useContext(pi),{hash:a,pathname:i,search:c}=Of(e,{relative:r}),f=i;return n!=="/"&&(f=i==="/"?n:wa([n,i])),o.createHref({pathname:f,search:c,hash:a})}function ks(){return P.useContext(Tf)!=null}function Ea(){return ks()||tr(!1),P.useContext(Tf).location}function $5(e){P.useContext(pi).static||P.useLayoutEffect(e)}function So(){let{isDataRoute:e}=P.useContext(Ho);return e?NI():bI()}function bI(){ks()||tr(!1);let e=P.useContext(Cf),{basename:t,navigator:r}=P.useContext(pi),{matches:n}=P.useContext(Ho),{pathname:o}=Ea(),a=JSON.stringify(xm(n).map(f=>f.pathnameBase)),i=P.useRef(!1);return $5(()=>{i.current=!0}),P.useCallback(function(f,g){if(g===void 0&&(g={}),!i.current)return;if(typeof f=="number"){r.go(f);return}let b=wm(f,JSON.parse(a),o,g.relative==="path");e==null&&t!=="/"&&(b.pathname=b.pathname==="/"?t:wa([t,b.pathname])),(g.replace?r.replace:r.push)(b,g.state,g)},[t,r,a,o,e])}function H5(){let{matches:e}=P.useContext(Ho),t=e[e.length-1];return t?t.params:{}}function Of(e,t){let{relative:r}=t===void 0?{}:t,{matches:n}=P.useContext(Ho),{pathname:o}=Ea(),a=JSON.stringify(xm(n).map(i=>i.pathnameBase));return P.useMemo(()=>wm(e,JSON.parse(a),o,r==="path"),[e,a,o,r])}function vI(e,t){return yI(e,t)}function yI(e,t,r){ks()||tr(!1);let{navigator:n}=P.useContext(pi),{matches:o}=P.useContext(Ho),a=o[o.length-1],i=a?a.params:{};a&&a.pathname;let c=a?a.pathnameBase:"/";a&&a.route;let f=Ea(),g;if(t){var b;let x=typeof t=="string"?Ps(t):t;c==="/"||(b=x.pathname)!=null&&b.startsWith(c)||tr(!1),g=x}else g=f;let S=g.pathname||"/",O=c==="/"?S:S.slice(c.length)||"/",T=YM(e,{pathname:O}),C=CI(T&&T.map(x=>Object.assign({},x,{params:Object.assign({},i,x.params),pathname:wa([c,n.encodeLocation?n.encodeLocation(x.pathname).pathname:x.pathname]),pathnameBase:x.pathnameBase==="/"?c:wa([c,n.encodeLocation?n.encodeLocation(x.pathnameBase).pathname:x.pathnameBase])})),o,r);return t&&C?P.createElement(Tf.Provider,{value:{location:Rd({pathname:"/",search:"",hash:"",state:null,key:"default"},g),navigationType:fa.Pop}},C):C}function xI(){let e=kI(),t=hI(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),r=e instanceof Error?e.stack:null,o={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"},a=null;return P.createElement(P.Fragment,null,P.createElement("h2",null,"Unexpected Application Error!"),P.createElement("h3",{style:{fontStyle:"italic"}},t),r?P.createElement("pre",{style:o},r):null,a)}const wI=P.createElement(xI,null);class _I extends P.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,r){return r.location!==t.location||r.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error||r.error,location:r.location,revalidation:t.revalidation||r.revalidation}}componentDidCatch(t,r){console.error("React Router caught the following error during render",t,r)}render(){return this.state.error?P.createElement(Ho.Provider,{value:this.props.routeContext},P.createElement(W5.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function SI(e){let{routeContext:t,match:r,children:n}=e,o=P.useContext(Cf);return o&&o.static&&o.staticContext&&(r.route.errorElement||r.route.ErrorBoundary)&&(o.staticContext._deepestRenderedBoundaryId=r.route.id),P.createElement(Ho.Provider,{value:t},n)}function CI(e,t,r){var n;if(t===void 0&&(t=[]),r===void 0&&(r=null),e==null){var o;if((o=r)!=null&&o.errors)e=r.matches;else return null}let a=e,i=(n=r)==null?void 0:n.errors;if(i!=null){let c=a.findIndex(f=>f.route.id&&(i==null?void 0:i[f.route.id]));c>=0||tr(!1),a=a.slice(0,Math.min(a.length,c+1))}return a.reduceRight((c,f,g)=>{let b=f.route.id?i==null?void 0:i[f.route.id]:null,S=null;r&&(S=f.route.errorElement||wI);let O=t.concat(a.slice(0,g+1)),T=()=>{let C;return b?C=S:f.route.Component?C=P.createElement(f.route.Component,null):f.route.element?C=f.route.element:C=c,P.createElement(SI,{match:f,routeContext:{outlet:c,matches:O,isDataRoute:r!=null},children:C})};return r&&(f.route.ErrorBoundary||f.route.errorElement||g===0)?P.createElement(_I,{location:r.location,revalidation:r.revalidation,component:S,error:b,children:T(),routeContext:{outlet:null,matches:O,isDataRoute:!0}}):T()},null)}var q5=function(e){return e.UseBlocker="useBlocker",e.UseRevalidator="useRevalidator",e.UseNavigateStable=Line truncated
* React Router DOM v6.17.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function Id(){return Id=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(e[n]=r[n])}return e},Id.apply(this,arguments)}function K5(e,t){if(e==null)return{};var r={},n=Object.keys(e),o,a;for(a=0;a<n.length;a++)o=n[a],!(t.indexOf(o)>=0)&&(r[o]=e[o]);return r}function AI(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function RI(e,t){return e.button===0&&(!t||t==="_self")&&!AI(e)}const MI=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","unstable_viewTransition"],II=["aria-current","caseSensitive","className","end","style","to","unstable_viewTransition","children"],DI=P.createContext({isTransitioning:!1}),LI="startTransition",Dx=wg[LI];function FI(e){let{basename:t,children:r,future:n,window:o}=e,a=P.useRef();a.current==null&&(a.current=qM({window:o,v5Compat:!0}));let i=a.current,[c,f]=P.useState({action:i.action,location:i.location}),{v7_startTransition:g}=n||{},b=P.useCallback(S=>{g&&Dx?Dx(()=>f(S)):f(S)},[f,g]);return P.useLayoutEffect(()=>i.listen(b),[i,b]),P.createElement(jI,{basename:t,children:r,location:c.location,navigationType:c.action,navigator:i})}const zI=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",BI=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,Y5=P.forwardRef(function(t,r){let{onClick:n,relative:o,reloadDocument:a,replace:i,state:c,target:f,to:g,preventScrollReset:b,unstable_viewTransition:S}=t,O=K5(t,MI),{basename:T}=P.useContext(pi),C,x=!1;if(typeof g=="string"&&BI.test(g)&&(C=g,zI))try{let u=new URL(window.location.href),l=g.startsWith("//")?new URL(u.protocol+g):new URL(g),h=Jl(l.pathname,T);l.origin===u.origin&&h!=null?g=h+l.search+l.hash:x=!0}catch{}let m=mI(g,{relative:o}),d=WI(g,{replace:i,state:c,target:f,preventScrollReset:b,relative:o,unstable_viewTransition:S});function s(u){n&&n(u),u.defaultPrevented||d(u)}return P.createElement("a",Id({},O,{href:C||m,onClick:x||a?n:s,ref:r,target:f}))}),VI=P.forwardRef(function(t,r){let{"aria-current":n="page",caseSensitive:o=!1,className:a="",end:i=!1,style:c,to:f,unstable_viewTransition:g,children:b}=t,S=K5(t,II),O=Of(f,{relative:S.relative}),T=Ea(),C=P.useContext(U5),{navigator:x}=P.useContext(pi),m=C!=null&&$I(O)&&g===!0,d=x.encodeLocation?x.encodeLocation(O).pathname:O.pathname,s=T.pathname,u=C&&C.navigation&&C.navigation.location?C.navigation.location.pathname:null;o||(s=s.toLowerCase(),u=u?u.toLowerCase():null,d=d.toLowerCase());let l=s===d||!i&&s.startsWith(d)&&s.charAt(d.length)==="/",h=u!=null&&(u===d||!i&&u.startsWith(d)&&u.charAt(d.length)==="/"),v={isActive:l,isPending:h,isTransitioning:m},w=l?n:void 0,j;typeof a=="function"?j=a(v):j=[a,l?"active":null,h?"pending":null,m?"transitioning":null].filter(Boolean).join(" ");let N=typeof c=="function"?c(v):c;return P.createElement(Y5,Id({},S,{"aria-current":w,className:j,ref:r,style:N,to:f,unstable_viewTransition:g}),typeof b=="function"?b(v):b)});var V0;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(V0||(V0={}));var Lx;(function(e){e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(Lx||(Lx={}));function UI(e){let t=P.useContext(Cf);return t||tr(!1),t}function WI(e,t){let{target:r,replace:n,state:o,preventScrollReset:a,relative:i,unstable_viewTransition:c}=t===void 0?{}:t,f=So(),g=Ea(),b=Of(e,{relative:i});return P.useCallback(S=>{if(RI(S,r)){S.preventDefault();let O=n!==void 0?n:Ad(g)===Ad(b);f(e,{replace:O,state:o,preventScrollReset:a,relative:i,unstable_viewTransition:c})}},[g,f,b,n,o,r,e,a,i,c])}function $I(e,t){t===void 0&&(t={});let r=P.useContext(DI);r==null&&tr(!1);let{basename:n}=UI(V0.useViewTransitionState),o=Of(e,{relative:t.relative});if(!r.isTransitioning)return!1;let a=Jl(r.currentLocation.pathname,n)||r.currentLocation.pathname,i=Jl(r.nextLocation.pathname,n)||r.nextLocation.pathname;return z0(o.pathname,i)!=null||z0(o.pathname,a)!=null}function HI({title:e,titleId:t,...r},n){return P.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor","aria-hidden":"true",ref:n,"aria-labelledby":t},r),e?P.createElement("title",{id:t},e):null,P.createElement("path",{fillRule:"evenodd",d:"M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm5.03 4.72a.75.75 0 010 1.06l-1.72 1.72h10.94a.75.75 0 010 1.5H10.81l1.72 1.72a.75.75 0 11-1.06 1.06l-3-3a.75.75 0 010-1.06l3-3a.75.75 0 011.06 0z",clipRule:"evenodd"}))}const qI=P.forwardRef(HI),GI=qI;function KI({title:e,titleId:t,...r},n){return P.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor","aria-hidden":"true",rLine truncated
Copyright (c) 2018 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/(function(e){(function(){var t={}.hasOwnProperty;function r(){for(var n=[],o=0;o<arguments.length;o++){var a=arguments[o];if(a){var i=typeof a;if(i==="string"||i==="number")n.push(a);else if(Array.isArray(a)){if(a.length){var c=r.apply(null,a);c&&n.push(c)}}else if(i==="object"){if(a.toString!==Object.prototype.toString&&!a.toString.toString().includes("[native code]")){n.push(a.toString());continue}for(var f in a)t.call(a,f)&&a[f]&&n.push(f)}}}return n.join(" ")}e.exports?(r.default=r,e.exports=r):window.classNames=r})()})(Z5);var ct=Z5.exports,J5={exports:{}},Bn={};function Sm(){for(var e,t,r=0,n="";r<arguments.length;)(e=arguments[r++])&&(t=e1(e))&&(n&&(n+=" "),n+=t);return n}function e1(e){if(typeof e=="string")return e;for(var t,r="",n=0;n<e.length;n++)e[n]&&(t=e1(e[n]))&&(r&&(r+=" "),r+=t);return r}function U0(){return U0=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)Object.prototype.hasOwnProperty.call(r,n)&&(e[n]=r[n])}return e},U0.apply(this,arguments)}function S4(e){var t=function(r){var n=r.theme,o=r.prefix,a={nextPart:new Map,validators:[]};return function(i,c){return c?i.map(function(f){return[f[0],f[1].map(function(g){return typeof g=="string"?c+g:typeof g=="object"?Object.fromEntries(Object.entries(g).map(function(b){return[c+b[0],b[1]]})):g})]}):i}(Object.entries(r.classGroups),o).forEach(function(i){W0(i[1],a,i[0],n)}),a}(e);return{getClassGroupId:function(r){var n=r.split("-");return n[0]===""&&n.length!==1&&n.shift(),t1(n,t)||function(o){if(Fx.test(o)){var a=Fx.exec(o)[1],i=a==null?void 0:a.substring(0,a.indexOf(":"));if(i)return"arbitrary.."+i}}(r)},getConflictingClassGroupIds:function(r){return e.conflictingClassGroups[r]||[]}}}function t1(e,t){var r;if(e.length===0)return t.classGroupId;var n=t.nextPart.get(e[0]),o=n?t1(e.slice(1),n):void 0;if(o)return o;if(t.validators.length!==0){var a=e.join("-");return(r=t.validators.find(function(i){return(0,i.validator)(a)}))==null?void 0:r.classGroupId}}Object.defineProperty(Bn,"__esModule",{value:!0});var Fx=/^\[(.+)\]$/;function W0(e,t,r,n){e.forEach(function(o){if(typeof o!="string"){if(typeof o=="function")return o.isThemeGetter?void W0(o(n),t,r,n):void t.validators.push({validator:o,classGroupId:r});Object.entries(o).forEach(function(a){W0(a[1],zx(t,a[0]),r,n)})}else(o===""?t:zx(t,o)).classGroupId=r})}function zx(e,t){var r=e;return t.split("-").forEach(function(n){r.nextPart.has(n)||r.nextPart.set(n,{nextPart:new Map,validators:[]}),r=r.nextPart.get(n)}),r}function C4(e){if(e<1)return{get:function(){},set:function(){}};var t=0,r=new Map,n=new Map;function o(a,i){r.set(a,i),++t>e&&(t=0,n=r,r=new Map)}return{get:function(a){var i=r.get(a);return i!==void 0?i:(i=n.get(a))!==void 0?(o(a,i),i):void 0},set:function(a,i){r.has(a)?r.set(a,i):o(a,i)}}}function T4(e){var t=e.separator||":";return function(r){for(var n=0,o=[],a=0,i=0;i<r.length;i++){var c=r[i];n===0&&c===t[0]&&(t.length!==1&&r.slice(i,i+t.length)!==t||(o.push(r.slice(a,i)),a=i+t.length)),c==="["?n++:c==="]"&&n--}var f=o.length===0?r:r.substring(a),g=f.startsWith("!");return{modifiers:o,hasImportantModifier:g,baseClassName:g?f.substring(1):f}}}function O4(e){return U0({cache:C4(e.cacheSize),splitModifiers:T4(e)},S4(e))}var P4=/\s+/;function k4(e,t){var r=t.splitModifiers,n=t.getClassGroupId,o=t.getConflictingClassGroupIds,a=new Set;return e.trim().split(P4).map(function(i){var c=r(i),f=c.modifiers,g=c.hasImportantModifier,b=n(c.baseClassName);if(!b)return{isTailwindClass:!1,originalClassName:i};var S=function(O){if(O.length<=1)return O;var T=[],C=[];return O.forEach(function(x){x[0]==="["?(T.push.apply(T,C.sort().concat([x])),C=[]):C.push(x)}),T.push.apply(T,C.sort()),T}(f).join(":");return{isTailwindClass:!0,modifierId:g?S+"!":S,classGroupId:b,originalClassName:i}}).reverse().filter(function(i){if(!i.isTailwindClass)return!0;var c=i.modifierId,f=i.classGroupId,g=c+f;return!a.has(g)&&(a.add(g),o(f).forEach(function(b){return a.add(c+b)}),!0)}).reverse().map(function(i){return i.originalClassName}).join(" ")}function $0(){for(var e=arguments.length,t=new Array(e),r=0;r<e;r++)t[r]=arguments[r];var n,o,a,i=c;function c(g){var b=t[0],S=t.slice(1).reduce(function(O,T){return T(O)},b());return n=O4(S),o=n.cache.get,a=n.cache.set,i=f,f(g)}function f(g){var b=o(g);if(b)return b;var S=k4(g,n);return a(g,S),S}return function(){return i(Sm.apply(null,arguments))}}function Ht(e){var t=function(r){return r[e]||[]};return t.isThemeGetter=!0,t}var ja=/^\[(.+)\]$/,N4=/^\d+\/\d+$/,E4=new Set(["px","full","screen"]),j4=/^(\d+)?(xs|sm|md|lg|xl)$/,A4=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh)/,R4=/^-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/;function xn(e){return!Number.isNaN(Number(e))||E4.has(e)||N4.test(e)||sa(e)}function sa(e){var t,r=(t=ja.exec(e))==null?void 0:t[1];return!!r&&(r.startsWith("length:")||A4.test(r))}function r1(e){var t,r=(t=ja.exec(e))==null?void 0:t[1];return!!r&&r.startsWLine truncated
* tabbable 6.2.0
* @license MIT, https://github.com/focus-trap/tabbable/blob/master/LICENSE
*/var SF=["input:not([inert])","select:not([inert])","textarea:not([inert])","a[href]:not([inert])","button:not([inert])","[tabindex]:not(slot):not([inert])","audio[controls]:not([inert])","video[controls]:not([inert])",'[contenteditable]:not([contenteditable="false"]):not([inert])',"details>summary:first-of-type:not([inert])","details:not([inert])"],ag=SF.join(","),uP=typeof Element>"u",au=uP?function(){}:Element.prototype.matches||Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector,Hd=!uP&&Element.prototype.getRootNode?function(e){var t;return e==null||(t=e.getRootNode)===null||t===void 0?void 0:t.call(e)}:function(e){return e==null?void 0:e.ownerDocument},qd=function e(t,r){var n;r===void 0&&(r=!0);var o=t==null||(n=t.getAttribute)===null||n===void 0?void 0:n.call(t,"inert"),a=o===""||o==="true",i=a||r&&t&&e(t.parentNode);return i},CF=function(t){var r,n=t==null||(r=t.getAttribute)===null||r===void 0?void 0:r.call(t,"contenteditable");return n===""||n==="true"},TF=function(t,r,n){if(qd(t))return[];var o=Array.prototype.slice.apply(t.querySelectorAll(ag));return r&&au.call(t,ag)&&o.unshift(t),o=o.filter(n),o},OF=function e(t,r,n){for(var o=[],a=Array.from(t);a.length;){var i=a.shift();if(!qd(i,!1))if(i.tagName==="SLOT"){var c=i.assignedElements(),f=c.length?c:i.children,g=e(f,!0,n);n.flatten?o.push.apply(o,g):o.push({scopeParent:i,candidates:g})}else{var b=au.call(i,ag);b&&n.filter(i)&&(r||!t.includes(i))&&o.push(i);var S=i.shadowRoot||typeof n.getShadowRoot=="function"&&n.getShadowRoot(i),O=!qd(S,!1)&&(!n.shadowRootFilter||n.shadowRootFilter(i));if(S&&O){var T=e(S===!0?i.children:S.children,!0,n);n.flatten?o.push.apply(o,T):o.push({scopeParent:i,candidates:T})}else a.unshift.apply(a,i.children)}}return o},cP=function(t){return!isNaN(parseInt(t.getAttribute("tabindex"),10))},dP=function(t){if(!t)throw new Error("No node provided");return t.tabIndex<0&&(/^(AUDIO|VIDEO|DETAILS)$/.test(t.tagName)||CF(t))&&!cP(t)?0:t.tabIndex},PF=function(t,r){var n=dP(t);return n<0&&r&&!cP(t)?0:n},kF=function(t,r){return t.tabIndex===r.tabIndex?t.documentOrder-r.documentOrder:t.tabIndex-r.tabIndex},fP=function(t){return t.tagName==="INPUT"},NF=function(t){return fP(t)&&t.type==="hidden"},EF=function(t){var r=t.tagName==="DETAILS"&&Array.prototype.slice.apply(t.children).some(function(n){return n.tagName==="SUMMARY"});return r},jF=function(t,r){for(var n=0;n<t.length;n++)if(t[n].checked&&t[n].form===r)return t[n]},AF=function(t){if(!t.name)return!0;var r=t.form||Hd(t),n=function(c){return r.querySelectorAll('input[type="radio"][name="'+c+'"]')},o;if(typeof window<"u"&&typeof window.CSS<"u"&&typeof window.CSS.escape=="function")o=n(window.CSS.escape(t.name));else try{o=n(t.name)}catch(i){return console.error("Looks like you have a radio button with a name attribute containing invalid CSS selector characters and need the CSS.escape polyfill: %s",i.message),!1}var a=jF(o,t.form);return!a||a===t},RF=function(t){return fP(t)&&t.type==="radio"},MF=function(t){return RF(t)&&!AF(t)},IF=function(t){var r,n=t&&Hd(t),o=(r=n)===null||r===void 0?void 0:r.host,a=!1;if(n&&n!==t){var i,c,f;for(a=!!((i=o)!==null&&i!==void 0&&(c=i.ownerDocument)!==null&&c!==void 0&&c.contains(o)||t!=null&&(f=t.ownerDocument)!==null&&f!==void 0&&f.contains(t));!a&&o;){var g,b,S;n=Hd(o),o=(g=n)===null||g===void 0?void 0:g.host,a=!!((b=o)!==null&&b!==void 0&&(S=b.ownerDocument)!==null&&S!==void 0&&S.contains(o))}}return a},pw=function(t){var r=t.getBoundingClientRect(),n=r.width,o=r.height;return n===0&&o===0},DF=function(t,r){var n=r.displayCheck,o=r.getShadowRoot;if(getComputedStyle(t).visibility==="hidden")return!0;var a=au.call(t,"details>summary:first-of-type"),i=a?t.parentElement:t;if(au.call(i,"details:not([open]) *"))return!0;if(!n||n==="full"||n==="legacy-full"){if(typeof o=="function"){for(var c=t;t;){var f=t.parentElement,g=Hd(t);if(f&&!f.shadowRoot&&o(f)===!0)return pw(t);t.assignedSlot?t=t.assignedSlot:!f&&g!==t.ownerDocument?t=g.host:t=f}t=c}if(IF(t))return!t.getClientRects().length;if(n!=="legacy-full")return!0}else if(n==="non-zero-area")return pw(t);return!1},LF=function(t){if(/^(INPUT|BUTTON|SELECT|TEXTAREA)$/.test(t.tagName))for(var r=t.parentElement;r;){if(r.tagName==="FIELDSET"&&r.disabled){for(var n=0;n<r.children.length;n++){var o=r.children.item(n);if(o.tagName==="LEGEND")return au.call(r,"fieldset[disabled] *")?!0:!o.contains(t)}return!0}r=r.parentElement}return!1},FF=function(t,r){return!(r.disabled||qd(r)||NF(r)||DF(r,t)||EF(r)||LF(r))},hw=function(t,r){return!(MF(r)||dP(r)<0||!FF(t,r))},zF=function(t){var r=parseInt(t.getAttribute("tabindex"),10);return!!(isNaN(r)||r>=0)},BF=function e(t){var r=[],n=[];return t.forEach(function(o,a){var i=!!o.scopeParent,c=i?o.scopeParent:o,f=PF(c,i),g=i?e(o.candidates):c;f===0?i?r.push.apply(r,g):r.push(c):n.push({documentOrder:a,tabIndex:f,item:o,isScope:i,content:g})}),n.sort(kF).reduce(function(o,a){return a.isScope?o.push.apply(o,a.content):o.push(a.content),o},[]).concatLine truncated
More details: https://www.material-tailwind.com/docs/react/collapse
`);var N=r.default.useRef(null),k=(0,g.useTheme)().navbar,A=k.styles,M=A.base.mobileNav;l=l??{},h=h??"";var D=(0,c.twMerge)((0,i.default)((0,f.default)(M)),h),L={unmount:{height:0,opacity:0,transition:{duration:.3,times:"[0.4, 0, 0.2, 1]"}},mount:{opacity:1,height:"".concat((j=N.current)===null||j===void 0?void 0:j.scrollHeight,"px"),transition:{duration:.3,times:"[0.4, 0, 0.2, 1]"}}},B=(0,a.default)(L,l),U=n.AnimatePresence,Y=(0,o.useMergeRefs)([s,N]);return r.default.createElement(n.LazyMotion,{features:n.domAnimation},r.default.createElement(U,null,r.default.createElement(n.m.div,S({},w,{ref:Y,className:D,initial:"unmount",exit:"unmount",animate:u?"mount":"unmount",variants:B}),v)))});x.displayName="MaterialTailwind.MobileNav",x.propTypes={open:b.propTypesOpen,animate:b.propTypesAnimate,className:b.propTypesClassName,children:b.propTypesChildren};var m=x})(tk);(function(e){Object.defineProperty(e,"__esModule",{value:!0});function t(s,u){for(var l in u)Object.defineProperty(s,l,{enumerable:!0,get:u[l]})}t(e,{Navbar:function(){return m},MobileNav:function(){return g.MobileNav},default:function(){return d}});var r=T(P),n=T(nt),o=T(ct),a=rt,i=T(Xt),c=T(et),f=Ze,g=tk,b=Pf;function S(s,u,l){return u in s?Object.defineProperty(s,u,{value:l,enumerable:!0,configurable:!0,writable:!0}):s[u]=l,s}function O(){return O=Object.assign||function(s){for(var u=1;u<arguments.length;u++){var l=arguments[u];for(var h in l)Object.prototype.hasOwnProperty.call(l,h)&&(s[h]=l[h])}return s},O.apply(this,arguments)}function T(s){return s&&s.__esModule?s:{default:s}}function C(s,u){if(s==null)return{};var l=x(s,u),h,v;if(Object.getOwnPropertySymbols){var w=Object.getOwnPropertySymbols(s);for(v=0;v<w.length;v++)h=w[v],!(u.indexOf(h)>=0)&&Object.prototype.propertyIsEnumerable.call(s,h)&&(l[h]=s[h])}return l}function x(s,u){if(s==null)return{};var l={},h=Object.keys(s),v,w;for(w=0;w<h.length;w++)v=h[w],!(u.indexOf(v)>=0)&&(l[v]=s[v]);return l}var m=r.default.forwardRef(function(s,u){var l=s.variant,h=s.color,v=s.shadow,w=s.blurred,j=s.fullWidth,N=s.className,k=s.children,A=C(s,["variant","color","shadow","blurred","fullWidth","className","children"]),M=(0,f.useTheme)().navbar,D=M.defaultProps,L=M.valid,B=M.styles,U=B.base,Y=B.variants;l=l??D.variant,h=h??D.color,v=v??D.shadow,w=w??D.blurred,j=j??D.fullWidth,N=N??D.className;var q,z=(0,o.default)((0,c.default)(U.navbar.initial),(q={},S(q,(0,c.default)(U.navbar.shadow),v),S(q,(0,c.default)(U.navbar.blurred),w&&h==="white"),S(q,(0,c.default)(U.navbar.fullWidth),j),q)),$=(0,o.default)((0,c.default)(Y[(0,i.default)(L.variants,l,"filled")][(0,i.default)(L.colors,h,"white")])),K=(0,a.twMerge)((0,o.default)(z,$),N);return r.default.createElement("nav",O({},A,{ref:u,className:K}),k)});m.propTypes={variant:n.default.oneOf(b.propTypesVariant),color:n.default.oneOf(b.propTypesColor),shadow:b.propTypesShadow,blurred:b.propTypesBlurred,fullWidth:b.propTypesFullWidth,className:b.propTypesClassName,children:b.propTypesChildren},m.displayName="MaterialTailwind.Navbar";var d=Object.assign(m,{MobileNav:g.MobileNav})})(ek);var rk={},Kf={},Ds={};(function(e){Object.defineProperty(e,"__esModule",{value:!0});function t(m,d){for(var s in d)Object.defineProperty(m,s,{enumerable:!0,get:d[s]})}t(e,{propTypesOpen:function(){return a},propTypesHandler:function(){return i},propTypesPlacement:function(){return c},propTypesOffset:function(){return f},propTypesDismiss:function(){return g},propTypesAnimate:function(){return b},propTypesContent:function(){return S},propTypesInteractive:function(){return O},propTypesClassName:function(){return T},propTypesChildren:function(){return C},propTypesContextValue:function(){return x}});var r=o(nt),n=Gt;function o(m){return m&&m.__esModule?m:{default:m}}var a=r.default.bool,i=r.default.func,c=n.propTypesPlacements,f=n.propTypesOffsetType,g=n.propTypesDismissType,b=n.propTypesAnimation,S=r.default.node,O=r.default.bool,T=r.default.string,C=r.default.node.isRequired,x=r.default.shape({open:r.default.bool.isRequired,strategy:r.default.oneOf(["fixed","absolute"]).isRequired,x:r.default.number,y:r.default.number,context:r.default.instanceOf(Object).isRequired,reference:r.default.func.isRequired,floating:r.default.func.isRequired,getReferenceProps:r.default.func.isRequired,getFloatingProps:r.default.func.isRequired,appliedAnimation:b.isRequired,labelId:r.default.string.isRequired,descriptionId:r.default.string.isRequired}).isRequired})(Ds);(function(e){Object.defineProperty(e,"__esModule",{value:!0});function t(f,g){for(var b in g)Object.defineProperty(f,b,{enumerable:!0,get:g[b]})}t(e,{PopoverContext:function(){return a},usePopover:function(){return i},PopoverContextProvider:function(){return c}});var r=o(P),n=Ds;function o(f){return f&&f.__esModule?f:{default:f}}var a=r.default.createContext(null);a.displayName="MaterialTailwind.PopoverContext";function i(){var f=r.default.useContext(a);if(!f)throw new Error("usePopover() must be used within a Popover. It happens when you us Line truncated
${ie?"border-purple-500 ring-1 ring-purple-500/40":"border-transparent"}
${!Ce||se?"opacity-75 bg-stormGrey-200/20":""}`,children:[y.jsxs("div",{className:"flex items-center space-x-4",id:"left",children:[N&&y.jsx("input",{type:"checkbox",checked:ie,onChange:()=>je(ae.Id,!Ue),className:`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${Ue?"":"opacity-40 cursor-not-allowed"}`,disabled:!Ue}),y.jsx("div",{className:`w-12 h-12 flex items-center justify-center rounded-full
${ae.updateStatus==="✅"?"bg-mossGreen-600":ae.updateStatus==="⚠️"?"bg-warmAmberGlow-400":"bg-sunsetCoral-500"}`}),y.jsxs("div",{children:[y.jsx("p",{className:"text-lg antialiased font-sans font-semibold leading-normal text-inherit",children:ae.Name}),y.jsxs("p",{className:"text-sm antialiased font-sans font-light leading-normal text-inherit",children:["ID: ",ae.Id]}),ae.duplicateName&&y.jsx("p",{className:"text-xs text-amber-700",children:"⚠️ Doppelter Name erkannt"})]})]}),y.jsx("div",{className:"ml-auto flex flex-col items-end gap-1 text-sm",id:"right",children:ve?y.jsxs(y.Fragment,{children:[y.jsx("span",{className:"antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-warmAmberGlow-600",children:"Redeploy"}),y.jsx("span",{className:"text-warmAmberGlow-500",children:"läuft…"})]}):oe?y.jsxs(y.Fragment,{children:[y.jsx("span",{className:"antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-warmAmberGlow-600",children:"Redeploy"}),y.jsx("span",{className:"text-warmAmberGlow-500",children:"in Warteliste…"})]}):Oe?y.jsxs(y.Fragment,{children:[y.jsx("span",{className:"antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-stormGrey-500",children:"System"}),y.jsx("span",{className:"text-gray-300",children:"Redeploy deaktiviert"})]}):he?y.jsxs(y.Fragment,{children:[y.jsx("span",{className:"antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-stormGrey-500",children:"Status"}),y.jsx("span",{className:"text-mossGreen-600",children:"Aktuell"})]}):j?y.jsx(H.Button,{onClick:()=>Ge(ae.Id),disabled:L||se,className:"disabled:opacity-50 disabled:cursor-not-allowed",children:"Redeploy"}):null})]})},ae.Id)})}),y.jsx(Jf,{disabled:r||!!o})]})}const tV={idle:"Bereit",running:"Läuft",success:"Erfolgreich",error:"Fehlgeschlagen"},dl={idle:"bg-stormGrey-700 text-gray-200",running:"bg-arcticBlue-600 text-white",success:"bg-mossGreen-600 text-white",error:"bg-sunsetCoral-600 text-white"},rV={initializing:"Vorbereitung","activating-maintenance":"Wartungsmodus aktivieren","executing-script":"Skript wird ausgeführt",waiting:"Warte auf Portainer",completed:"Abgeschlossen",failed:"Fehlgeschlagen"},nV={info:"text-arcticBlue-700",success:"text-mossGreen-700",warning:"text-warmAmberGlow-700",error:"text-sunsetCoral-700",stdout:"text-stormGrey-700",stderr:"text-warmAmberGlow-700",debug:"text-lavenderSmoke-700"},oV=e=>{if(!e)return"-";const t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString("de-DE",{hour:"2-digit",minute:"2-digit",second:"2-digit"})},Ua=e=>{if(!e&&e!==0)return"-";const r=(n=>{if(n instanceof Date)return n;if(typeof n=="number"){const o=n>1e12?n:n*1e3;return new Date(o)}if(typeof n=="string"){const o=Number(n);if(!Number.isNaN(o)){const a=o>1e12?o:o*1e3;return new Date(a)}return new Date(n)}return null})(e);return!r||Number.isNaN(r.getTime())?String(e):r.toLocaleString("de-DE",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})},Yw=e=>e===1?"Git":e===2?"Compose":e??"-",Mh=()=>({host:"",port:"22",username:"",password:"",extraSshArgs:""});function aV(){var Ws,Vu,Uu,$s;const{hasPermission:e,user:t}=Ur(),r=P.useCallback(()=>{},[]),{maintenance:n,update:o,script:a,ssh:i,loading:c,error:f,setMaintenanceMode:g,triggerUpdate:b,saveScript:S,resetScript:O,saveSshConfig:T,deleteSshConfig:C,testSshConnection:x,fetchSuperuserStatus:m,fetchSetupStatus:d,deleteSetupServer:s,updateSetupApiKey:u,updateSelfStackId:l,removeSuperuserAccount:h}=Aa(),v=!!(t!=null&&t.isSuperuser),w=!!(v||e("maintenance-access","read")),j=!!(v||e("maintenance-access","full")),N=!!(v||e("maintenance-server-manage","read")),k=!!(v||e("maintenance-server-manage","full")),A=!!(v||e("maintenance-server-delete","full")),M=!!(v||e("maintenance-superuser-delete","full")),D=!!(v||e("maintenance-portainer","read")),L=!!(v||e("maintenance-update","full")),B=!!(v||e("maintenance-ssh-update","read")),U=!!(v||e("maintenance-ssh-update","full")),Y=!!(v||e("maintenance-duplicates","read")),q=!!(v||e("maintenance-duplicates","full")),z=!!(n!=null&&n.active),$=n==null?void 0:n.message,K=(Ws=n==null?void 0:n.extra)==null?void 0:Ws.type,X=!!(o!=null&&o.running),te=z||X,[me,de]=P.useState(""),[ee,Se]=P.useState(!1),[pe,fe]=P.useState(!1),[be,ye]=P.useState(""),[Pe,Me]=P.useState(!1),[ke,ce]=P.useState(()=>Mh()),[je,He]=P.useState(!1),[qe,Je]=P.useState(!1),[Qe,Ge]=P.useState(!1),[Xe,ht]=P.useState(!1),[Ve,ot]=P.useState(!1),[_t,Ne]=P.useState(null);P.useEffect(()=>{if(!a)return;const xe=a.custom??a.default??a.effective??"";de(xe)},[a]),P.useEffect(()=>{if(!i){ce(Mh()),He(!1),Je(!1),Ne(null);return}ce({host:i.host??"",port:String(i.port??"22"),username:i.username??"",password:"",extraSshArgs:Array.isArray(i.extraSshArgs)?i.extraSshArgs.join(`
`):""}),He(!!i.passwordStored),Je(!1),Ne(null)},[i]);const at=P.useMemo(()=>a?a.source==="custom"&&typeof a.custom=="string"?a.custom:a.default??a.effective??"":"",[a]),Te=a?me!==at:!1,ae=(a==null?void 0:a.source)==="custom"?"Benutzerdefiniert":"Standard",[ge,oe]=P.useState(!0),[ve,se]=P.useState(!1),[ie,he]=P.useState(null),[Oe,Ce]=P.useState(""),[Ue,Fe]=P.useState(!1),[Ie,Ee]=P.useState(null),[dt,ft]=P.useState(!0),[Ft,Kt]=P.useState(""),[Pt,Rt]=P.useState({}),[hr,ir]=P.useState(null),[Jr,jn]=P.useState(null),[Mr,en]=P.useState(""),[wr,no]=P.useState(!1),[An,J]=P.useState(""),[Ae,Be]=P.useState([]),[Mt,yt]=P.useState(!0),[Et,It]=P.useState(""),[or,kt]=P.useState(!1),[Rn,Ir]=P.useState(null),[gr,Mn]=P.useState(null),oo=P.useRef(null),[Ct,Ra]=P.useState(null),[To,ao]=P.useState(!0),[io,Oo]=P.useState(""),[Ma,xi]=P.useState(!1),[wi,Ia]=P.useState(null),mn=P.useRef(null),jt=P.useCallback(async({silent:xe=!1}={})=>{var Le,ze;oe(!0),Ce("");try{const We=await m(),it=!!(We!=null&&We.exists);se(it),he(it?We.user??null:null)}catch(We){const it=((ze=(Le=We.response)==null?void 0:Le.data)==null?void 0:ze.error)||We.message||"Superuser-Status konnte nicht geladen werden";se(!1),he(null),Ce(it),xe||r({variant:"error",title:"Statusabfrage fehlgeschlagen",description:it})}finally{oe(!1)}},[m,r]),Nt=P.useCallback(async({silent:xe=!1}={})=>{var Le,ze;xe||ft(!0),Kt("");try{const We=await d();Ee(We),Rt({})}catch(We){const it=((ze=(Le=We.response)==null?void 0:Le.data)==null?void 0:ze.error)||We.message||"Setup-Daten konnten nicht geladen werden";Kt(it),xe||r({variant:"error",title:"Setup-Daten",description:it})}finally{ft(!1)}},[d,r]),zt=P.useCallback(async({silent:xe=!1}={})=>{if(mn.current)return mn.current;const Le=(async()=>{var ze,We;xe?xi(!0):ao(!0),Oo("");try{const Wt=(await lt.get("/api/maintenance/portainer-status")).data??{};Ra(Wt),Ia(new Date)}catch(it){const Wt=((We=(ze=it.response)==null?void 0:ze.data)==null?void 0:We.error)||it.message||"Fehler beim Prüfen des Portainer-Status";Oo(Wt),r({variant:"error",title:"Statusaktualisierung fehlgeschlagen",description:Wt})}finally{xe?xi(!1):ao(!1),mn.current=null}})();return mn.current=Le,Le},[r]),Or=P.useCallback(async({silent:xe=!1}={})=>{if(z||X){Be([]),It("Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert."),yt(!1),kt(!1);return}if(oo.current)return oo.current;const Le=(async()=>{var ze,We,it;xe?kt(!0):yt(!0),It("");try{const Vt=(await lt.get("/api/maintenance/duplicates")).data,rn=Array.isArray(Vt)?Vt:(Vt==null?void 0:Vt.items)??[];Be(rn),Mn(new Date)}catch(Wt){if(((ze=Wt.response)==null?void 0:ze.status)===423){const Vt="Wartungsmodus aktiv – Duplikat-Verwaltung ist vorübergehend deaktiviert.";Be([]),It(Vt),r({variant:"warning",title:"Duplikat-Verwaltung gesperrt",description:Vt})}else{const Vt=((it=(We=Wt.response)==null?void 0:We.data)==null?void 0:it.error)||Wt.message||"Fehler beim Laden der Wartungsdaten";It(Vt),r({variant:"error",title:"Duplikate konnten nicht geladen werden",description:Vt})}}finally{xe?kt(!1):yt(!1),oo.current=null}})();return oo.current=Le,Le},[z,X,r]);P.useEffect(()=>{jt({silent:!0})},[jt]),P.useEffect(()=>{Nt()},[Nt]),P.useEffect(()=>{zt()},[zt]),P.useEffect(()=>{Or()},[Or]);const tn=P.useMemo(()=>{var xe;return((xe=Ie==null?void 0:Ie.servers)==null?void 0:xe.items)??[]},[Ie]),so=P.useMemo(()=>{var Le;const xe=((Le=Ie==null?void 0:Ie.apiKeys)==null?void 0:Le.items)??[];return new Map(xe.map(ze=>[ze.serverId,ze]))},[Ie]),Da=P.useMemo(()=>!!(Ie!=null&&Ie.setupComplete),[Ie]),In=(Ie==null?void 0:Ie.selfStack)??null,Dn=(In==null?void 0:In.current)??"",_i=((Vu=Ie==null?void 0:Ie.envDefaults)==null?void 0:Vu.selfStackId)??"";P.useEffect(()=>{en(Dn||""),J("")},[Dn]);const Bs=Mr!==Dn,Si=P.useMemo(()=>{const xe=Array.isArray(Ae)?Ae.length:0,Le=Array.isArray(Ae)?Ae.reduce((ze,We)=>{var it;return ze+(((it=We==null?void 0:We.duplicates)==null?void 0:it.length)||0)},0):0;return{groups:xe,duplicateCount:Le}},[Ae]),La=P.useCallback((xe,Le)=>{U&&(ce(ze=>({...ze,[xe]:Le})),xe==="password"&&He(!1))},[U]),Ci=P.useMemo(()=>{const xe={host:ke.host.trim(),port:Number.parseInt(ke.port,10)||22,username:ke.username.trim(),extraSshArgs:(ke.extraSshArgs||"").split(/\r?\n/).map(ze=>ze.trim()).filter(Boolean)},Le=ke.password??"";return Le?xe.password=Le:je||(xe.password=""),xe},[ke,je]),ep=P.useCallback(async()=>{var xe,Le;if(U&&a)try{Se(!0),await S(me),r({variant:"success",title:"Skript gespeichert",description:"Das benutzerdefinierte Portainer-Update-Skript wurde aktualisiert."})}catch(ze){const We=((Le=(xe=ze.response)==null?void 0:xe.data)==null?void 0:Le.error)||ze.message||"Skript konnte nicht gespeichert werden";r({variant:"error",title:"Speichern fehlgeschlagen",description:We})}finally{Se(!1)}},[U,S,me,a,r]),Eu=P.useCallback(async()=>{var xe,Le;if(U)try{Se(!0),await O(),r({variant:"info",title:"Standardskript wiederhergestellt",description:"Es wird wieder das Standard-Update-Skript verwendet."})}catch(ze){const We=((Le=(xe=ze. Line truncated
Dies setzt die Superuser-Einrichtung zurück.`)))try{Fe(!0);const We=await h(),it=Number((We==null?void 0:We.usersRemoved)??0);r({variant:"success",title:"Superuser gelöscht",description:it>1?`${it} Konten aus der Superuser-Gruppe wurden entfernt.`:"Superuser-Konto wurde entfernt."}),await jt({silent:!0})}catch(We){if(((xe=We.response)==null?void 0:xe.status)===404)await jt({silent:!0}),r({variant:"info",title:"Kein Superuser vorhanden",description:"Es ist kein Superuser mehr vorhanden."});else{const Wt=((ze=(Le=We.response)==null?void 0:Le.data)==null?void 0:ze.error)||We.message||"Superuser konnte nicht gelöscht werden";r({variant:"error",title:"Löschen fehlgeschlagen",description:Wt})}}finally{Fe(!1)}},[M,Ue,ge,ve,h,jt,r]),Ru=P.useCallback(async xe=>{var it,Wt;if(!A||Jr===xe)return;const Le=tn.find(Vt=>Vt.id===xe);if(!Le)return;const ze=Le.name||Le.url||`Server ${xe}`,We=[`Server "${ze}" wirklich löschen?`,"Das System benötigt anschließend erneut einen gültigen Server im Setup."].filter(Boolean).join(`
`);if(!(typeof window<"u"&&!window.confirm(We))){jn(xe);try{await s(xe),r({variant:"success",title:"Server gelöscht",description:`Server "${ze}" wurde entfernt.`}),await Nt({silent:!0})}catch(Vt){const rn=((Wt=(it=Vt.response)==null?void 0:it.data)==null?void 0:Wt.error)||Vt.message||"Server konnte nicht gelöscht werden.";r({variant:"error",title:"Server löschen fehlgeschlagen",description:rn})}finally{jn(null)}}},[A,s,Nt,Jr,tn,r]),Mu=P.useCallback((xe,Le)=>{k&&Rt(ze=>({...ze,[xe]:Le}))},[k]),Vs=P.useCallback(async xe=>{var ze,We;if(!k)return;const Le=(Pt[xe]??"").trim();if(!Le){r({variant:"warning",title:"API-Key fehlt",description:"Bitte gib einen API-Key ein."});return}if(hr!==xe){ir(xe);try{await u(xe,Le),r({variant:"success",title:"API-Key aktualisiert",description:"Der API-Key wurde gespeichert."}),Rt(it=>({...it,[xe]:""})),await Nt({silent:!0})}catch(it){const Wt=((We=(ze=it.response)==null?void 0:ze.data)==null?void 0:We.error)||it.message||"API-Key konnte nicht aktualisiert werden.";r({variant:"error",title:"Aktualisierung fehlgeschlagen",description:Wt})}finally{ir(null)}}},[Pt,hr,k,Nt,r,u]),Iu=P.useCallback(xe=>{k&&(en(xe),J(""))},[k]),tp=P.useCallback(async()=>{var Le,ze;if(!k||!Bs)return;no(!0),J("");const xe=typeof Mr=="string"?Mr.trim():"";try{await l(xe),r({variant:"success",title:"Self-Stack-ID gespeichert",description:xe?`Self-Stack-ID wurde auf "${xe}" gesetzt.`:"Self-Stack-ID wurde entfernt."}),await Nt({silent:!0})}catch(We){const it=((ze=(Le=We.response)==null?void 0:Le.data)==null?void 0:ze.error)||We.message||"Self-Stack-ID konnte nicht aktualisiert werden.";J(it),r({variant:"error",title:"Self-Stack-ID",description:it})}finally{no(!1)}},[k,Nt,Bs,Mr,r,l]),Du=P.useCallback(async()=>{var xe,Le;if(k&&!(!Mr&&!Dn)&&!(typeof window<"u"&&!window.confirm("Self-Stack-ID wirklich entfernen?"))){no(!0),J("");try{await l(""),en(""),r({variant:"success",title:"Self-Stack-ID entfernt",description:"Die Self-Stack-ID wurde gelöscht."}),await Nt({silent:!0})}catch(ze){const We=((Le=(xe=ze.response)==null?void 0:xe.data)==null?void 0:Le.error)||ze.message||"Self-Stack-ID konnte nicht entfernt werden.";J(We),r({variant:"error",title:"Self-Stack-ID",description:We})}finally{no(!1)}}},[k,Dn,Nt,Mr,r,l]),Oi=P.useCallback(async xe=>{var Le,ze;if(j&&!(c||Pe)&&xe!==z){if(X){r({variant:"warning",title:"Aktion nicht möglich",description:"Während eines laufenden Updates kann der Wartungsmodus nicht geändert werden."});return}Me(!0);try{const We=xe&&!!K,it={active:xe};We&&$?it.message=$:it.message=null,await g(it),r({variant:xe?"info":"success",title:xe?"Wartungsmodus aktiviert":"Wartungsmodus deaktiviert",description:xe?"Benutzer sehen jetzt die Wartungsseite.":"StackPulse steht wieder zur Verfügung."})}catch(We){const it=((ze=(Le=We.response)==null?void 0:Le.data)==null?void 0:ze.error)||We.message||"Wartungsmodus konnte nicht geändert werden";r({variant:"error",title:"Änderung fehlgeschlagen",description:it})}finally{Me(!1)}}},[j,c,Pe,z,X,g,$,K,r]),rp=P.useCallback(async()=>{var Le,ze;if(!L)return;if(z||X){r({variant:"warning",title:"Update nicht möglich",description:"Während eines aktiven Wartungsmodus kann kein weiteres Update gestartet werden."});return}const xe=(Ct==null?void 0:Ct.latestVersion)??(Ct==null?void 0:Ct.currentVersion)??"unbekannt";if(!(typeof window<"u"&&!window.confirm(`Portainer-Update starten?
Zielversion: ${xe}.
Während des Updates befindet sich StackPulse im Wartungsmodus.
Fortfahren?`)))try{ye(""),fe(!0),await b(),r({variant:"info",title:"Update gestartet",description:"Das Portainer-Update wurde gestartet."})}catch(We){const it=((ze=(Le=We.response)==null?void 0:Le.data)==null?void 0:ze.error)||We.message||"Portainer-Update konnte nicht gestartet werden";ye(it),r({variant:"error",title:"Update fehlgeschlagen",description:it})}finally{fe(!1)}},[L,z,X,Ct,b,r]),Lu=P.useCallback(async xe=>{var it,Wt,Vt,rn;if(!q||!xe)return;if(z||X){r({variant:"warning",title:"Aktion nicht möglich",description:"Während des Wartungsmodus sind Bereinigungen deaktiviert."});return}const Le=(it=xe.canonical)==null?void 0:it.Id;if(!Le)return;const ze=(xe.duplicates||[]).map(ko=>ko.Id).filter(Boolean);if(!ze.length)return;const We=((Wt=xe.canonical)==null?void 0:Wt.Name)||xe.name||`Stack ${Le}`;if(!(typeof window<"u"&&!window.confirm(`Bereinigung für "${We}" starten?
Es werden ${ze.length} Duplikate entfernt: ${ze.join(", ")}`))){Ir(String(Le));try{const Wr=(await lt.post("/api/maintenance/duplicates/cleanup",{canonicalId:Le,duplicateIds:ze})).data??{};if(Wr.success===!1)throw new Error(Wr.error||"Bereinigung fehlgeschlagen");const Pi=Array.isArray(Wr.results)?Wr.results.filter(za=>za.status==="deleted").map(za=>za.id):ze;r({variant:"success",title:"Bereinigung abgeschlossen",description:`${We} – entfernte IDs: ${Pi.join(", ")}`}),await Or({silent:!0})}catch(ko){const Wr=((rn=(Vt=ko.response)==null?void 0:Vt.data)==null?void 0:rn.error)||ko.message||"Bereinigung fehlgeschlagen";r({variant:"error",title:"Bereinigung fehlgeschlagen",description:Wr})}finally{Ir(null)}}},[q,Or,z,r,X]);n!=null&&n.activatedAt&&Ua(n.activatedAt),n!=null&&n.updatedAt&&Ua(n.updatedAt);const Ln=z||X,Fa=(o==null?void 0:o.status)??(X?"running":"idle"),Us=tV[Fa]??Fa,np=dl[Fa]??dl.idle,Fu=o!=null&&o.stage?rV[o.stage]??o.stage:"–",zu=(o==null?void 0:o.logs)??[],op=(o==null?void 0:o.targetVersion)??"-",ap=(o==null?void 0:o.resultVersion)??"-",ip=o!=null&&o.startedAt?Ua(o.startedAt):"-",sp=o!=null&&o.finishedAt?Ua(o.finishedAt):"-",Bu=(o==null?void 0:o.message)??(X?"Update läuft…":""),lo=X||ee||c;return w?y.jsxs("div",{className:"mt-12 mb-8 flex flex-col gap-12",children:[(z||X)&&y.jsx("div",{className:"rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100",children:y.jsxs("div",{className:"flex flex-col gap-1",children:[y.jsxs("span",{children:["Wartungsmodus aktiv",$?` – ${$}`:X?" – Portainer-Update läuft":"","."]}),X&&y.jsxs("span",{className:"text-xs text-indigo-900",children:["Phase: ",Fu]})]})}),w&&y.jsxs(H.Card,{children:[y.jsx(H.CardHeader,{variant:"gradient",color:"gray",className:"mb-5 p-4",children:y.jsx(H.Typography,{variant:"h6",color:"white",className:"flex items-center justify-between",children:y.jsx("span",{children:"Wartungsmodus"})})}),y.jsxs(H.CardBody,{className:"flex flex-col gap-4 p-4",children:[y.jsxs("div",{className:"flex items-start justify-between gap-4",children:[y.jsx("div",{className:"space-y-1",children:y.jsx("p",{className:"text-sm antialiased font-sans text-sm font-light leading-normal text-inherit",children:"Schaltet StackPulse für Benutzer in den Wartungsmodus."})}),y.jsx(H.Switch,{checked:z,disabled:!j||Pe||c||X,onChange:xe=>Oi(xe.target.checked),ripple:!1,color:"amber"})]}),Pe&&y.jsx("p",{className:"text-xs text-stormGrey-500",children:"Wartungsmodus wird aktualisiert…"})]})]}),N&&y.jsxs(H.Card,{children:[y.jsx(H.CardHeader,{variant:"gradient",color:"gray",className:"mb-5 p-4",children:y.jsx(H.Typography,{variant:"h6",color:"white",className:"flex items-center justify-between",children:y.jsx("span",{children:"Server & API-Keys"})})}),y.jsxs(H.CardBody,{className:"flex flex-col gap-4 p-4",children:[dt&&y.jsx("p",{className:"text-sm text-stormGrey-500",children:"Daten werden geladen…"}),!dt&&Ft&&y.jsx(H.Alert,{color:"red",className:"border border-red-200 bg-red-50 text-red-700",children:Ft}),!dt&&!Ft&&y.jsxs("div",{className:"flex flex-col gap-6",children:[y.jsxs("div",{className:"flex flex-col gap-3",children:[y.jsxs("div",{className:"flex items-center justify-between",children:[y.jsx(H.Typography,{variant:"small",color:"blue-gray",className:"font-semibold uppercase",children:"Server"}),y.jsxs(H.Typography,{variant:"small",color:"blue-gray",children:[tn.length," vorhanden"]})]}),tn.length===0?y.jsx("p",{className:"text-sm text-stormGrey-500",children:"Es sind derzeit keine Server hinterlegt."}):y.jsx("div",{className:"flex flex-col gap-3",children:tn.map(xe=>{const Le=so.get(xe.id)||null,ze=!!(Le!=null&&Le.hasKey),We=Le!=null&&Le.updatedAt?new Date(Le.updatedAt).toLocaleString("de-DE",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit"}):null;return y.jsxs("div",{className:"flex flex-col gap-3 rounded-md border border-blue-gray-100 p-3 md:flex-row md:items-start md:justify-between",children:[y.jsxs("div",{className:"flex flex-col gap-1",children:[y.jsx("p",{className:"text-sm font-medium text-stormGrey-900",children:xe.name||"Ohne Namen"}),y.jsx("p",{className:"text-xs text-stormGrey-500 break-all",children:xe.url}),y.jsxs("p",{className:"text-xs text-stormGrey-500",children:["API-Key: ",ze?"vorhanden":"nicht hinterlegt",We?` (aktualisiert am ${We})`:""]})]}),y.jsxs("div",{className:"flex w-full flex-col gap-2 md:max-w-xs",children:[y.jsx(H.Input,{type:"password",label:"Neuer API-Key",value:Pt[xe.id]??"",onChange:it=>Mu(xe.id,it.target.value),disabled:!k||dt||hr===xe.id}),y.jsxs("div",{className:"flex items-center gap-2 justify-end",children:[y.jsx(H.Button,{variant:"text",color:"red",size:"sm",onClick:()=>Ru(xe.id),disabled:te||!A||dt||Jr===xe.id||hr===xe.id,children:Jr===xe.id?"Lösche…":"Löschen"}),y.jsx(H.Button,{color:"blue",size:"sm",onClick:()=>Vs(xe.id),disabled:te||!k||dt||hr===xe.id,children:hr===xe.id?"Speichere…":"API-Key speichern"})]})]})]},xe.id)})})]}),y.jsxs("div",{clas Line truncated
`)},kV=(e=[])=>{if(!Array.isArray(e))return[];const t=[];for(let r=0;r<e.length;r+=4)t.push(e.slice(r,r+4));return t};function NV(){const e=So(),{user:t,setSession:r,refreshSession:n}=Ur(),{maintenance:o,update:a}=Aa(),[i,c]=P.useState(!0),[f,g]=P.useState(""),[b,S]=P.useState([]),[O,T]=P.useState(!1),[C,x]=P.useState(!1),m=!!(t!=null&&t.requiresSecurityPhraseDownload),d=!!(o!=null&&o.active);o==null||o.message;const s=!!(a!=null&&a.running);a!=null&&a.stage&&(OV[a.stage]??a.stage);const u=d||s,l=P.useMemo(()=>kV(b),[b]),h=P.useCallback(N=>{if(!N)return;const k=new Blob([N],{type:"text/plain;charset=utf-8"}),A=URL.createObjectURL(k),M=document.createElement("a");M.href=A;const D=t!=null&&t.username?`${t.username}-sicherheitsschluessel`:"security-phrase";M.download=`${D}.txt`,document.body.appendChild(M),M.click(),document.body.removeChild(M),URL.revokeObjectURL(A)},[t==null?void 0:t.username]),v=P.useCallback(async()=>{var N,k,A,M,D,L,B,U;c(!0),g("");try{const Y=await lt.get("/api/users/me/security-phrase"),q=Array.isArray((k=(N=Y.data)==null?void 0:N.item)==null?void 0:k.words)?Y.data.item.words:[];if(!q.length)throw new Error("EMPTY_SECURITY_PHRASE");S(q),x(!1)}catch(Y){if(((A=Y==null?void 0:Y.response)==null?void 0:A.status)===409||((D=(M=Y==null?void 0:Y.response)==null?void 0:M.data)==null?void 0:D.error)==="SECURITY_PHRASE_ALREADY_DOWNLOADED"){await n(),e("/dashboard/stacks",{replace:!0});return}if(((L=Y==null?void 0:Y.response)==null?void 0:L.status)===401){await n(),e("/auth/sign-in",{replace:!0});return}const q=((U=(B=Y==null?void 0:Y.response)==null?void 0:B.data)==null?void 0:U.error)||(Y==null?void 0:Y.message)||"Sicherheitsschlüssel konnte nicht geladen werden.";g(q)}finally{c(!1)}},[e,n]);P.useEffect(()=>{if(!t){e("/auth/sign-in",{replace:!0});return}if(!m){e("/dashboard/stacks",{replace:!0});return}v()},[t,m,v,e]);const w=P.useCallback(()=>{v()},[v]),j=P.useCallback(async()=>{var N,k,A,M,D,L;if(!(O||!Array.isArray(b)||b.length===0)){T(!0),g("");try{const B=PV(b);h(B);const U=await lt.post("/api/users/me/security-phrase/downloaded"),Y=((k=(N=U.data)==null?void 0:N.item)==null?void 0:k.downloadedAt)||null,q=(A=U.data)==null?void 0:A.session;q!=null&&q.user?r(q):await n(),x(!!Y),e("/dashboard/stacks",{replace:!0})}catch(B){if(((M=B==null?void 0:B.response)==null?void 0:M.status)===401){await n(),e("/auth/sign-in",{replace:!0});return}const U=((L=(D=B==null?void 0:B.response)==null?void 0:D.data)==null?void 0:L.error)||(B==null?void 0:B.message)||"Download konnte nicht bestätigt werden.";g(U)}finally{T(!1)}}},[b,O,h,n,e,r]);return m?y.jsxs("div",{className:"flex min-h-screen flex-col items-center justify-center bg-blue-gray-50/50 px-4 py-10",children:[y.jsxs("div",{className:"mb-6 text-center",children:[y.jsx(H.Typography,{variant:"h2",className:"font-semibold text-blue-gray-800",children:"Sicherheitsschlüssel sichern"}),y.jsx(H.Typography,{color:"blue-gray",className:"mt-2 max-w-3xl text-base",children:"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."})]}),y.jsx(H.Card,{className:"w-full max-w-3xl shadow-lg",children:y.jsxs(H.CardBody,{className:"space-y-6",children:[i&&y.jsx("div",{className:"flex items-center justify-center py-10",children:y.jsx(H.Spinner,{className:"h-10 w-10 text-blue-gray-500"})}),!i&&f&&y.jsx(H.Alert,{color:"red",className:"border border-red-200 bg-red-50 text-red-700",icon:y.jsx(zz,{className:"h-5 w-5"}),children:y.jsxs("div",{className:"flex items-start justify-between gap-4",children:[y.jsxs("div",{children:[y.jsx(H.Typography,{variant:"h6",color:"red",children:"Fehler"}),y.jsx(H.Typography,{color:"red",className:"text-sm",children:f})]}),y.jsx(H.Button,{size:"sm",color:"red",variant:"text",onClick:w,className:"normal-case",children:"Erneut versuchen"})]})}),!i&&!f&&y.jsxs(y.Fragment,{children:[y.jsxs("div",{className:"rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 p-6",children:[y.jsx(H.Typography,{variant:"h5",className:"mb-4 text-blue-gray-800",children:"Deine Sicherheitsphrase"}),y.jsx("div",{className:"space-y-3",children:l.map((N,k)=>y.jsx("div",{className:"flex flex-wrap items-center justify-center gap-4 text-lg font-mono tracking-wide text-blue-gray-900 md:text-xl",children:N.map((A,M)=>y.jsx("span",{className:"uppercase",children:A},`phrase-${k}-${M}`))},`phrase-row-${k}`))})]}),y.jsxs("div",{className:"flex flex-col items-center justify-between gap-4 md:flex-row",children:[y.jsx("div",{className:"text-sm text-blue-gray-600",children:"Der Download startet im TXT-Format. Bewahre die Datei sicher auf – sie kann später nicht erneut angezeigt werden."}),y.jsxs(H.Button,{color:"blue",size:"md",className:"flex items-center gap-2 normal-case",onClick:j,disabled:u||O||C||b.length===0,children:[y.jsx(Dz,{className:"h-5 w-5"}),"Sicherheitsschlüssel herunterladen"]})]})]})]})})]}):null}const EV="/assets Line truncated
+1
View File
@@ -0,0 +1 @@
*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#eee}:before,:after{--tw-content: ""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#bdbdbd}input::placeholder,textarea::placeholder{opacity:1;color:#bdbdbd}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(33 150 243 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(33 150 243 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%}@media (min-width: 540px){.container{max-width:540px}}@media (min-width: 720px){.container{max-width:720px}}@media (min-width: 960px){.container{max-width:960px}}@media (min-width: 1140px){.container{max-width:1140px}}@media (min-width: 1320px){.container{max-width:1320px}}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.\!invisible{visibility:hidden!important}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.\!absolute{position:absolute!important}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bLine truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+4 -2
View File
@@ -32,8 +32,10 @@
<!-- Nepcha Analytics (nepcha.com) -->
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<script type="module" crossorigin src="/assets/index-12a5cbeb.js"></script>
<link rel="stylesheet" href="/assets/index-c94d5694.css">
<script type="module" crossorigin src="/assets/index-71db1823.js"></script>
<link rel="stylesheet" href="/assets/index-dad5f6fa.css">
</head>
<body>
<div id="root"></div>
+373
View File
@@ -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
};
+987
View File
@@ -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;
}
+136
View File
@@ -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;
+7 -6
View File
@@ -8,12 +8,13 @@ services:
- "4001:4001"
volumes:
- stackpulse_data:/app/backend/data
environment:
PORTAINER_URL: "Your_Portainer_Server_Address"
PORTAINER_API_KEY: "Your_Portainer_API_Key"
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
SELF_STACK_ID: "Stackpulse ID"
restart: unless-stopped
environment:
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
volumes:
stackpulse_data:
+2
View File
@@ -32,6 +32,8 @@
<!-- Nepcha Analytics (nepcha.com) -->
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<link rel="stylesheet" href="/src/tailwind.css" />
</head>
<body>
<div id="root"></div>
+17
View File
@@ -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));
+2
View File
@@ -1,11 +1,13 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { Dashboard, Auth } from "@/layouts";
import Setup from "@/pages/setup/setup";
function App() {
return (
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/auth/*" element={<Auth />} />
<Route path="/setup" element={<Setup />} />
<Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
</Routes>
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+172
View File
@@ -0,0 +1,172 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
const LEVEL_PRIORITY = {
none: 0,
read: 1,
full: 2,
};
const AuthContext = createContext(null);
AuthContext.displayName = "AuthContext";
const normalizePermissions = (raw) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return Object.entries(raw).reduce((acc, [key, value]) => {
if (typeof key !== "string" || !key) {
return acc;
}
const normalizedValue = typeof value === "string" ? value : String(value ?? "").trim();
acc[key] = normalizedValue || "none";
return acc;
}, {});
};
export function AuthProvider({ children }) {
const [state, setState] = useState({
user: null,
permissions: {},
loading: false,
error: null,
lastErrorCode: null,
initialized: false,
});
const setSession = useCallback((payload) => {
const user = payload?.user ?? null;
const permissions = normalizePermissions(payload?.permissions);
setState({
user,
permissions,
loading: false,
error: null,
lastErrorCode: null,
initialized: true,
});
}, []);
const clearSession = useCallback(() => {
setState((prev) => ({
user: null,
permissions: {},
loading: false,
error: null,
lastErrorCode: null,
initialized: prev.initialized || true,
}));
}, []);
const refreshSession = useCallback(async () => {
setState((prev) => ({
...prev,
loading: true,
error: null,
lastErrorCode: null,
}));
try {
const response = await fetch("/api/auth/session", {
credentials: "include",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setState({
user: null,
permissions: {},
loading: false,
error: payload?.error ?? null,
lastErrorCode: payload?.error ?? null,
initialized: true,
});
return { ok: false, status: response.status, error: payload?.error ?? null };
}
setSession(payload);
return { ok: true, data: payload };
} catch (err) {
const message = err?.message || "NETWORK_ERROR";
setState({
user: null,
permissions: {},
loading: false,
error: message,
lastErrorCode: "NETWORK_ERROR",
initialized: true,
});
return { ok: false, error: message };
}
}, [setSession]);
const logout = useCallback(async () => {
let capturedError = null;
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const logoutError = new Error(payload?.error || "LOGOUT_FAILED");
logoutError.code = payload?.error || `STATUS_${response.status}`;
throw logoutError;
}
} catch (err) {
capturedError = err;
} finally {
clearSession();
}
if (capturedError) {
throw capturedError;
}
}, [clearSession]);
const hasPermission = useCallback(
(permissionKey, requiredLevel = "full") => {
if (!permissionKey) {
return true;
}
if (state.user?.isSuperuser) {
return true;
}
const current = state.permissions?.[permissionKey] ?? "none";
const currentPriority = LEVEL_PRIORITY[current] ?? 0;
const requiredPriority = LEVEL_PRIORITY[requiredLevel] ?? LEVEL_PRIORITY.full;
return currentPriority >= requiredPriority;
},
[state.permissions, state.user]
);
const value = useMemo(
() => ({
user: state.user,
permissions: state.permissions,
loading: state.loading,
error: state.error,
lastErrorCode: state.lastErrorCode,
initialized: state.initialized,
isAuthenticated: Boolean(state.user),
refreshSession,
setSession,
clearSession,
logout,
hasPermission,
}),
[state, refreshSession, setSession, clearSession, logout, hasPermission]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
export const AuthContextDisplayName = "/src/components/AuthProvider.jsx";
@@ -1,5 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
import { useAuth } from "@/components/AuthProvider.jsx";
const MaintenanceContext = createContext(null);
@@ -14,6 +15,8 @@ const INITIAL_STATE = {
};
export default function MaintenanceProvider({ children }) {
const { isAuthenticated, hasPermission, user } = useAuth();
const canAccessMaintenance = Boolean(isAuthenticated && (user?.isSuperuser || hasPermission("maintenance-access", "read")));
const [state, setState] = useState(INITIAL_STATE);
const pollingRef = useRef(null);
const wasRunningRef = useRef(false);
@@ -26,6 +29,10 @@ export default function MaintenanceProvider({ children }) {
}, []);
const fetchConfig = useCallback(async () => {
if (!canAccessMaintenance) {
applyState({ loading: false, error: "" });
return null;
}
applyState({ loading: true, error: "" });
try {
const response = await axios.get("/api/maintenance/config");
@@ -43,9 +50,12 @@ export default function MaintenanceProvider({ children }) {
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
applyState({ loading: false, error: message });
}
}, [applyState]);
}, [applyState, canAccessMaintenance]);
const refreshUpdateStatus = useCallback(async () => {
if (!canAccessMaintenance) {
return;
}
try {
const response = await axios.get("/api/maintenance/update-status");
const data = response.data ?? {};
@@ -59,13 +69,20 @@ export default function MaintenanceProvider({ children }) {
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
setState((prev) => ({ ...prev, error: message }));
}
}, []);
}, [canAccessMaintenance]);
useEffect(() => {
if (!canAccessMaintenance) {
applyState({ loading: false, error: "" });
return;
}
fetchConfig();
}, [fetchConfig]);
}, [applyState, fetchConfig, canAccessMaintenance]);
useEffect(() => {
if (!canAccessMaintenance) {
return undefined;
}
if (state.update?.running) {
refreshUpdateStatus();
const intervalId = setInterval(() => {
@@ -79,15 +96,15 @@ export default function MaintenanceProvider({ children }) {
pollingRef.current = null;
}
return undefined;
}, [state.update?.running, refreshUpdateStatus]);
}, [canAccessMaintenance, state.update?.running, refreshUpdateStatus]);
useEffect(() => {
const currentlyRunning = Boolean(state.update?.running);
if (!currentlyRunning && wasRunningRef.current) {
if (canAccessMaintenance && !currentlyRunning && wasRunningRef.current) {
fetchConfig();
}
wasRunningRef.current = currentlyRunning;
}, [state.update?.running, fetchConfig]);
}, [canAccessMaintenance, state.update?.running, fetchConfig]);
const triggerUpdate = useCallback(async (payload = {}) => {
await axios.post("/api/maintenance/portainer-update", payload);
@@ -131,6 +148,40 @@ export default function MaintenanceProvider({ children }) {
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,
@@ -147,8 +198,14 @@ export default function MaintenanceProvider({ children }) {
resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
testSshConnection,
fetchSuperuserStatus,
fetchSetupStatus,
deleteSetupServer,
updateSetupApiKey,
updateSelfStackId,
removeSuperuserAccount
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, fetchSetupStatus, deleteSetupServer, updateSetupApiKey, updateSelfStackId, removeSuperuserAccount]);
return (
<MaintenanceContext.Provider value={value}>
@@ -0,0 +1,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;
+14
View File
@@ -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";
-1
View File
@@ -7,7 +7,6 @@ import {
} from "@heroicons/react/24/solid";
import { Navbar, Footer } from "@/widgets/layout";
import routes from "@/routes";
export function Auth() {
const navbarRoutes = [
{
+172 -2
View File
@@ -1,4 +1,5 @@
import { Routes, Route } from "react-router-dom";
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 {
@@ -8,21 +9,169 @@ import {
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="p-4 xl:ml-80">
)}
<div className={isSecurityPhraseRoute ? "" : "p-4 xl:ml-80"}>
{!isSecurityPhraseRoute && (
<>
<DashboardNavbar />
<Configurator />
<IconButton
@@ -34,6 +183,8 @@ export function Dashboard() {
>
<Cog6ToothIcon className="h-5 w-5" />
</IconButton>
</>
)}
<Routes>
{routes.map(
({ layout, pages }) =>
@@ -42,10 +193,29 @@ export function Dashboard() {
<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>
);
+6
View File
@@ -10,6 +10,7 @@
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
import React from "react";
import axios from "axios";
import ReactDOM from "react-dom/client";
import App from "./App";
import { BrowserRouter } from "react-router-dom";
@@ -19,10 +20,14 @@ import "./tailwind.css";
import ToastProvider from "@/components/ToastProvider.jsx";
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
import PageProvider from "@/components/PageProvider.jsx";
import { AuthProvider } from "@/components/AuthProvider.jsx";
axios.defaults.withCredentials = true;
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<MaintenanceProvider>
<ToastProvider>
<PageProvider>
@@ -34,6 +39,7 @@ ReactDOM.createRoot(document.getElementById("root")).render(
</PageProvider>
</ToastProvider>
</MaintenanceProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);
+408
View File
@@ -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;
+2
View File
@@ -1,2 +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";
+182 -79
View File
@@ -1,124 +1,227 @@
import { useCallback, useEffect, useState } from "react";
import {
Card,
Input,
Checkbox,
Button,
Typography,
Alert,
} from "@material-tailwind/react";
import { Link } from "react-router-dom";
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">Sign In</Typography>
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Enter your email and password to Sign In.</Typography>
<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">
<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">
Your email
E-Mail oder Benutzername
</Typography>
<Input
size="lg"
placeholder="name@mail.com"
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">
Password
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>
<Checkbox
label={
<Typography
variant="small"
color="gray"
className="flex items-center justify-start font-medium"
>
I agree the&nbsp;
<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>
Sign In
</Button>
<div className="flex items-center justify-between gap-2 mt-6">
<Checkbox
label={
{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
variant="small"
color="gray"
className="flex items-center justify-start font-medium"
as="button"
type="button"
onClick={() => navigate("/auth/forgot-password")}
className="mt-4 text-sm font-medium text-blue-600 transition hover:text-blue-800"
>
Subscribe me to newsletter
</Typography>
}
containerProps={{ className: "-ml-2.5" }}
/>
<Typography variant="small" className="font-medium text-gray-900">
<a href="#">
Forgot Password
</a>
</Typography>
</div>
<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">
Not registered?
<Link to="/auth/sign-up" className="text-gray-900 ml-1">Create account</Link>
Passwort vergessen?
</Typography>
</form>
</div>
<div className="w-2/5 h-full hidden lg:block">
<img
src="/img/pattern.png"
className="h-full w-full object-cover rounded-3xl"
/>
<img src={pattern} className="h-full w-full object-cover rounded-3xl" />
</div>
</section>
);
}
+63
View File
@@ -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";
+32 -1
View File
@@ -26,10 +26,41 @@ import {
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
@@ -87,7 +118,7 @@ export function Home() {
</div>
<Menu placement="left-start">
<MenuHandler>
<IconButton size="sm" variant="text" color="blue-gray">
<IconButton size="sm" variant="text" color="blue-gray" disabled={maintenanceLocked}>
<EllipsisVerticalIcon
strokeWidth={3}
fill="currenColor"
+5
View File
@@ -1,3 +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
+628 -32
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
import { useToast } from "@/components/ToastProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider";
import { useAuth } from "@/components/AuthProvider.jsx";
import {
Typography,
@@ -9,7 +9,9 @@ import {
CardHeader,
CardBody,
Button,
Switch
Switch,
Input,
Alert
} from "@material-tailwind/react";
const UPDATE_STATUS_LABELS = {
@@ -109,7 +111,8 @@ const createEmptySshDraft = () => ({
});
export function Maintenance() {
const { showToast } = useToast();
const { hasPermission, user: authUser } = useAuth();
const showToast = useCallback(() => { }, []);
const {
maintenance: maintenanceMeta,
update: updateState,
@@ -123,13 +126,34 @@ export function Maintenance() {
resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection
testSshConnection,
fetchSuperuserStatus,
fetchSetupStatus,
deleteSetupServer,
updateSetupApiKey,
updateSelfStackId,
removeSuperuserAccount
} = useMaintenance();
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
const canViewMaintenance = Boolean(isSuperuserAccount || hasPermission("maintenance-access", "read"));
const canControlMaintenance = Boolean(isSuperuserAccount || hasPermission("maintenance-access", "full"));
const canViewServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "read"));
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "full"));
const canDeleteServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-delete", "full"));
const canDeleteSuperuser = Boolean(isSuperuserAccount || hasPermission("maintenance-superuser-delete", "full"));
const canViewPortainer = Boolean(isSuperuserAccount || hasPermission("maintenance-portainer", "read"));
const canManagePortainerUpdate = Boolean(isSuperuserAccount || hasPermission("maintenance-update", "full"));
const canViewSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "read"));
const canManageSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "full"));
const canViewDuplicates = Boolean(isSuperuserAccount || hasPermission("maintenance-duplicates", "read"));
const canManageDuplicates = Boolean(isSuperuserAccount || hasPermission("maintenance-duplicates", "full"));
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const maintenanceExtraType = maintenanceMeta?.extra?.type;
const updateRunning = Boolean(updateState?.running);
const maintenanceLocked = maintenanceActive || updateRunning;
const [scriptDraft, setScriptDraft] = useState("");
const [scriptSaving, setScriptSaving] = useState(false);
@@ -182,6 +206,21 @@ export function Maintenance() {
const scriptIsDirty = scriptConfig ? scriptDraft !== scriptBaseline : false;
const scriptSourceLabel = scriptConfig?.source === "custom" ? "Benutzerdefiniert" : "Standard";
const [superuserStatusLoading, setSuperuserStatusLoading] = useState(true);
const [superuserExists, setSuperuserExists] = useState(false);
const [superuserSummary, setSuperuserSummary] = useState(null);
const [superuserStatusError, setSuperuserStatusError] = useState("");
const [superuserDeleteLoading, setSuperuserDeleteLoading] = useState(false);
const [setupResources, setSetupResources] = useState(null);
const [setupResourcesLoading, setSetupResourcesLoading] = useState(true);
const [setupResourcesError, setSetupResourcesError] = useState("");
const [apiKeyDrafts, setApiKeyDrafts] = useState({});
const [apiKeyUpdatingId, setApiKeyUpdatingId] = useState(null);
const [serverDeleteId, setServerDeleteId] = useState(null);
const [selfStackDraft, setSelfStackDraft] = useState("");
const [selfStackSaving, setSelfStackSaving] = useState(false);
const [selfStackError, setSelfStackError] = useState("");
const [duplicates, setDuplicates] = useState([]);
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
const [duplicatesError, setDuplicatesError] = useState("");
@@ -197,6 +236,51 @@ export function Maintenance() {
const [portainerUpdatedAt, setPortainerUpdatedAt] = useState(null);
const portainerRequestRef = useRef(null);
const loadSuperuserStatus = useCallback(async ({ silent = false } = {}) => {
setSuperuserStatusLoading(true);
setSuperuserStatusError("");
try {
const data = await fetchSuperuserStatus();
const exists = Boolean(data?.exists);
setSuperuserExists(exists);
setSuperuserSummary(exists ? data.user ?? null : null);
} catch (err) {
const message = err.response?.data?.error || err.message || "Superuser-Status konnte nicht geladen werden";
setSuperuserExists(false);
setSuperuserSummary(null);
setSuperuserStatusError(message);
if (!silent) {
showToast({ variant: "error", title: "Statusabfrage fehlgeschlagen", description: message });
}
} finally {
setSuperuserStatusLoading(false);
}
}, [fetchSuperuserStatus, showToast]);
const loadSetupResources = useCallback(async ({ silent = false } = {}) => {
if (!silent) {
setSetupResourcesLoading(true);
}
setSetupResourcesError("");
try {
const data = await fetchSetupStatus();
setSetupResources(data);
setApiKeyDrafts({});
} catch (err) {
const message = err.response?.data?.error || err.message || "Setup-Daten konnten nicht geladen werden";
setSetupResourcesError(message);
if (!silent) {
showToast({
variant: "error",
title: "Setup-Daten",
description: message
});
}
} finally {
setSetupResourcesLoading(false);
}
}, [fetchSetupStatus, showToast]);
const fetchPortainerStatus = useCallback(async ({ silent = false } = {}) => {
if (portainerRequestRef.current) {
return portainerRequestRef.current;
@@ -285,6 +369,14 @@ export function Maintenance() {
return requestPromise;
}, [maintenanceActive, updateRunning, showToast]);
useEffect(() => {
loadSuperuserStatus({ silent: true });
}, [loadSuperuserStatus]);
useEffect(() => {
loadSetupResources();
}, [loadSetupResources]);
useEffect(() => {
fetchPortainerStatus();
}, [fetchPortainerStatus]);
@@ -293,6 +385,23 @@ export function Maintenance() {
fetchDuplicates();
}, [fetchDuplicates]);
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
const apiKeyInfoMap = useMemo(() => {
const items = setupResources?.apiKeys?.items ?? [];
return new Map(items.map((entry) => [entry.serverId, entry]));
}, [setupResources]);
const setupComplete = useMemo(() => Boolean(setupResources?.setupComplete), [setupResources]);
const selfStackInfo = setupResources?.selfStack ?? null;
const currentSelfStackValue = selfStackInfo?.current ?? "";
const envSelfStackValue = setupResources?.envDefaults?.selfStackId ?? "";
useEffect(() => {
setSelfStackDraft(currentSelfStackValue || "");
setSelfStackError("");
}, [currentSelfStackValue]);
const selfStackDirty = selfStackDraft !== currentSelfStackValue;
const totals = useMemo(() => {
const groups = Array.isArray(duplicates) ? duplicates.length : 0;
const duplicateCount = Array.isArray(duplicates)
@@ -302,11 +411,12 @@ export function Maintenance() {
}, [duplicates]);
const handleSshDraftChange = useCallback((field, value) => {
if (!canManageSsh) return;
setSshDraft((prev) => ({ ...prev, [field]: value }));
if (field === 'password') {
setSshPasswordStored(false);
}
}, []);
}, [canManageSsh]);
const normalizedSshDraft = useMemo(() => {
const normalized = {
@@ -330,6 +440,7 @@ export function Maintenance() {
}, [sshDraft, sshPasswordStored]);
const handleScriptSave = useCallback(async () => {
if (!canManageSsh) return;
if (!scriptConfig) return;
try {
setScriptSaving(true);
@@ -345,9 +456,10 @@ export function Maintenance() {
} finally {
setScriptSaving(false);
}
}, [saveScript, scriptDraft, scriptConfig, showToast]);
}, [canManageSsh, saveScript, scriptDraft, scriptConfig, showToast]);
const handleScriptReset = useCallback(async () => {
if (!canManageSsh) return;
try {
setScriptSaving(true);
await resetScript();
@@ -362,9 +474,10 @@ export function Maintenance() {
} finally {
setScriptSaving(false);
}
}, [resetScript, showToast]);
}, [canManageSsh, resetScript, showToast]);
const handleSshSaveConfig = useCallback(async () => {
if (!canManageSsh) return;
try {
setSshSaving(true);
await saveSshConfig(normalizedSshDraft);
@@ -381,9 +494,10 @@ export function Maintenance() {
} finally {
setSshSaving(false);
}
}, [normalizedSshDraft, saveSshConfig, showToast]);
}, [canManageSsh, normalizedSshDraft, saveSshConfig, showToast]);
const handleSshTestConnection = useCallback(async () => {
if (!canManageSsh) return;
try {
setSshTesting(true);
const result = await testSshConnection(normalizedSshDraft);
@@ -400,9 +514,10 @@ export function Maintenance() {
} finally {
setSshTesting(false);
}
}, [normalizedSshDraft, testSshConnection, showToast]);
}, [canManageSsh, normalizedSshDraft, testSshConnection, showToast]);
const handleSshDeleteConfig = useCallback(async () => {
if (!canManageSsh) return;
try {
setSshDeleting(true);
await deleteSshConfig();
@@ -421,9 +536,222 @@ export function Maintenance() {
} finally {
setSshDeleting(false);
}
}, [deleteSshConfig, showToast]);
}, [canManageSsh, deleteSshConfig, showToast]);
const handleSuperuserDelete = useCallback(async () => {
if (!canDeleteSuperuser || superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
return;
}
if (typeof window !== "undefined") {
const confirmed = window.confirm(
"Superuser und zugehörige Gruppe wirklich löschen?\nDies setzt die Superuser-Einrichtung zurück."
);
if (!confirmed) {
return;
}
}
try {
setSuperuserDeleteLoading(true);
const response = await removeSuperuserAccount();
const removedUsers = Number(response?.usersRemoved ?? 0);
showToast({
variant: "success",
title: "Superuser gelöscht",
description: removedUsers > 1
? `${removedUsers} Konten aus der Superuser-Gruppe wurden entfernt.`
: "Superuser-Konto wurde entfernt."
});
await loadSuperuserStatus({ silent: true });
} catch (err) {
const status = err.response?.status;
if (status === 404) {
await loadSuperuserStatus({ silent: true });
showToast({
variant: "info",
title: "Kein Superuser vorhanden",
description: "Es ist kein Superuser mehr vorhanden."
});
} else {
const message = err.response?.data?.error || err.message || "Superuser konnte nicht gelöscht werden";
showToast({ variant: "error", title: "Löschen fehlgeschlagen", description: message });
}
} finally {
setSuperuserDeleteLoading(false);
}
}, [canDeleteSuperuser, superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
const handleDeleteServer = useCallback(async (serverId) => {
if (!canDeleteServers) {
return;
}
if (serverDeleteId === serverId) {
return;
}
const target = setupServers.find((entry) => entry.id === serverId);
if (!target) {
return;
}
const label = target.name || target.url || `Server ${serverId}`;
const confirmMessage = [
`Server "${label}" wirklich löschen?`,
"Das System benötigt anschließend erneut einen gültigen Server im Setup."
].filter(Boolean).join("\n");
if (typeof window !== "undefined") {
const confirmed = window.confirm(confirmMessage);
if (!confirmed) {
return;
}
}
setServerDeleteId(serverId);
try {
await deleteSetupServer(serverId);
showToast({
variant: "success",
title: "Server gelöscht",
description: `Server "${label}" wurde entfernt.`
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Server konnte nicht gelöscht werden.";
showToast({
variant: "error",
title: "Server löschen fehlgeschlagen",
description: message
});
} finally {
setServerDeleteId(null);
}
}, [canDeleteServers, deleteSetupServer, loadSetupResources, serverDeleteId, setupServers, showToast]);
const handleApiKeyDraftChange = useCallback((serverId, value) => {
if (!canEditServers) return;
setApiKeyDrafts((prev) => ({
...prev,
[serverId]: value
}));
}, [canEditServers]);
const handleApiKeyUpdate = useCallback(async (serverId) => {
if (!canEditServers) {
return;
}
const draft = (apiKeyDrafts[serverId] ?? "").trim();
if (!draft) {
showToast({
variant: "warning",
title: "API-Key fehlt",
description: "Bitte gib einen API-Key ein."
});
return;
}
if (apiKeyUpdatingId === serverId) {
return;
}
setApiKeyUpdatingId(serverId);
try {
await updateSetupApiKey(serverId, draft);
showToast({
variant: "success",
title: "API-Key aktualisiert",
description: "Der API-Key wurde gespeichert."
});
setApiKeyDrafts((prev) => ({
...prev,
[serverId]: ""
}));
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "API-Key konnte nicht aktualisiert werden.";
showToast({
variant: "error",
title: "Aktualisierung fehlgeschlagen",
description: message
});
} finally {
setApiKeyUpdatingId(null);
}
}, [apiKeyDrafts, apiKeyUpdatingId, canEditServers, loadSetupResources, showToast, updateSetupApiKey]);
const handleSelfStackDraftChange = useCallback((value) => {
if (!canEditServers) return;
setSelfStackDraft(value);
setSelfStackError("");
}, [canEditServers]);
const handleSelfStackSave = useCallback(async () => {
if (!canEditServers || !selfStackDirty) {
return;
}
setSelfStackSaving(true);
setSelfStackError("");
const normalizedValue = typeof selfStackDraft === "string" ? selfStackDraft.trim() : "";
try {
await updateSelfStackId(normalizedValue);
showToast({
variant: "success",
title: "Self-Stack-ID gespeichert",
description: normalizedValue
? `Self-Stack-ID wurde auf "${normalizedValue}" gesetzt.`
: "Self-Stack-ID wurde entfernt."
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Self-Stack-ID konnte nicht aktualisiert werden.";
setSelfStackError(message);
showToast({
variant: "error",
title: "Self-Stack-ID",
description: message
});
} finally {
setSelfStackSaving(false);
}
}, [canEditServers, loadSetupResources, selfStackDirty, selfStackDraft, showToast, updateSelfStackId]);
const handleSelfStackRemove = useCallback(async () => {
if (!canEditServers) {
return;
}
if (!selfStackDraft && !currentSelfStackValue) {
return;
}
if (typeof window !== "undefined") {
const confirmed = window.confirm("Self-Stack-ID wirklich entfernen?");
if (!confirmed) {
return;
}
}
setSelfStackSaving(true);
setSelfStackError("");
try {
await updateSelfStackId("");
setSelfStackDraft("");
showToast({
variant: "success",
title: "Self-Stack-ID entfernt",
description: "Die Self-Stack-ID wurde gelöscht."
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Self-Stack-ID konnte nicht entfernt werden.";
setSelfStackError(message);
showToast({
variant: "error",
title: "Self-Stack-ID",
description: message
});
} finally {
setSelfStackSaving(false);
}
}, [canEditServers, currentSelfStackValue, loadSetupResources, selfStackDraft, showToast, updateSelfStackId]);
const handleMaintenanceToggle = useCallback(async (nextActive) => {
if (!canControlMaintenance) return;
if (maintenanceLoading || maintenanceToggleLoading) return;
if (nextActive === maintenanceActive) return;
if (updateRunning) {
@@ -460,9 +788,12 @@ export function Maintenance() {
} finally {
setMaintenanceToggleLoading(false);
}
}, [maintenanceLoading, maintenanceToggleLoading, maintenanceActive, updateRunning, setMaintenanceMode, maintenanceMessage, maintenanceExtraType, showToast]);
}, [canControlMaintenance, maintenanceLoading, maintenanceToggleLoading, maintenanceActive, updateRunning, setMaintenanceMode, maintenanceMessage, maintenanceExtraType, showToast]);
const handleTriggerUpdate = useCallback(async () => {
if (!canManagePortainerUpdate) {
return;
}
if (maintenanceActive || updateRunning) {
showToast({
variant: "warning",
@@ -498,9 +829,10 @@ export function Maintenance() {
} finally {
setUpdateActionLoading(false);
}
}, [maintenanceActive, updateRunning, portainerStatus, triggerUpdate, showToast]);
}, [canManagePortainerUpdate, maintenanceActive, updateRunning, portainerStatus, triggerUpdate, showToast]);
const handleCleanup = useCallback(async (entry) => {
if (!canManageDuplicates) return;
if (!entry) return;
if (maintenanceActive || updateRunning) {
showToast({
@@ -562,7 +894,7 @@ export function Maintenance() {
} finally {
setActiveCleanupId(null);
}
}, [fetchDuplicates, maintenanceActive, showToast, updateRunning]);
}, [canManageDuplicates, fetchDuplicates, maintenanceActive, showToast, updateRunning]);
const maintenanceActivatedAt = maintenanceMeta?.activatedAt ? formatCreatedAt(maintenanceMeta.activatedAt) : null;
const maintenanceUpdatedAt = maintenanceMeta?.updatedAt ? formatCreatedAt(maintenanceMeta.updatedAt) : null;
@@ -583,6 +915,10 @@ export function Maintenance() {
const disableUpdateButton = updateRunning || scriptSaving || maintenanceLoading;
if (!canViewMaintenance) {
return null;
}
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
@@ -601,6 +937,7 @@ export function Maintenance() {
</div>
)}
{canViewMaintenance && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
@@ -621,7 +958,7 @@ export function Maintenance() {
</div>
<Switch
checked={maintenanceActive}
disabled={maintenanceToggleLoading || maintenanceLoading || updateRunning}
disabled={!canControlMaintenance || maintenanceToggleLoading || maintenanceLoading || updateRunning}
onChange={(event) => handleMaintenanceToggle(event.target.checked)}
ripple={false}
color="amber"
@@ -632,7 +969,258 @@ export function Maintenance() {
)}
</CardBody>
</Card>
)}
{canViewServers && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
className="flex items-center justify-between"
>
<span>Server &amp; API-Keys</span>
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{setupResourcesLoading && (
<p className="text-sm text-stormGrey-500">Daten werden geladen…</p>
)}
{!setupResourcesLoading && setupResourcesError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{setupResourcesError}
</Alert>
)}
{!setupResourcesLoading && !setupResourcesError && (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Server
</Typography>
<Typography variant="small" color="blue-gray">
{setupServers.length} vorhanden
</Typography>
</div>
{setupServers.length === 0 ? (
<p className="text-sm text-stormGrey-500">
Es sind derzeit keine Server hinterlegt.
</p>
) : (
<div className="flex flex-col gap-3">
{setupServers.map((server) => {
const apiKeyInfo = apiKeyInfoMap.get(server.id) || null;
const hasKey = Boolean(apiKeyInfo?.hasKey);
const apiKeyUpdatedLabel = apiKeyInfo?.updatedAt
? new Date(apiKeyInfo.updatedAt).toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
: null;
return (
<div
key={server.id}
className="flex flex-col gap-3 rounded-md border border-blue-gray-100 p-3 md:flex-row md:items-start md:justify-between"
>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-stormGrey-900">
{server.name || "Ohne Namen"}
</p>
<p className="text-xs text-stormGrey-500 break-all">
{server.url}
</p>
<p className="text-xs text-stormGrey-500">
API-Key: {hasKey ? "vorhanden" : "nicht hinterlegt"}{apiKeyUpdatedLabel ? ` (aktualisiert am ${apiKeyUpdatedLabel})` : ""}
</p>
</div>
<div className="flex w-full flex-col gap-2 md:max-w-xs">
<Input
type="password"
label="Neuer API-Key"
value={apiKeyDrafts[server.id] ?? ""}
onChange={(event) => handleApiKeyDraftChange(server.id, event.target.value)}
disabled={!canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
/>
<div className="flex items-center gap-2 justify-end">
<Button
variant="text"
color="red"
size="sm"
onClick={() => handleDeleteServer(server.id)}
disabled={maintenanceLocked || !canDeleteServers || setupResourcesLoading || serverDeleteId === server.id || apiKeyUpdatingId === server.id}
>
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
</Button>
<Button
color="blue"
size="sm"
onClick={() => handleApiKeyUpdate(server.id)}
disabled={maintenanceLocked || !canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
>
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="flex flex-col gap-3 border-t border-blue-gray-50 pt-4">
<div className="flex flex-col gap-1">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Self-Stack-ID
</Typography>
<p className="text-xs text-stormGrey-500">
Optional – lass das Feld leer, wenn keine Self-Stack-ID benötigt wird.
</p>
</div>
{selfStackInfo?.envProvided && (
<p className="text-xs text-stormGrey-500">
Hinweis: Eine Umgebungsvariable liefert bereits einen Standardwert.
</p>
)}
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<div className="flex-1">
<Input
label="Self-Stack-ID"
value={selfStackDraft}
onChange={(event) => handleSelfStackDraftChange(event.target.value)}
disabled={!canEditServers || setupResourcesLoading || selfStackSaving}
/>
</div>
{canEditServers && (
<div className="flex items-center justify-end gap-2">
<Button
variant="text"
color="red"
size="sm"
onClick={handleSelfStackRemove}
disabled={maintenanceLocked || selfStackSaving || (!currentSelfStackValue && !selfStackDraft)}
>
Entfernen
</Button>
<Button
color="blue"
size="sm"
onClick={handleSelfStackSave}
disabled={maintenanceLocked || selfStackSaving || setupResourcesLoading || !selfStackDirty}
>
{selfStackSaving ? "Speichere…" : "Self-Stack-ID speichern"}
</Button>
</div>
)}
</div>
{!canEditServers && (
<p className="text-xs text-stormGrey-500">
Bearbeitung benötigt das Gruppenrecht „Server-Sektion“.
</p>
)}
<p className="text-xs text-stormGrey-500">
Aktuell gespeichert: {currentSelfStackValue || "nicht gesetzt"}
</p>
{envSelfStackValue && (
<p className="text-xs text-stormGrey-500">
Vorgabe aus Umgebung: {envSelfStackValue}
</p>
)}
{selfStackError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{selfStackError}
</Alert>
)}
</div>
{!setupComplete && (
<Alert color="amber" className="border border-amber-200 bg-amber-50 text-amber-800">
Das Setup ist aktuell unvollständig. Bitte öffne den Setup-Bereich, um Server und API-Key erneut festzulegen.
</Alert>
)}
<div className="flex items-center justify-end">
<Button
variant="outlined"
color="gray"
size="sm"
onClick={() => loadSetupResources()}
disabled={maintenanceLocked || setupResourcesLoading}
>
Aktualisieren
</Button>
</div>
</div>
)}
</CardBody>
</Card>
)}
{canViewServers && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
className="flex items-center justify-between"
>
<span>Superuser</span>
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
<div className="space-y-2">
{superuserStatusLoading && (
<p className="text-sm text-stormGrey-500">Status wird geladen…</p>
)}
{!superuserStatusLoading && superuserStatusError && (
<p className="text-sm text-sunsetCoral-600">{superuserStatusError}</p>
)}
{!superuserStatusLoading && !superuserStatusError && superuserExists && (
<div className="space-y-1">
<p className="text-sm">Superuser ist angelegt.</p>
<p className="text-xs text-stormGrey-500">
Benutzername: <span className="font-medium text-stormGrey-900">{superuserSummary?.username ?? "unbekannt"}</span>
{superuserSummary?.email ? ` - ${superuserSummary.email}` : ""}
</p>
</div>
)}
{!superuserStatusLoading && !superuserStatusError && !superuserExists && (
<p className="text-sm">Es ist kein Superuser vorhanden.</p>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button
variant="outlined"
color="gray"
onClick={() => loadSuperuserStatus()}
disabled={maintenanceLocked || superuserStatusLoading || superuserDeleteLoading}
className="w-full sm:w-auto"
>
Status aktualisieren
</Button>
<Button
color="red"
onClick={handleSuperuserDelete}
disabled={maintenanceLocked || !canDeleteSuperuser || superuserStatusLoading || superuserDeleteLoading || !superuserExists}
className="w-full sm:w-auto"
>
{superuserDeleteLoading ? "Wird gelöscht…" : "Superuser löschen"}
</Button>
</div>
<p className="text-xs text-stormGrey-500">
Entfernt das Superuser-Konto samt zugehöriger Gruppe. Danach kann der Erstbenutzer bei Bedarf erneut angelegt werden.
</p>
</CardBody>
</Card>
)}
{canViewPortainer && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
@@ -672,7 +1260,7 @@ export function Maintenance() {
<div className="flex items-center gap-3">
<Button
onClick={() => fetchPortainerStatus({ silent: false })}
disabled={portainerLoading || portainerRefreshing}
disabled={maintenanceLocked || portainerLoading || portainerRefreshing}
className="w-full">
Status aktualisieren
</Button>
@@ -809,6 +1397,7 @@ export function Maintenance() {
<div className="mt-6 grid gap-6 lg:grid-cols-2 xl:grid-cols-3">
{canViewSsh && (
<div className="rounded-md border border-gray-700/20 p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">SSH-Verbindung</h3>
@@ -820,7 +1409,7 @@ export function Maintenance() {
type="text"
value={sshDraft.host}
onChange={(event) => handleSshDraftChange('host', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
/>
</label>
@@ -832,7 +1421,7 @@ export function Maintenance() {
min="1"
value={sshDraft.port}
onChange={(event) => handleSshDraftChange('port', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
/>
</label>
@@ -842,7 +1431,7 @@ export function Maintenance() {
type="text"
value={sshDraft.username}
onChange={(event) => handleSshDraftChange('username', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
/>
</label>
@@ -853,7 +1442,7 @@ export function Maintenance() {
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
className="text-[11px] font-medium text-blue-500 transition hover:text-blue-800 disabled:opacity-50"
>
{showPassword ? 'Verbergen' : 'Anzeigen'}
@@ -864,7 +1453,7 @@ export function Maintenance() {
type={showPassword ? 'text' : 'password'}
value={sshDraft.password}
onChange={(event) => handleSshDraftChange('password', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
placeholder="Passwort für den SSH-Benutzer"
autoComplete="off"
@@ -882,7 +1471,7 @@ export function Maintenance() {
rows={3}
value={sshDraft.extraSshArgs}
onChange={(event) => handleSshDraftChange('extraSshArgs', event.target.value)}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 font-mono text-[11px] focus:border-purple-500 focus:outline-none"
placeholder="je Zeile ein Argument (optional)"
/>
@@ -892,14 +1481,14 @@ export function Maintenance() {
<Button
onClick={handleSshSaveConfig}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
className="mt-3 w-full">
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
</Button>
<Button
onClick={handleSshTestConnection}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
className="w-full bg-arcticBlue-500 hover:bg-arcticBlue-600">
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
</Button>
@@ -907,7 +1496,7 @@ export function Maintenance() {
<Button
onClick={handleSshDeleteConfig}
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500"
>
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
@@ -919,7 +1508,9 @@ export function Maintenance() {
</p>
)}
</div>
)}
{canViewSsh && (
<div className="rounded-md border border-gray-700/20 p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Update-Skript</h3>
@@ -929,20 +1520,20 @@ export function Maintenance() {
value={scriptDraft}
onChange={(event) => setScriptDraft(event.target.value)}
rows={10}
disabled={scriptSaving || updateRunning}
disabled={!canManageSsh || scriptSaving || updateRunning}
className="mt-3 w-full rounded-md border border-gray-700/20 px-3 py-2 font-mono text-xs focus:border-purple-500 focus:outline-none"
/>
<div className="mt-3 grid gap-2">
<Button
onClick={handleScriptSave}
disabled={!scriptIsDirty || scriptSaving || updateRunning}
disabled={!canManageSsh || !scriptIsDirty || scriptSaving || maintenanceLocked}
className="mt-3 w-full">
Speichern
</Button>
<Button
color="purple"
onClick={handleScriptReset}
disabled={!scriptConfig || scriptConfig.source !== "custom" || scriptSaving || updateRunning}
disabled={!canManageSsh || !scriptConfig || scriptConfig.source !== "custom" || scriptSaving || maintenanceLocked}
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500">
Standard wiederherstellen
</Button>
@@ -954,7 +1545,9 @@ export function Maintenance() {
</p>
)}
</div>
)}
{canViewSsh && (
<div className="rounded-md border border-gray-700/20 p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Update-Status</h3>
@@ -1016,19 +1609,22 @@ export function Maintenance() {
<Button
onClick={handleTriggerUpdate}
disabled={disableUpdateButton || updateActionLoading}
disabled={maintenanceLocked || !canManagePortainerUpdate || disableUpdateButton || updateActionLoading}
className="mt-3 w-full">
{updateActionLoading ? "Update wird gestartet…" : "Portainer aktualisieren"}
</Button>
</div>
)}
</div>
</CardBody>
</Card>
)}
{canViewDuplicates && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
@@ -1042,7 +1638,7 @@ export function Maintenance() {
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
<div className="rounded-lg border border-warmAmberGlow-600 bg-warmAmberGlow-700 px-4 py-3 text-sm text-white">
Das Entfernen von deppelten Stacks ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
Das Entfernen von doppelten Stacks ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
</div>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
@@ -1110,7 +1706,7 @@ export function Maintenance() {
</span>
</div>
<p className="text-sm text-gray-300">
Behaltener Stack: ID {canonicalId} (Endpoint {entry?.canonical?.EndpointId ?? "-"})
Behaltener Stack: ID {canonicalId}
</p>
<p className="text-xs text-gray-500">
Typ: {resolveStackType(entry?.canonical?.Type)} • Erstellt: {formatCreatedAt(entry?.canonical?.Created)}
@@ -1120,7 +1716,7 @@ export function Maintenance() {
<button
type="button"
onClick={() => handleCleanup(entry)}
disabled={isDuplicatesDisabled || isProcessing || duplicatesRefreshing || duplicatesLoading}
disabled={!canManageDuplicates || isDuplicatesDisabled || isProcessing || duplicatesRefreshing || duplicatesLoading}
className="self-start rounded-lg bg-sunsetCoral-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sunsetCoral-600 disabled:opacity-50"
>
{isProcessing ? "Bereinigung läuft…" : `Bereinigen (${duplicatesForEntry.length})`}
@@ -1135,7 +1731,6 @@ export function Maintenance() {
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-semibold text-white">ID: {duplicate.Id}</span>
<span>Endpoint: {duplicate.EndpointId ?? "-"}</span>
<span>Typ: {resolveStackType(duplicate.Type)}</span>
<span>Erstellt: {formatCreatedAt(duplicate.Created)}</span>
</div>
@@ -1149,6 +1744,7 @@ export function Maintenance() {
)}
</CardBody>
</Card>
)}
</div>
);
@@ -1,88 +0,0 @@
import React from "react";
import {
Typography,
Alert,
Card,
CardHeader,
CardBody,
} from "@material-tailwind/react";
import { InformationCircleIcon } from "@heroicons/react/24/outline";
export function Notifications() {
const [showAlerts, setShowAlerts] = React.useState({
blue: true,
green: true,
orange: true,
red: true,
});
const [showAlertsWithIcon, setShowAlertsWithIcon] = React.useState({
blue: true,
green: true,
orange: true,
red: true,
});
const alerts = ["gray", "green", "orange", "red"];
return (
<div className="mx-auto my-20 flex max-w-screen-lg flex-col gap-8">
<Card>
<CardHeader
color="transparent"
floated={false}
shadow={false}
className="m-0 p-4"
>
<Typography variant="h5" color="blue-gray">
Alerts
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{alerts.map((color) => (
<Alert
key={color}
open={showAlerts[color]}
color={color}
onClose={() => setShowAlerts((current) => ({ ...current, [color]: false }))}
>
A simple {color} alert with an <a href="#">example link</a>. Give
it a click if you like.
</Alert>
))}
</CardBody>
</Card>
<Card>
<CardHeader
color="transparent"
floated={false}
shadow={false}
className="m-0 p-4"
>
<Typography variant="h5" color="blue-gray">
Alerts with Icon
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{alerts.map((color) => (
<Alert
key={color}
open={showAlertsWithIcon[color]}
color={color}
icon={
<InformationCircleIcon strokeWidth={2} className="h-6 w-6" />
}
onClose={() => setShowAlertsWithIcon((current) => ({
...current,
[color]: false,
}))}
>
A simple {color} alert with an <a href="#">example link</a>. Give
it a click if you like.
</Alert>
))}
</CardBody>
</Card>
</div>
);
}
export default Notifications;
-221
View File
@@ -1,221 +0,0 @@
import {
Card,
CardBody,
CardHeader,
CardFooter,
Avatar,
Typography,
Tabs,
TabsHeader,
Tab,
Switch,
Tooltip,
Button,
} from "@material-tailwind/react";
import {
HomeIcon,
ChatBubbleLeftEllipsisIcon,
Cog6ToothIcon,
PencilIcon,
} from "@heroicons/react/24/solid";
import { Link } from "react-router-dom";
import { ProfileInfoCard, MessageCard } from "@/widgets/cards";
import { platformSettingsData, conversationsData, projectsData } from "@/data";
export function Profile() {
return (
<>
<div className="relative mt-8 h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover bg-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 items-center justify-between flex-wrap gap-6">
<div className="flex items-center gap-6">
<Avatar
src="/img/bruce-mars.jpeg"
alt="bruce-mars"
size="xl"
variant="rounded"
className="rounded-lg shadow-lg shadow-blue-gray-500/40"
/>
<div>
<Typography variant="h5" color="blue-gray" className="mb-1">
Richard Davis
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-600"
>
CEO / Co-Founder
</Typography>
</div>
</div>
<div className="w-96">
<Tabs value="app">
<TabsHeader>
<Tab value="app">
<HomeIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
App
</Tab>
<Tab value="message">
<ChatBubbleLeftEllipsisIcon className="-mt-0.5 mr-2 inline-block h-5 w-5" />
Message
</Tab>
<Tab value="settings">
<Cog6ToothIcon className="-mt-1 mr-2 inline-block h-5 w-5" />
Settings
</Tab>
</TabsHeader>
</Tabs>
</div>
</div>
<div className="gird-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-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>
<ProfileInfoCard
title="Profile Information"
description="Hi, I'm Alec Thompson, Decisions: If you can't decide, the answer is no. If two equally difficult paths, choose the one more painful in the short term (pain avoidance is creating an illusion of equality)."
details={{
"first name": "Alec M. Thompson",
mobile: "(44) 123 1234 123",
email: "alecthompson@mail.com",
location: "USA",
social: (
<div className="flex items-center gap-4">
<i className="fa-brands fa-facebook text-blue-700" />
<i className="fa-brands fa-twitter text-blue-400" />
<i className="fa-brands fa-instagram text-purple-500" />
</div>
),
}}
action={
<Tooltip content="Edit Profile">
<PencilIcon className="h-4 w-4 cursor-pointer text-blue-gray-500" />
</Tooltip>
}
/>
<div>
<Typography variant="h6" color="blue-gray" className="mb-3">
Platform Settings
</Typography>
<ul className="flex flex-col gap-6">
{conversationsData.map((props) => (
<MessageCard
key={props.name}
{...props}
action={
<Button variant="text" size="sm">
reply
</Button>
}
/>
))}
</ul>
</div>
</div>
<div className="px-4 pb-4">
<Typography variant="h6" color="blue-gray" className="mb-2">
Projects
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
Architects design houses
</Typography>
<div className="mt-6 grid grid-cols-1 gap-12 md:grid-cols-2 xl:grid-cols-4">
{projectsData.map(
({ img, title, description, tag, route, members }) => (
<Card key={title} color="transparent" shadow={false}>
<CardHeader
floated={false}
color="gray"
className="mx-0 mt-0 mb-4 h-64 xl:h-40"
>
<img
src={img}
alt={title}
className="h-full w-full object-cover"
/>
</CardHeader>
<CardBody className="py-0 px-1">
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
{tag}
</Typography>
<Typography
variant="h5"
color="blue-gray"
className="mt-1 mb-2"
>
{title}
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
{description}
</Typography>
</CardBody>
<CardFooter className="mt-6 flex items-center justify-between py-0 px-1">
<Link to={route}>
<Button variant="outlined" size="sm">
view project
</Button>
</Link>
<div>
{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>
))}
</div>
</CardFooter>
</Card>
)
)}
</div>
</div>
</CardBody>
</Card>
</>
);
}
export default Profile;
@@ -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;
+56 -17
View File
@@ -4,6 +4,7 @@ import { io } from "socket.io-client";
import { useToast } from "@/components/ToastProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
import {
Typography,
@@ -73,6 +74,10 @@ export function Stacks() {
script: scriptConfig,
ssh: sshConfig,
} = useMaintenance();
const { hasPermission } = useAuth();
const canRedeploySingle = hasPermission("stacks-redeploy-single", "full");
const canRedeploySelection = hasPermission("stacks-redeploy-selection", "full");
const canRedeployAll = hasPermission("stacks-redeploy-all", "full");
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
@@ -519,6 +524,7 @@ export function Stacks() {
}, [filteredStacks.length, perPage, page, setPage]);
const toggleStackSelection = (stackId, disabled) => {
if (!canRedeploySelection) return;
if (disabled) return;
setSelectedStackIds(prev =>
prev.includes(stackId)
@@ -527,8 +533,20 @@ export function Stacks() {
);
};
useEffect(() => {
if (!canRedeploySelection) {
if (selectedStackIds.length) {
setSelectedStackIds([]);
}
if (selectionPromptVisible) {
setSelectionPromptVisible(false);
}
}
}, [canRedeploySelection, selectedStackIds.length, selectionPromptVisible]);
const applySelectionPreference = (action) => {
if (!canRedeploySelection) return;
const remember = rememberSelectionChoice;
if (typeof window !== 'undefined') {
try {
@@ -559,6 +577,7 @@ export function Stacks() {
};
const clearStoredSelectionPreference = () => {
if (!canRedeploySelection) return;
if (typeof window !== 'undefined') {
try {
window.sessionStorage.removeItem(SELECTION_PROMPT_STORAGE_KEY);
@@ -577,15 +596,20 @@ export function Stacks() {
};
const clearSelection = () => {
if (!canRedeploySelection) return;
setSelectedStackIds([]);
setSelectionPromptVisible(false);
};
const handleChipRemove = (stackId) => {
if (!canRedeploySelection) return;
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
};
const handleRedeploy = async (stackId) => {
if (!canRedeploySingle) {
return;
}
const snapshot = stacksByIdRef.current.get(stackId);
const phase = snapshot?.redeployPhase;
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return;
@@ -636,6 +660,9 @@ export function Stacks() {
};
const handleRedeployAll = async () => {
if (!canRedeployAll) {
return;
}
if (!eligibleFilteredStacks.length) return;
const targetIdList = eligibleFilteredStacks.map((stack) => stack.Id);
@@ -687,6 +714,9 @@ export function Stacks() {
};
const handleRedeploySelection = async () => {
if (!canRedeploySelection) {
return;
}
if (!selectedStackIds.length) return;
const eligibleIds = selectedStackIds.filter((id) => {
@@ -747,14 +777,16 @@ export function Stacks() {
}
};
const hasSelection = selectedStackIds.length > 0;
const hasSelection = canRedeploySelection && selectedStackIds.length > 0;
const hasOutdatedStacks = eligibleFilteredStacks.length > 0;
const bulkButtonLabel = hasSelection
const showBulkButton = (canRedeploySelection && hasSelection) || canRedeployAll;
const bulkButtonLabel = canRedeploySelection && hasSelection
? `Redeploy Auswahl (${selectedStackIds.length})`
: 'Redeploy Alle';
const bulkActionDisabled = maintenanceLocked || (hasSelection
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
const bulkActionDisabled = maintenanceLocked || (
canRedeploySelection && hasSelection
? selectionPromptVisible || selectedStackIds.every(id => {
const targetStack = stacks.find(stack => stack.Id === id);
if (!targetStack) return true;
if (targetStack.updateStatus === '✅') return true;
@@ -762,7 +794,8 @@ export function Stacks() {
const phase = targetStack.redeployPhase;
return phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED;
})
: !hasOutdatedStacks);
: (!canRedeployAll || !hasOutdatedStacks)
);
const handleBulkRedeploy = () => {
if (maintenanceLocked) {
@@ -774,9 +807,9 @@ export function Stacks() {
return;
}
if (hasSelection) {
if (canRedeploySelection && hasSelection) {
handleRedeploySelection();
} else {
} else if (canRedeployAll) {
handleRedeployAll();
}
};
@@ -786,7 +819,8 @@ export function Stacks() {
<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">
{(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" : ""}.
@@ -868,7 +902,7 @@ export function Stacks() {
</div>
</div>
<div className="flex items-center justify-end gap-3 mt-5">
{selectionPreferenceStored && (
{canRedeploySelection && selectionPreferenceStored && (
<button
onClick={clearStoredSelectionPreference}
className="block antialiased font-sans text-sm leading-normal text-inherit text-xs font-medium text-warmAmberGlow-500 underline underline-offset-2 transition hover:text-warmAmberGlow-600"
@@ -881,7 +915,7 @@ export function Stacks() {
</div>
)}
{selectionPromptVisible && (
{canRedeploySelection && selectionPromptVisible && (
<div className="flex flex-col mt-5">
<div className="flex flex-col gap-3 rounded-lg border border-arcticBlue-800 bg-arcticBlue-900/90 px-4 py-3 text-arcticBlue-100 md:flex-row md:items-center md:justify-between">
@@ -921,8 +955,9 @@ export function Stacks() {
</div>
)}
{showBulkButton && (
<div id="collect" className="mt-8 flex flex-col gap-4 md:flex-row md:items-start md:gap-6">
{selectedStackIds.length > 0 && (
{canRedeploySelection && selectedStackIds.length > 0 && (
<div className="w-full md:order-first order-last md:w-3/4">
<div className="flex flex-col gap-3 text-sm text-gray-300">
<div className="flex flex-wrap items-center gap-2">
@@ -964,6 +999,7 @@ export function Stacks() {
</div>
</div>
)}
</CardBody>
</Card>
@@ -978,6 +1014,7 @@ export function Stacks() {
const isCurrent = stack.updateStatus === '✅';
const isSelfStack = Boolean(stack.redeployDisabled);
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
const canSelectStack = canRedeploySelection && isSelectable;
return (
<Card key={stack.Id}>
@@ -989,13 +1026,15 @@ export function Stacks() {
${!isSelectable || isBusy ? 'opacity-75 bg-stormGrey-200/20' : ''}`}
>
<div className="flex items-center space-x-4" id="left">
{canRedeploySelection && (
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleStackSelection(stack.Id, !isSelectable)}
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!isSelectable ? 'opacity-40 cursor-not-allowed' : ''}`}
disabled={!isSelectable}
onChange={() => toggleStackSelection(stack.Id, !canSelectStack)}
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!canSelectStack ? 'opacity-40 cursor-not-allowed' : ''}`}
disabled={!canSelectStack}
/>
)}
<div className={`w-12 h-12 flex items-center justify-center rounded-full
${stack.updateStatus === '✅' ? 'bg-mossGreen-600' :
stack.updateStatus === '⚠️' ? 'bg-warmAmberGlow-400' :
@@ -1031,15 +1070,15 @@ export function Stacks() {
<span className="antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-stormGrey-500">Status</span>
<span className="text-mossGreen-600">Aktuell</span>
</>
) : (
) : (canRedeploySingle ? (
<Button
onClick={() => handleRedeploy(stack.Id)}
disabled={isBusy}
disabled={maintenanceLocked || isBusy}
className="disabled:opacity-50 disabled:cursor-not-allowed"
>
Redeploy
</Button>
)}
) : null)}
</div>
</CardBody>
@@ -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;
+561
View File
@@ -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;
+805
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export { Setup as default, Setup } from "./setup";
+821
View File
@@ -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 &gt; Server &amp; 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;
+76 -23
View File
@@ -1,10 +1,17 @@
import {
Square3Stack3DIcon,
WrenchScrewdriverIcon,
ListBulletIcon
ListBulletIcon,
ServerStackIcon,
RectangleStackIcon,
ArrowLeftOnRectangleIcon,
UserIcon,
UserGroupIcon,
KeyIcon
} from "@heroicons/react/24/solid";
import { Stacks, Maintenance, Logs } from "@/pages/dashboard";
import { SignIn, SignUp } from "@/pages/auth";
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",
@@ -19,39 +26,85 @@ export const routes = [
name: "stacks",
path: "/stacks",
element: <Stacks />,
permission: null,
},
{
icon: <WrenchScrewdriverIcon {...icon} />,
name: "wartung",
path: "/maintenance",
element: <Maintenance />,
element: (
<PermissionGate permission="maintenance-access" requiredLevel="read">
<Maintenance />
</PermissionGate>
),
permission: { key: "maintenance-access", requiredLevel: "read" },
},
{
icon: <ListBulletIcon {...icon} />,
name: "logs",
path: "/logs",
element: <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: <RectangleStackIcon {...icon} />,
// name: "sign up",
// path: "/sign-up",
// element: <SignUp />,
// },
// ],
// },
{
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;
+31 -10
View File
@@ -1,18 +1,15 @@
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 { 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",
@@ -25,7 +22,7 @@ export function Sidenav({ brandImg, brandName, routes }) {
} 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.4</span>
<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"
@@ -38,7 +35,30 @@ export function Sidenav({ brandImg, brandName, routes }) {
</IconButton>
</div>
<div className="m-4">
{routes.map(({ layout, title, pages }, key) => (
{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">
@@ -51,7 +71,7 @@ export function Sidenav({ brandImg, brandName, routes }) {
</Typography>
</li>
)}
{pages.map(({ icon, name, path }) => (
{visiblePages.map(({ icon, name, path }) => (
<li key={name}>
<NavLink to={`/${layout}${path}`}>
{({ isActive }) => (
@@ -80,7 +100,8 @@ export function Sidenav({ brandImg, brandName, routes }) {
</li>
))}
</ul>
))}
);
})}
</div>
</aside>
);
+20 -20
View File
@@ -10,27 +10,27 @@ module.exports = withMT({
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'},
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'},
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' },
},
},
},
+8
View File
@@ -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));
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StackPulse</title>
<link rel="stylesheet" href="/src/index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-33
View File
@@ -1,33 +0,0 @@
{
"name": "stackpulse-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "1.7.5",
"socket.io-client": "4.8.1",
"@heroicons/react": "2.0.18",
"@material-tailwind/react": "2.1.4",
"apexcharts": "3.44.0",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-apexcharts": "1.4.1",
"react-dom": "18.2.0",
"react-router-dom": "6.17.0"
},
"devDependencies": {
"@types/react": "18.2.31",
"@types/react-dom": "18.2.14",
"@vitejs/plugin-react": "4.1.0",
"autoprefixer": "10.4.16",
"postcss": "8.4.31",
"prettier": "3.0.3",
"prettier-plugin-tailwindcss": "0.5.6",
"tailwindcss": "3.3.4",
"vite": "4.5.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-65
View File
@@ -1,65 +0,0 @@
import React from "react";
import { NavLink, Route, Routes } from "react-router-dom";
import Stacks from "./Stacks.jsx";
import Logs from "./Logs.jsx";
import Maintenance from "./Maintenance.jsx";
import logo from "./assets/images/stackpulse.png";
import { useMaintenance } from "./context/MaintenanceContext.jsx";
const navLinkBase =
"px-4 py-2 rounded-md font-medium transition-colors duration-150";
const getNavClass = ({ isActive }) =>
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
export default function App() {
const { maintenance, update } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const maintenanceLabel = maintenance?.message || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv");
return (
<div className="min-h-screen bg-gray-900 text-white">
<header className="bg-gray-800 shadow-md">
<div className="max-w-6xl mx-auto px-6 py-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col items-end gap-1">
<span className="text-xs text-gray-500">v0.3</span>
{maintenanceActive && (
<span className="flex items-center gap-1 text-xs text-amber-300">
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
{maintenanceLabel}
</span>
)}
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
</div>
<nav className="flex gap-2 items-end">
<NavLink to="/" end className={getNavClass}>
Stacks
</NavLink>
<NavLink to="/maintenance" className={getNavClass}>
<span className="flex items-center gap-2">
Wartung
{maintenanceActive && (
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
)}
</span>
</NavLink>
<NavLink to="/logs" className={getNavClass}>
Logs
</NavLink>
</nav>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-6">
<Routes>
<Route path="/" element={<Stacks />} />
<Route path="/maintenance" element={<Maintenance />} />
<Route path="/logs" element={<Logs />} />
<Route path="*" element={<Stacks />} />
</Routes>
</main>
</div>
);
}
-935
View File
@@ -1,935 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
const STATUS_COLORS = {
success: "text-green-400",
warning: "text-yellow-400",
error: "text-red-400",
started: "text-blue-300"
};
const formatTimestamp = (value) => {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
};
const normalizeDateParam = (value) => {
if (!value) return undefined;
return value.replace("T", " ");
};
const FILTER_STORAGE_KEY = "redeployLogFilters";
const ALL_OPTION_VALUE = "__all__";
const ALL_OPTION_LABEL = "- Alle -";
const PER_PAGE_DEFAULT = "50";
const PER_PAGE_OPTIONS = [
{ value: "10", label: "10" },
{ value: "25", label: "25" },
{ value: "50", label: "50" },
{ value: "100", label: "100" },
{ value: "all", label: "Alle" }
];
const VALID_PER_PAGE_VALUES = new Set(PER_PAGE_OPTIONS.map((option) => option.value));
const REDEPLOY_TYPE_LABELS = {
Einzeln: "Einzeln",
Alle: "Alle",
Auswahl: "Auswahl",
single: "Einzeln",
all: "Alle",
selection: "Auswahl"
};
const hasActiveFilters = (filters) => Boolean(
(filters.stacks && filters.stacks.length) ||
(filters.statuses && filters.statuses.length) ||
(filters.endpoints && filters.endpoints.length) ||
(filters.redeployTypes && filters.redeployTypes.length) ||
(filters.message && filters.message.trim()) ||
(filters.from && filters.from.trim()) ||
(filters.to && filters.to.trim())
);
export default function Logs() {
const [logs, setLogs] = useState([]);
const [totalLogs, setTotalLogs] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const [stackOptions, setStackOptions] = useState([]);
const [statusOptions, setStatusOptions] = useState([]);
const [endpointOptions, setEndpointOptions] = useState([]);
const [redeployTypeOptions, setRedeployTypeOptions] = useState([]);
const [selectedStacks, setSelectedStacks] = useState([]);
const [selectedStatuses, setSelectedStatuses] = useState([]);
const [selectedEndpoints, setSelectedEndpoints] = useState([]);
const [selectedRedeployTypes, setSelectedRedeployTypes] = useState([]);
const [messageQuery, setMessageQuery] = useState("");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [filtersReady, setFiltersReady] = useState(false);
const [optionsInitialized, setOptionsInitialized] = useState(false);
const [refreshSignal, setRefreshSignal] = useState(0);
const [perPage, setPerPage] = useState(PER_PAGE_DEFAULT);
const [page, setPage] = useState(1);
const updateFilterOptions = useCallback((payload) => {
const logsPayload = Array.isArray(payload) ? payload : payload?.items ?? [];
const stackMap = new Map();
const statusSet = new Set();
const endpointSet = new Set();
const redeployTypeSet = new Set();
logsPayload.forEach((log) => {
if (log.stackId) {
const value = String(log.stackId);
const label = log.stackName || `Stack ${value}`;
stackMap.set(value, label);
}
if (log.status) {
statusSet.add(log.status);
}
if (log.endpoint !== null && log.endpoint !== undefined && log.endpoint !== "") {
endpointSet.add(String(log.endpoint));
}
if (log.redeployType) {
redeployTypeSet.add(log.redeployType);
}
});
setStackOptions(Array.from(stackMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label)));
setStatusOptions(Array.from(statusSet).sort());
setEndpointOptions(Array.from(endpointSet)
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));
setRedeployTypeOptions(Array.from(redeployTypeSet).sort());
setOptionsInitialized(true);
}, []);
const stackLabelMap = useMemo(() => {
const map = new Map();
stackOptions.forEach((option) => {
map.set(option.value, option.label);
});
return map;
}, [stackOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedStacks((prev) => {
const valid = prev.filter((value) => stackOptions.some((option) => option.value === value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, stackOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedStatuses((prev) => {
const valid = prev.filter((value) => statusOptions.includes(value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, statusOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedEndpoints((prev) => {
const valid = prev.filter((value) => endpointOptions.includes(value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, endpointOptions]);
useEffect(() => {
if (!optionsInitialized) return;
setSelectedRedeployTypes((prev) => {
const valid = prev.filter((value) => redeployTypeOptions.includes(value));
return valid.length === prev.length ? prev : valid;
});
}, [optionsInitialized, redeployTypeOptions]);
useEffect(() => {
if (typeof window === "undefined") {
setFiltersReady(true);
return;
}
try {
const storedValue = window.localStorage.getItem(FILTER_STORAGE_KEY);
if (storedValue) {
const parsed = JSON.parse(storedValue);
const storedFilters = parsed?.filters ?? parsed ?? {};
const storedPagination = parsed?.pagination ?? {};
setSelectedStacks(storedFilters.stacks || []);
setSelectedStatuses(storedFilters.statuses || []);
setSelectedEndpoints(storedFilters.endpoints || []);
setSelectedRedeployTypes(storedFilters.redeployTypes || []);
setMessageQuery(storedFilters.message || "");
setFromDate(storedFilters.from || "");
setToDate(storedFilters.to || "");
setFiltersOpen(hasActiveFilters(storedFilters));
const rawPerPage = storedPagination.perPage;
if (rawPerPage !== undefined) {
const parsedPerPage = String(rawPerPage);
if (VALID_PER_PAGE_VALUES.has(parsedPerPage)) {
setPerPage(parsedPerPage);
}
}
const rawPage = storedPagination.page;
if (typeof rawPage === 'number' && rawPage > 0) {
setPage(rawPage);
}
}
} catch (storageError) {
console.error("❌ Fehler beim Laden der gespeicherten Log-Filter:", storageError);
} finally {
setFiltersReady(true);
}
}, []);
const buildFilterParams = useCallback(() => {
const params = {};
if (selectedStacks.length) {
params.stackIds = selectedStacks.join(",");
}
if (selectedStatuses.length) {
params.statuses = selectedStatuses.join(",");
}
if (selectedEndpoints.length) {
params.endpoints = selectedEndpoints.join(",");
}
if (selectedRedeployTypes.length) {
params.redeployTypes = selectedRedeployTypes.join(",");
}
if (messageQuery.trim()) {
params.message = messageQuery.trim();
}
const fromParam = normalizeDateParam(fromDate);
if (fromParam) {
params.from = fromParam;
}
const toParam = normalizeDateParam(toDate);
if (toParam) {
params.to = toParam;
}
return params;
}, [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
useEffect(() => {
if (!filtersReady) return;
let cancelled = false;
axios.get("/api/logs", { params: { perPage: 'all', page: 1 } })
.then((response) => {
if (cancelled) return;
updateFilterOptions(response.data);
})
.catch((err) => {
if (cancelled) return;
console.error("⚠️ Konnte Filteroptionen nicht aktualisieren:", err);
});
return () => {
cancelled = true;
};
}, [filtersReady, updateFilterOptions, refreshSignal]);
const currentFilters = useMemo(() => ({
stacks: selectedStacks,
statuses: selectedStatuses,
endpoints: selectedEndpoints,
redeployTypes: selectedRedeployTypes,
message: messageQuery,
from: fromDate,
to: toDate
}), [selectedStacks, selectedStatuses, selectedEndpoints, selectedRedeployTypes, messageQuery, fromDate, toDate]);
useEffect(() => {
if (!filtersReady) return;
const params = { ...buildFilterParams() };
if (perPage === 'all') {
params.perPage = 'all';
params.page = 1;
} else {
params.perPage = perPage;
params.page = page;
}
let cancelled = false;
setLoading(true);
setError("");
axios.get("/api/logs", { params })
.then((response) => {
if (cancelled) return;
const data = response.data || {};
const items = Array.isArray(data) ? data : data.items ?? [];
const total = Array.isArray(data) ? items.length : data.total ?? items.length;
setLogs(items);
setTotalLogs(total);
if (perPage === 'all') {
if (page !== 1) setPage(1);
} else {
const totalPages = Math.max(1, Math.ceil((total || 0) / Number(perPage)));
const nextPage = Math.min(Math.max(page, 1), totalPages);
if (nextPage !== page) {
setPage(nextPage);
}
}
})
.catch((err) => {
if (cancelled) return;
console.error("❌ Fehler beim Laden der Logs:", err);
setError("Fehler beim Laden der Redeploy-Logs");
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [filtersReady, buildFilterParams, updateFilterOptions, refreshSignal, perPage, page]);
useEffect(() => {
if (!filtersReady) return;
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
FILTER_STORAGE_KEY,
JSON.stringify({
filters: currentFilters,
pagination: {
perPage,
page
}
})
);
} catch (storageError) {
console.error("⚠️ Konnte Filter nicht speichern:", storageError);
}
}, [filtersReady, currentFilters, perPage, page]);
const handleMultiSelectChange = (setter) => (event) => {
const values = Array.from(event.target.selectedOptions).map((option) => option.value);
if (values.includes(ALL_OPTION_VALUE)) {
setter([]);
} else {
setter(values);
}
setPage(1);
};
const handleOptionMouseDown = (event, currentValues, setter) => {
event.preventDefault();
event.stopPropagation();
const { value } = event.target;
if (value === ALL_OPTION_VALUE) {
if (currentValues.length) {
setter([]);
setPage(1);
}
return;
}
const nextValues = currentValues.includes(value)
? currentValues.filter((entry) => entry !== value)
: [...currentValues, value];
setter(nextValues);
setPage(1);
};
const handleResetFilters = () => {
setSelectedStacks([]);
setSelectedStatuses([]);
setSelectedEndpoints([]);
setSelectedRedeployTypes([]);
setMessageQuery("");
setFromDate("");
setToDate("");
setFiltersOpen(false);
setPage(1);
};
const handleToggleFilters = () => {
setFiltersOpen((prev) => !prev);
};
const handleRefresh = () => {
setRefreshSignal((prev) => prev + 1);
};
const handlePerPageChange = (event) => {
const value = event.target.value;
if (!VALID_PER_PAGE_VALUES.has(value)) return;
setPerPage(value);
setPage(1);
};
const handlePageChange = (nextPage) => {
if (perPage === 'all') return;
if (nextPage < 1) return;
const totalPages = Math.max(1, Math.ceil((totalLogs || 0) / Number(perPage)));
if (nextPage > totalPages) return;
setPage(nextPage);
};
const stackSelectOptions = useMemo(() => {
const entries = stackOptions.filter((option) => option.value !== ALL_OPTION_VALUE);
return [
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
...entries
];
}, [stackOptions]);
const statusSelectOptions = useMemo(() => {
const entries = statusOptions
.filter((status) => status !== ALL_OPTION_VALUE)
.map((status) => ({ value: status, label: status }));
return [
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
...entries
];
}, [statusOptions]);
const endpointSelectOptions = useMemo(() => {
const entries = endpointOptions
.filter((endpoint) => endpoint !== ALL_OPTION_VALUE)
.map((endpoint) => ({ value: endpoint, label: endpoint }));
return [
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
...entries
];
}, [endpointOptions]);
const redeployTypeSelectOptions = useMemo(() => {
const entries = redeployTypeOptions
.filter((type) => type !== ALL_OPTION_VALUE)
.map((type) => ({ value: type, label: REDEPLOY_TYPE_LABELS[type] ?? type }));
return [
{ value: ALL_OPTION_VALUE, label: ALL_OPTION_LABEL },
...entries
];
}, [redeployTypeOptions]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (selectedStacks.length) count += selectedStacks.length;
if (selectedStatuses.length) count += selectedStatuses.length;
if (selectedEndpoints.length) count += selectedEndpoints.length;
if (selectedRedeployTypes.length) count += selectedRedeployTypes.length;
if (messageQuery.trim()) count += 1;
if (fromDate) count += 1;
if (toDate) count += 1;
return count;
}, [selectedStacks, selectedStatuses, selectedEndpoints, messageQuery, fromDate, toDate]);
const totalPages = useMemo(() => {
if (perPage === 'all') return 1;
const numeric = Number(perPage) || Number(PER_PAGE_DEFAULT);
return Math.max(1, Math.ceil((totalLogs || 0) / numeric));
}, [perPage, totalLogs]);
const rangeStart = useMemo(() => {
if (totalLogs === 0) return 0;
if (perPage === 'all') return 1;
return (page - 1) * Number(perPage) + 1;
}, [totalLogs, perPage, page]);
const rangeEnd = useMemo(() => {
if (totalLogs === 0) return 0;
if (perPage === 'all') return totalLogs;
return Math.min(totalLogs, (page - 1) * Number(perPage) + logs.length);
}, [totalLogs, perPage, page, logs.length]);
const handleDeleteLog = async (id) => {
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
setActionLoading(true);
setError("");
try {
await axios.delete(`/api/logs/${id}`);
setRefreshSignal((prev) => prev + 1);
} catch (err) {
console.error("❌ Fehler beim Löschen des Logs:", err);
setError("Fehler beim Löschen des Redeploy-Logs");
} finally {
setActionLoading(false);
}
};
const handleDeleteFiltered = async () => {
if (!logs.length) return;
if (!window.confirm("Alle angezeigten Logs (entsprechend Filter) löschen?")) return;
setActionLoading(true);
setError("");
try {
const params = {
...buildFilterParams(),
ids: logs.map((log) => log.id).join(',')
};
await axios.delete("/api/logs", { params });
setRefreshSignal((prev) => prev + 1);
} catch (err) {
console.error("❌ Fehler beim Löschen der gefilterten Logs:", err);
setError("Fehler beim Löschen der gefilterten Logs");
} finally {
setActionLoading(false);
}
};
const handleExport = async (format) => {
setActionLoading(true);
setError("");
try {
const response = await axios.get("/api/logs/export", {
params: { ...buildFilterParams(), format },
responseType: "blob"
});
const contentType = response.headers["content-type"] || (format === "sql" ? "application/sql" : "text/plain");
const disposition = response.headers["content-disposition"] || "";
const match = disposition.match(/filename="?([^";]+)"?/i);
const filename = match?.[1] || `redeploy-logs.${format}`;
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("❌ Fehler beim Export der Logs:", err);
setError("Fehler beim Export der Redeploy-Logs");
} finally {
setActionLoading(false);
}
};
return (
<div className="mx-auto max-w-6xl space-y-4 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-gray-100">Redeploy-Logs</h2>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => handleExport('txt')}
disabled={actionLoading || loading}
className="rounded-md border border-gray-600 px-3 py-1.5 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Export TXT
</button>
<button
onClick={() => handleExport('sql')}
disabled={actionLoading || loading}
className="rounded-md border border-gray-600 px-3 py-1.5 text-sm text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Export SQL
</button>
<button
onClick={handleDeleteFiltered}
disabled={actionLoading || loading || logs.length === 0}
className="rounded-md border border-red-600 px-3 py-1.5 text-sm text-red-300 transition hover:bg-red-600/20 disabled:opacity-60"
>
Angezeigte löschen
</button>
{loading && <span className="text-sm text-gray-400">Aktualisiere…</span>}
<button
onClick={handleRefresh}
disabled={actionLoading}
className="px-4 py-2 rounded-md font-medium transition bg-purple-500 hover:bg-purple-600 disabled:opacity-60"
>
Aktualisieren
</button>
</div>
</div>
{error && (
<div className="bg-red-900/20 border border-red-500/40 text-red-300 px-4 py-3 rounded-lg">
{error}
</div>
)}
<div className="rounded-xl border border-gray-700 bg-gray-800/60">
<button
onClick={handleToggleFilters}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-gray-200 hover:bg-gray-700/40"
>
<span>
Filter {activeFilterCount > 0 && (
<span className="ml-2 rounded-full bg-purple-500/20 px-2 py-0.5 text-xs text-purple-300">
{activeFilterCount} aktiv
</span>
)}
</span>
<span className="text-xs uppercase tracking-wide text-gray-400">
{filtersOpen ? "Ausblenden" : "Anzeigen"}
</span>
</button>
{filtersOpen && (
<div className="space-y-4 border-t border-gray-700 px-4 py-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Stack</label>
<select
multiple
value={selectedStacks}
onChange={handleMultiSelectChange(setSelectedStacks)}
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{stackSelectOptions.map(({ value, label }) => (
<option
key={value}
value={value}
onMouseDown={(event) => handleOptionMouseDown(event, selectedStacks, setSelectedStacks)}
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
>
{label}
</option>
))}
</select>
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
{selectedStacks.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
Alle Stacks
</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedStacks.map((stackId) => (
<span
key={stackId}
className="rounded-full bg-purple-500/20 px-2 py-0.5 text-purple-200"
>
{stackLabelMap.get(stackId) ?? `Stack ${stackId}`}
</span>
))}
</div>
)}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Status</label>
<select
multiple
value={selectedStatuses}
onChange={handleMultiSelectChange(setSelectedStatuses)}
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{statusSelectOptions.map(({ value, label }) => (
<option
key={value}
value={value}
onMouseDown={(event) => handleOptionMouseDown(event, selectedStatuses, setSelectedStatuses)}
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
>
{label}
</option>
))}
</select>
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
{selectedStatuses.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
Alle Status
</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedStatuses.map((status) => (
<span
key={status}
className="rounded-full bg-amber-500/20 px-2 py-0.5 text-amber-200"
>
{status}
</span>
))}
</div>
)}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Endpoint</label>
<select
multiple
value={selectedEndpoints}
onChange={handleMultiSelectChange(setSelectedEndpoints)}
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{endpointSelectOptions.map(({ value, label }) => (
<option
key={value}
value={value}
onMouseDown={(event) => handleOptionMouseDown(event, selectedEndpoints, setSelectedEndpoints)}
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
>
{label}
</option>
))}
</select>
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
{selectedEndpoints.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
Alle Endpoints
</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedEndpoints.map((endpoint) => (
<span
key={endpoint}
className="rounded-full bg-blue-500/20 px-2 py-0.5 text-blue-200"
>
{endpoint}
</span>
))}
</div>
)}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Redeploy-Typ</label>
<select
multiple
value={selectedRedeployTypes}
onChange={handleMultiSelectChange(setSelectedRedeployTypes)}
className="w-full min-h-[8rem] rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{redeployTypeSelectOptions.map(({ value, label }) => (
<option
key={value}
value={value}
onMouseDown={(event) => handleOptionMouseDown(event, selectedRedeployTypes, setSelectedRedeployTypes)}
className={`bg-gray-900 text-gray-200 ${value === ALL_OPTION_VALUE ? 'font-semibold text-gray-100' : ''}`}
>
{label}
</option>
))}
</select>
<div className="mt-2 min-h-[1.5rem] text-xs text-gray-400">
{selectedRedeployTypes.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
Alle Typen
</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedRedeployTypes.map((type) => (
<span
key={type}
className="rounded-full bg-teal-500/20 px-2 py-0.5 text-teal-200"
>
{REDEPLOY_TYPE_LABELS[type] ?? type}
</span>
))}
</div>
)}
</div>
</div>
<div className="md:col-span-2 lg:col-span-4">
<label className="mb-2 block text-sm font-medium text-gray-300">Nachricht (Freitext)</label>
<input
type="text"
value={messageQuery}
onChange={(event) => {
setMessageQuery(event.target.value);
setPage(1);
}}
placeholder="Textsuche in Log-Nachrichten..."
className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Von</label>
<input
type="datetime-local"
value={fromDate}
onChange={(event) => {
setFromDate(event.target.value);
setPage(1);
}}
className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-300">Bis</label>
<input
type="datetime-local"
value={toDate}
onChange={(event) => {
setToDate(event.target.value);
setPage(1);
}}
className="w-full rounded-md border border-gray-700 bg-gray-900/70 px-3 py-2 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<Button
onClick={handleResetFilters}
className="rounded-md border border-gray-600 px-4 py-2 text-gray-200 transition hover:bg-gray-700"
>
Zurücksetzen
</Button>
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 text-sm text-gray-300">
<span>Einträge pro Seite</span>
<select
value={perPage}
onChange={handlePerPageChange}
className="rounded-md border border-gray-700 bg-gray-900 px-2 py-1 text-sm text-gray-100 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
>
{PER_PAGE_OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="overflow-x-auto bg-gray-800/60 rounded-xl border border-gray-700">
<table className="min-w-full divide-y divide-gray-700">
<thead className="bg-gray-800">
<tr className="text-left text-sm uppercase tracking-wide text-gray-400">
<th className="px-4 py-3">Zeitpunkt</th>
<th className="px-4 py-3">Stack</th>
<th className="px-4 py-3">Art</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Nachricht</th>
<th className="px-4 py-3">Endpoint</th>
<th className="px-4 py-3 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 text-sm">
{logs.length === 0 && !loading && (
<tr>
<td colSpan="7" className="px-4 py-6 text-center text-gray-400">
Keine Logs vorhanden.
</td>
</tr>
)}
{logs.map((log) => {
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
const stackDisplayName = log.stackName || "Unbekannt";
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
const redeployTypeLabel = log.redeployType
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
: '---';
return (
<tr key={log.id} className="hover:bg-gray-700/40">
<td className="px-4 py-3 whitespace-nowrap text-gray-300">
{formatTimestamp(log.timestamp)}
</td>
<td className="px-4 py-3 text-gray-200">
<div className="flex flex-col">
<span className="font-medium">{stackDisplayName}</span>
{showStackId && (
<span className="text-xs text-gray-400">ID: {log.stackId}</span>
)}
</div>
</td>
<td className="px-4 py-3 text-gray-300">
{redeployTypeLabel}
</td>
<td className={`px-4 py-3 font-semibold ${statusClass}`}>
{log.status}
</td>
<td className="px-4 py-3 text-gray-300">
{log.message || "-"}
</td>
<td className="px-4 py-3 text-gray-400">
{log.endpoint ?? "-"}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDeleteLog(log.id)}
disabled={actionLoading}
className="rounded-md border border-red-600 px-3 py-1 text-xs text-red-300 transition hover:bg-red-600/20 disabled:opacity-60"
>
Löschen
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-300">
<span>
{totalLogs === 0
? "Keine Einträge"
: perPage === 'all'
? `Zeige alle ${totalLogs} Einträge`
: `Zeige ${rangeStart.toLocaleString()} – ${rangeEnd.toLocaleString()} von ${totalLogs.toLocaleString()} Einträgen`}
</span>
{perPage !== 'all' && (
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1 || actionLoading}
className="rounded-md border border-gray-600 px-3 py-1 text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Zurück
</button>
<span className="text-gray-400">
Seite {page} / {totalPages}
</span>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages || actionLoading}
className="rounded-md border border-gray-600 px-3 py-1 text-gray-200 transition hover:bg-gray-700 disabled:opacity-60"
>
Weiter
</button>
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

@@ -1,121 +0,0 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
const ToastContext = createContext(null);
const VARIANT_STYLES = {
success: {
container: "border-green-500/80 bg-green-950/90 text-green-100",
accent: "bg-green-400"
},
warning: {
container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100",
accent: "bg-yellow-400"
},
error: {
container: "border-red-500/80 bg-red-950/90 text-red-100",
accent: "bg-red-400"
},
info: {
container: "border-sky-500/60 bg-sky-950/90 text-sky-100",
accent: "bg-sky-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;
}
@@ -1,153 +0,0 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
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 [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 () => {
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]);
const refreshUpdateStatus = useCallback(async () => {
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 }));
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
useEffect(() => {
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;
}, [state.update?.running, refreshUpdateStatus]);
useEffect(() => {
const currentlyRunning = Boolean(state.update?.running);
if (!currentlyRunning && wasRunningRef.current) {
fetchConfig();
}
wasRunningRef.current = currentlyRunning;
}, [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 testSshConnection = useCallback(async (config) => {
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
return response.data;
}, []);
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,
triggerUpdate,
saveScript,
resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
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;
}
-18
View File
@@ -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;
}
-19
View File
@@ -1,19 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
import ToastProvider from "./components/ToastProvider.jsx";
import MaintenanceProvider from "./context/MaintenanceContext.jsx";
import './index.css'; // Tailwind CSS
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<MaintenanceProvider>
<ToastProvider>
<App />
</ToastProvider>
</MaintenanceProvider>
</BrowserRouter>
</React.StrictMode>
);
-13
View File
@@ -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: [],
}
-21
View File
@@ -1,21 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: true, // Hört auf allen Netzwerk-Interfaces, kein localhost notwendig
port: 5173, // optional, Standardport für Vite
proxy: {
'/api': {
target: 'http://127.0.0.1:4001', // Backend lokal auf Port 4001
changeOrigin: true
},
'/socket.io': {
target: 'http://127.0.0.1:4001', // WebSocket-Verbindungen über Proxy weiterleiten
ws: true,
changeOrigin: true
}
}
}
});