Update
This commit is contained in:
@@ -18,6 +18,12 @@ const selectSuperuser = db.prepare(`
|
||||
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, is_active, created_at, updated_at)
|
||||
@@ -31,8 +37,8 @@ const updateUser = db.prepare(`
|
||||
`);
|
||||
|
||||
const insertMembership = db.prepare(`
|
||||
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
const removeMembershipsForGroup = db.prepare(`
|
||||
@@ -70,24 +76,48 @@ const creationTransaction = db.transaction(({ username, email, passwordHash, pas
|
||||
}
|
||||
|
||||
removeMembershipsForGroup.run(groupId);
|
||||
insertMembership.run(userId, groupId, SUPERUSER_GROUP_NAME);
|
||||
insertMembership.run(userId, groupId);
|
||||
|
||||
return selectUserById.get(userId);
|
||||
});
|
||||
|
||||
function hashPassword(password) {
|
||||
export function hashPassword(password) {
|
||||
if (!password || typeof password !== 'string') {
|
||||
throw new Error('INVALID_PASSWORD');
|
||||
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) {
|
||||
throw new Error('PASSWORD_TOO_SHORT');
|
||||
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;
|
||||
@@ -101,6 +131,25 @@ export function hasSuperuser() {
|
||||
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();
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -58,6 +58,45 @@ CREATE TABLE IF NOT EXISTS user_group_memberships (
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
const createServersTable = `
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
`;
|
||||
|
||||
const createEndpointsTable = `
|
||||
CREATE TABLE IF NOT EXISTS endpoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||
UNIQUE (server_id, external_id)
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
const createServerApiKeysTable = `
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
`;
|
||||
|
||||
const createUserSettingsTable = `
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER NOT NULL,
|
||||
@@ -97,6 +136,15 @@ console.log('✅ user_groups table ready');
|
||||
db.exec(createUserGroupMembershipsTable);
|
||||
console.log('✅ user_group_memberships table ready');
|
||||
|
||||
db.exec(createServersTable);
|
||||
console.log('✅ servers table ready');
|
||||
|
||||
db.exec(createEndpointsTable);
|
||||
console.log('✅ endpoints table ready');
|
||||
|
||||
db.exec(createServerApiKeysTable);
|
||||
console.log('✅ server_api_keys table ready');
|
||||
|
||||
db.exec(createUserSettingsTable);
|
||||
console.log('✅ user_settings table ready');
|
||||
|
||||
|
||||
+586
-26
@@ -11,7 +11,17 @@ import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { db } from './db/index.js';
|
||||
import { ensureSuperuserFromEnv, getSuperuserSummary, hasSuperuser, registerSuperuser, removeSuperuser } from './auth/superuser.js';
|
||||
import {
|
||||
ensureSuperuserFromEnv,
|
||||
getSuperuserSummary,
|
||||
hasSuperuser,
|
||||
registerSuperuser,
|
||||
removeSuperuser,
|
||||
findUserByIdentifier,
|
||||
findUserById,
|
||||
markUserLogin,
|
||||
verifyPassword
|
||||
} from './auth/superuser.js';
|
||||
import {
|
||||
logRedeployEvent,
|
||||
buildLogFilter,
|
||||
@@ -21,10 +31,25 @@ import {
|
||||
} from './db/redeployLogs.js';
|
||||
import { getSetting, setSetting, deleteSetting } from './db/settings.js';
|
||||
import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js';
|
||||
import {
|
||||
ensureDefaultsFromEnv,
|
||||
getActiveEndpointExternalId,
|
||||
getActiveApiKey,
|
||||
getActiveServerUrl,
|
||||
hasServer,
|
||||
hasEndpoint,
|
||||
hasApiKey,
|
||||
getSetupStatus,
|
||||
completeSetup,
|
||||
removeEndpoint,
|
||||
removeServer,
|
||||
setServerApiKey
|
||||
} from './setup/index.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
ensureSuperuserFromEnv();
|
||||
ensureDefaultsFromEnv();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -39,13 +64,46 @@ app.get('*', (req, res, next) => {
|
||||
});
|
||||
|
||||
const PORT = 4001;
|
||||
const ENDPOINT_ID = Number(process.env.PORTAINER_ENDPOINT_ID);
|
||||
|
||||
const requireActiveEndpointId = () => {
|
||||
const value = getActiveEndpointExternalId();
|
||||
if (!value) {
|
||||
const error = new Error('ENDPOINT_NOT_CONFIGURED');
|
||||
error.code = 'ENDPOINT_NOT_CONFIGURED';
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const agent = new https.Agent({ rejectUnauthorized: false });
|
||||
|
||||
const resolvePortainerBaseUrl = () => {
|
||||
const envUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL.trim() : '';
|
||||
if (envUrl) return envUrl;
|
||||
const activeUrl = getActiveServerUrl();
|
||||
return activeUrl ? activeUrl.trim() : '';
|
||||
};
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
httpsAgent: agent,
|
||||
headers: { "X-API-Key": process.env.PORTAINER_API_KEY },
|
||||
baseURL: process.env.PORTAINER_URL,
|
||||
httpsAgent: agent
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const currentBase = typeof config.baseURL === 'string' ? config.baseURL.trim() : '';
|
||||
const effectiveBase = currentBase || resolvePortainerBaseUrl();
|
||||
if (!effectiveBase) {
|
||||
throw new Error('PORTAINER_URL_NOT_CONFIGURED');
|
||||
}
|
||||
config.baseURL = effectiveBase;
|
||||
|
||||
const apiKey = getActiveApiKey();
|
||||
if (apiKey) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers["X-API-Key"] = apiKey;
|
||||
} else if (config.headers?.["X-API-Key"]) {
|
||||
delete config.headers["X-API-Key"];
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
const REDEPLOY_PHASES = {
|
||||
@@ -58,6 +116,132 @@ const REDEPLOY_PHASES = {
|
||||
|
||||
const redeployingStacks = new Map();
|
||||
|
||||
const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME || 'sp_auth_token';
|
||||
const DEFAULT_SESSION_TTL_MS = 1000 * 60 * 60 * 12;
|
||||
const envSessionTtl = Number(process.env.AUTH_SESSION_TTL_MS);
|
||||
const AUTH_SESSION_TTL_MS = Number.isFinite(envSessionTtl) && envSessionTtl > 0 ? envSessionTtl : DEFAULT_SESSION_TTL_MS;
|
||||
|
||||
const AUTH_COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/'
|
||||
};
|
||||
|
||||
const activeSessions = new Map();
|
||||
|
||||
const PUBLIC_API_ROUTES = [
|
||||
{ method: 'GET', matcher: /^\/api\/auth\/superuser\/status$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/superuser\/register$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/login$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/auth\/logout$/ },
|
||||
{ method: 'GET', matcher: /^\/api\/auth\/session$/ },
|
||||
{ method: 'GET', matcher: /^\/api\/setup\/status$/ },
|
||||
{ method: 'POST', matcher: /^\/api\/setup\/complete$/ }
|
||||
];
|
||||
|
||||
const sanitizeUser = (user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
});
|
||||
|
||||
const cleanupExpiredSessions = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, session] of activeSessions.entries()) {
|
||||
if (!session || session.expiresAt <= now) {
|
||||
activeSessions.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSessionsForUser = (userId) => {
|
||||
if (!userId) return;
|
||||
for (const [token, session] of activeSessions.entries()) {
|
||||
if (session?.userId === userId) {
|
||||
activeSessions.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createSessionForUser = (user) => {
|
||||
cleanupExpiredSessions();
|
||||
removeSessionsForUser(user.id);
|
||||
const token = crypto.randomBytes(48).toString('hex');
|
||||
const expiresAt = Date.now() + AUTH_SESSION_TTL_MS;
|
||||
activeSessions.set(token, { userId: user.id, expiresAt });
|
||||
return { token, expiresAt };
|
||||
};
|
||||
|
||||
const getSessionRecord = (token) => {
|
||||
if (!token) return null;
|
||||
cleanupExpiredSessions();
|
||||
const record = activeSessions.get(token);
|
||||
if (!record) return null;
|
||||
if (record.expiresAt <= Date.now()) {
|
||||
activeSessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
};
|
||||
|
||||
const touchSession = (token) => {
|
||||
if (!token) return;
|
||||
const record = activeSessions.get(token);
|
||||
if (!record) return;
|
||||
record.expiresAt = Date.now() + AUTH_SESSION_TTL_MS;
|
||||
activeSessions.set(token, record);
|
||||
};
|
||||
|
||||
const extractAuthToken = (req) => {
|
||||
const rawCookie = req.headers?.cookie;
|
||||
if (rawCookie && typeof rawCookie === 'string') {
|
||||
const parts = rawCookie.split(';');
|
||||
for (const part of parts) {
|
||||
const [name, ...rest] = part.trim().split('=');
|
||||
if (name === AUTH_COOKIE_NAME) {
|
||||
return rest.join('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
||||
if (authHeader && typeof authHeader === 'string') {
|
||||
const lower = authHeader.toLowerCase();
|
||||
if (lower.startsWith('bearer ')) {
|
||||
return authHeader.slice(7).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setAuthCookie = (res, token) => {
|
||||
if (!token || !res) return;
|
||||
if (typeof res.cookie === 'function') {
|
||||
res.cookie(AUTH_COOKIE_NAME, token, {
|
||||
...AUTH_COOKIE_OPTIONS,
|
||||
maxAge: AUTH_SESSION_TTL_MS
|
||||
});
|
||||
} else {
|
||||
res.setHeader('Set-Cookie', `${AUTH_COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax`);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAuthCookie = (res) => {
|
||||
if (!res) return;
|
||||
if (typeof res.clearCookie === 'function') {
|
||||
res.clearCookie(AUTH_COOKIE_NAME, {
|
||||
...AUTH_COOKIE_OPTIONS,
|
||||
maxAge: 0
|
||||
});
|
||||
} else {
|
||||
res.setHeader('Set-Cookie', `${AUTH_COOKIE_NAME}=; Path=/; Max-Age=0`);
|
||||
}
|
||||
};
|
||||
|
||||
const isPublicApiRoute = (req) => {
|
||||
return PUBLIC_API_ROUTES.some(({ method, matcher }) => req.method === method && matcher.test(req.path));
|
||||
};
|
||||
|
||||
const isActiveRedeployPhase = (phase) => phase === REDEPLOY_PHASES.QUEUED || phase === REDEPLOY_PHASES.STARTED;
|
||||
|
||||
const resolveRedeployPhase = (phase, message) => {
|
||||
@@ -461,7 +645,8 @@ const testSshConnection = async (configOverride = null) => {
|
||||
|
||||
const detectPortainerContainer = async () => {
|
||||
try {
|
||||
const containersRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json`, {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
const containersRes = await axiosInstance.get(`/api/endpoints/${endpointId}/docker/containers/json`, {
|
||||
params: { all: true }
|
||||
});
|
||||
const containers = Array.isArray(containersRes.data) ? containersRes.data : [];
|
||||
@@ -487,7 +672,7 @@ const detectPortainerContainer = async () => {
|
||||
return { summary: null, error: 'Portainer-Container nicht gefunden' };
|
||||
}
|
||||
|
||||
const inspectRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/${matchedContainer.Id}/json`);
|
||||
const inspectRes = await axiosInstance.get(`/api/endpoints/${endpointId}/docker/containers/${matchedContainer.Id}/json`);
|
||||
const inspect = inspectRes.data ?? {};
|
||||
|
||||
const trimName = (value) => (typeof value === 'string' ? value.replace(/^\//, '') : value);
|
||||
@@ -732,6 +917,7 @@ let currentPortainerUpdatePromise = null;
|
||||
|
||||
const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => {
|
||||
let maintenanceActivated = false;
|
||||
const endpointId = requireActiveEndpointId();
|
||||
|
||||
try {
|
||||
addUpdateLog(`Portainer-Update gestartet (Quelle: ${scriptSource})`, 'info');
|
||||
@@ -758,7 +944,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'Portainer',
|
||||
status: 'started',
|
||||
message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
|
||||
@@ -767,7 +953,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'StackPulse Wartung',
|
||||
status: 'started',
|
||||
message: 'Wartungsmodus aktiviert (Portainer Update)',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
|
||||
@@ -797,7 +983,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'Portainer',
|
||||
status: 'success',
|
||||
message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
|
||||
@@ -821,7 +1007,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'StackPulse Wartung',
|
||||
status: 'success',
|
||||
message: 'Wartungsmodus deaktiviert',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
}
|
||||
@@ -832,7 +1018,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'Portainer',
|
||||
status: 'error',
|
||||
message,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
|
||||
@@ -854,7 +1040,7 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
stackName: 'StackPulse Wartung',
|
||||
status: 'error',
|
||||
message: 'Wartungsmodus deaktiviert (Fehler)',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.MAINTENANCE
|
||||
});
|
||||
}
|
||||
@@ -864,8 +1050,9 @@ const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) =
|
||||
};
|
||||
|
||||
const fetchPortainerStacks = async () => {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
||||
return stacksRes.data.filter((stack) => stack.EndpointId === ENDPOINT_ID);
|
||||
return stacksRes.data.filter((stack) => String(stack.EndpointId) === String(endpointId));
|
||||
};
|
||||
|
||||
const buildStackCollections = (stacks = []) => {
|
||||
@@ -982,13 +1169,14 @@ const shouldFallbackToStackFile = (message) => {
|
||||
const redeployStackById = async (stackId, redeployType) => {
|
||||
let stack;
|
||||
const messages = getRedeployMessages(redeployType);
|
||||
const endpointId = requireActiveEndpointId();
|
||||
|
||||
try {
|
||||
const stackRes = await axiosInstance.get(`/api/stacks/${stackId}`);
|
||||
stack = stackRes.data;
|
||||
|
||||
if (stack.EndpointId !== ENDPOINT_ID) {
|
||||
throw new Error(`Stack gehört nicht zum Endpoint ${ENDPOINT_ID}`);
|
||||
if (String(stack.EndpointId) !== String(endpointId)) {
|
||||
throw new Error(`Stack gehört nicht zum Endpoint ${endpointId}`);
|
||||
}
|
||||
|
||||
const targetId = stack.Id || stackId;
|
||||
@@ -1107,7 +1295,7 @@ const redeployStackById = async (stackId, redeployType) => {
|
||||
stackName: fallbackName,
|
||||
status: 'error',
|
||||
message: errorMessage,
|
||||
endpoint: stack?.EndpointId || ENDPOINT_ID,
|
||||
endpoint: stack?.EndpointId || endpointId,
|
||||
redeployType
|
||||
});
|
||||
|
||||
@@ -1116,8 +1304,373 @@ const redeployStackById = async (stackId, redeployType) => {
|
||||
}
|
||||
};
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (!req.path.startsWith('/api')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS' || isPublicApiRoute(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!hasSuperuser()) {
|
||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||
}
|
||||
|
||||
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
|
||||
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
||||
}
|
||||
|
||||
const token = extractAuthToken(req);
|
||||
const session = getSessionRecord(token);
|
||||
if (!session) {
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId);
|
||||
if (!user || !user.is_active) {
|
||||
activeSessions.delete(token);
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
req.user = sanitizeUser(user);
|
||||
req.authToken = token;
|
||||
touchSession(token);
|
||||
setAuthCookie(res, token);
|
||||
next();
|
||||
});
|
||||
|
||||
// --- API Endpoints ---
|
||||
|
||||
app.get('/api/setup/status', (req, res) => {
|
||||
try {
|
||||
const status = getSetupStatus();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Statusabfrage fehlgeschlagen:', error);
|
||||
res.status(500).json({ error: 'SETUP_STATUS_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/setup/complete', (req, res) => {
|
||||
const { superuser: superuserInput, server: serverInput, endpoint: endpointInput, apiKey: apiKeyInput } = req.body ?? {};
|
||||
const initialStatus = getSetupStatus();
|
||||
const needsSuperuser = initialStatus.requirements.superuser;
|
||||
const needsServer = initialStatus.requirements.server;
|
||||
const needsEndpoint = initialStatus.requirements.endpoint;
|
||||
const needsApiKey = initialStatus.requirements.apiKey;
|
||||
|
||||
const created = {};
|
||||
|
||||
const normalizeServerInput = (input) => {
|
||||
if (!input) return null;
|
||||
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
||||
const url = typeof input.url === 'string' ? input.url.trim() : '';
|
||||
if (!url) {
|
||||
return { name: name || '', url: '' };
|
||||
}
|
||||
return {
|
||||
name: name || null,
|
||||
url
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeEndpointInput = (input) => {
|
||||
if (!input) return null;
|
||||
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
||||
const externalRaw = input.externalId !== undefined && input.externalId !== null
|
||||
? String(input.externalId).trim()
|
||||
: '';
|
||||
const serverIdRaw = input.serverId !== undefined && input.serverId !== null ? Number(input.serverId) : null;
|
||||
const serverId = Number.isFinite(serverIdRaw) ? serverIdRaw : null;
|
||||
|
||||
return {
|
||||
name: name || null,
|
||||
externalId: externalRaw || '',
|
||||
serverId
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeApiKeyInput = (input) => {
|
||||
if (!input) return null;
|
||||
if (typeof input === 'string') {
|
||||
const value = input.trim();
|
||||
return { value, serverId: null };
|
||||
}
|
||||
if (typeof input === 'object') {
|
||||
const rawValue = typeof input.value === 'string' ? input.value : typeof input.key === 'string' ? input.key : '';
|
||||
const value = rawValue.trim();
|
||||
const serverIdRaw = input.serverId !== undefined && input.serverId !== null ? Number(input.serverId) : null;
|
||||
const serverId = Number.isFinite(serverIdRaw) ? serverIdRaw : null;
|
||||
return { value, serverId };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
let serverPayload = normalizeServerInput(serverInput);
|
||||
let endpointPayload = normalizeEndpointInput(endpointInput);
|
||||
const apiKeyPayload = normalizeApiKeyInput(apiKeyInput);
|
||||
|
||||
if (needsServer && (!serverPayload || !serverPayload.url)) {
|
||||
return res.status(400).json({ error: 'SERVER_DETAILS_REQUIRED' });
|
||||
}
|
||||
|
||||
if (endpointPayload && !endpointPayload.externalId) {
|
||||
endpointPayload.externalId = '';
|
||||
}
|
||||
|
||||
if (needsEndpoint && (!endpointPayload || !endpointPayload.externalId)) {
|
||||
const fallbackExternal = initialStatus.envDefaults.endpointExternalId?.trim();
|
||||
if (fallbackExternal) {
|
||||
endpointPayload = endpointPayload || {};
|
||||
endpointPayload.externalId = fallbackExternal;
|
||||
endpointPayload.name = endpointPayload.name || initialStatus.envDefaults.endpointName || `Endpoint ${fallbackExternal}`;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'ENDPOINT_DETAILS_REQUIRED' });
|
||||
}
|
||||
}
|
||||
|
||||
const shouldHandleInfrastructure =
|
||||
needsServer ||
|
||||
needsEndpoint ||
|
||||
(serverPayload && serverPayload.url) ||
|
||||
(endpointPayload && endpointPayload.externalId);
|
||||
|
||||
if (shouldHandleInfrastructure) {
|
||||
const endpointInputPayload = endpointPayload;
|
||||
if (!endpointInputPayload || !endpointInputPayload.externalId) {
|
||||
return res.status(400).json({ error: 'ENDPOINT_DETAILS_REQUIRED' });
|
||||
}
|
||||
|
||||
const setupResult = completeSetup({
|
||||
server: serverPayload && serverPayload.url ? serverPayload : null,
|
||||
endpoint: endpointInputPayload
|
||||
});
|
||||
|
||||
created.server = setupResult.server;
|
||||
created.endpoint = setupResult.endpoint;
|
||||
created.defaultEndpoint = setupResult.defaultEndpoint;
|
||||
}
|
||||
|
||||
let targetServerId = apiKeyPayload?.serverId ?? created.server?.id ?? endpointPayload?.serverId ?? null;
|
||||
|
||||
if (needsSuperuser) {
|
||||
const username = typeof superuserInput?.username === 'string' ? superuserInput.username.trim() : '';
|
||||
const email = typeof superuserInput?.email === 'string' ? superuserInput.email.trim() : '';
|
||||
const password = typeof superuserInput?.password === 'string' ? superuserInput.password : '';
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({ error: 'SUPERUSER_DETAILS_REQUIRED' });
|
||||
}
|
||||
|
||||
const user = registerSuperuser({ username, email, password });
|
||||
created.superuser = user;
|
||||
}
|
||||
|
||||
const statusSnapshot = getSetupStatus();
|
||||
|
||||
if (!targetServerId) {
|
||||
targetServerId = statusSnapshot.servers.items?.[0]?.id ?? null;
|
||||
}
|
||||
|
||||
if (apiKeyPayload && apiKeyPayload.value) {
|
||||
if (!targetServerId) {
|
||||
return res.status(400).json({ error: 'SERVER_DETAILS_REQUIRED' });
|
||||
}
|
||||
const apiKeyResult = setServerApiKey({ serverId: targetServerId, apiKey: apiKeyPayload.value });
|
||||
created.apiKey = apiKeyResult;
|
||||
} else if (needsApiKey) {
|
||||
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
|
||||
}
|
||||
|
||||
const finalStatus = getSetupStatus();
|
||||
created.defaultEndpoint = finalStatus.endpoints.default;
|
||||
res.status(201).json({
|
||||
success: finalStatus.setupComplete,
|
||||
status: finalStatus,
|
||||
created
|
||||
});
|
||||
} catch (error) {
|
||||
const code = error.code || error.message;
|
||||
switch (code) {
|
||||
case 'SERVER_URL_REQUIRED':
|
||||
case 'SERVER_NAME_REQUIRED':
|
||||
case 'SERVER_DETAILS_REQUIRED':
|
||||
case 'ENDPOINT_DETAILS_REQUIRED':
|
||||
case 'ENDPOINT_EXTERNAL_ID_REQUIRED':
|
||||
case 'USERNAME_REQUIRED':
|
||||
case 'EMAIL_INVALID':
|
||||
case 'PASSWORD_TOO_SHORT':
|
||||
case 'INVALID_PASSWORD':
|
||||
case 'API_KEY_REQUIRED':
|
||||
return res.status(400).json({ error: code });
|
||||
case 'API_KEY_ENCRYPT_FAILED':
|
||||
console.error('⚠️ [Setup] API-Key Verschlüsselung fehlgeschlagen:', error);
|
||||
return res.status(500).json({ error: 'API_KEY_ENCRYPT_FAILED' });
|
||||
case 'SERVER_NOT_FOUND':
|
||||
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
|
||||
case 'SUPERUSER_ALREADY_EXISTS':
|
||||
return res.status(409).json({ error: 'SUPERUSER_EXISTS' });
|
||||
default:
|
||||
console.error('⚠️ [Setup] Fehler beim Abschluss:', error);
|
||||
return res.status(500).json({ error: 'SETUP_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/setup/endpoints/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
return res.status(400).json({ error: 'ENDPOINT_ID_INVALID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = removeEndpoint(numericId);
|
||||
const status = getSetupStatus();
|
||||
res.json({ success: true, removed: result, status });
|
||||
} catch (error) {
|
||||
const code = error.code || error.message;
|
||||
switch (code) {
|
||||
case 'ENDPOINT_ID_INVALID':
|
||||
return res.status(400).json({ error: 'ENDPOINT_ID_INVALID' });
|
||||
case 'ENDPOINT_NOT_FOUND':
|
||||
return res.status(404).json({ error: 'ENDPOINT_NOT_FOUND' });
|
||||
default:
|
||||
console.error('⚠️ [Setup] Endpoint konnte nicht gelöscht werden:', error);
|
||||
return res.status(500).json({ error: 'ENDPOINT_DELETE_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/setup/servers/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = removeServer(numericId);
|
||||
const status = getSetupStatus();
|
||||
res.json({ success: true, removed: result, status });
|
||||
} catch (error) {
|
||||
const code = error.code || error.message;
|
||||
switch (code) {
|
||||
case 'SERVER_ID_INVALID':
|
||||
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
|
||||
case 'SERVER_NOT_FOUND':
|
||||
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
|
||||
default:
|
||||
console.error('⚠️ [Setup] Server konnte nicht gelöscht werden:', error);
|
||||
return res.status(500).json({ error: 'SERVER_DELETE_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/setup/servers/:id/api-key', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
|
||||
}
|
||||
|
||||
const rawValue = typeof req.body?.apiKey === 'string' ? req.body.apiKey : typeof req.body?.key === 'string' ? req.body.key : '';
|
||||
|
||||
try {
|
||||
const result = setServerApiKey({ serverId: numericId, apiKey: rawValue });
|
||||
const status = getSetupStatus();
|
||||
res.json({ success: true, updated: result, status });
|
||||
} catch (error) {
|
||||
const code = error.code || error.message;
|
||||
switch (code) {
|
||||
case 'SERVER_ID_INVALID':
|
||||
return res.status(400).json({ error: 'SERVER_ID_INVALID' });
|
||||
case 'SERVER_NOT_FOUND':
|
||||
return res.status(404).json({ error: 'SERVER_NOT_FOUND' });
|
||||
case 'API_KEY_REQUIRED':
|
||||
return res.status(400).json({ error: 'API_KEY_REQUIRED' });
|
||||
case 'API_KEY_ENCRYPT_FAILED':
|
||||
console.error('⚠️ [Setup] API-Key Verschlüsselung fehlgeschlagen:', error);
|
||||
return res.status(500).json({ error: 'API_KEY_ENCRYPT_FAILED' });
|
||||
default:
|
||||
console.error('⚠️ [Setup] API-Key konnte nicht aktualisiert werden:', error);
|
||||
return res.status(500).json({ error: 'API_KEY_UPDATE_FAILED' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
if (!hasSuperuser()) {
|
||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||
}
|
||||
|
||||
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
|
||||
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
||||
}
|
||||
|
||||
const { identifier, password } = req.body ?? {};
|
||||
if (!identifier || !password) {
|
||||
return res.status(400).json({ error: 'MISSING_CREDENTIALS' });
|
||||
}
|
||||
|
||||
const user = findUserByIdentifier(identifier);
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||
}
|
||||
|
||||
const passwordValid = verifyPassword(password, user.password_hash, user.password_salt);
|
||||
if (!passwordValid) {
|
||||
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||
}
|
||||
|
||||
const session = createSessionForUser(user);
|
||||
markUserLogin(user.id);
|
||||
setAuthCookie(res, session.token);
|
||||
|
||||
res.json({ user: sanitizeUser(user) });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
const token = extractAuthToken(req);
|
||||
if (token) {
|
||||
activeSessions.delete(token);
|
||||
}
|
||||
clearAuthCookie(res);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/auth/session', (req, res) => {
|
||||
if (!hasSuperuser()) {
|
||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||
}
|
||||
|
||||
if (!hasServer() || !hasEndpoint() || !hasApiKey()) {
|
||||
return res.status(403).json({ error: 'SETUP_REQUIRED' });
|
||||
}
|
||||
|
||||
const token = extractAuthToken(req);
|
||||
const session = getSessionRecord(token);
|
||||
if (!session) {
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId);
|
||||
if (!user || !user.is_active) {
|
||||
activeSessions.delete(token);
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
touchSession(token);
|
||||
setAuthCookie(res, token);
|
||||
res.json({ user: sanitizeUser(user) });
|
||||
});
|
||||
|
||||
// Superuser Setup
|
||||
app.get('/api/auth/superuser/status', (req, res) => {
|
||||
const exists = hasSuperuser();
|
||||
@@ -1225,6 +1778,9 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => {
|
||||
res.json(stacksWithStatus);
|
||||
} catch (err) {
|
||||
console.error(`❌ Fehler beim Abrufen der Stacks:`, err.message);
|
||||
if (err.message === 'PORTAINER_URL_NOT_CONFIGURED') {
|
||||
return res.status(503).json({ error: 'PORTAINER_URL_NOT_CONFIGURED' });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
@@ -1237,6 +1793,9 @@ app.get('/api/maintenance/portainer-status', async (req, res) => {
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.message || err.message || 'Unbekannter Fehler';
|
||||
console.error(`❌ [Maintenance] Fehler beim Prüfen des Portainer-Status: ${message}`);
|
||||
if (message === 'PORTAINER_URL_NOT_CONFIGURED') {
|
||||
return res.status(503).json({ error: 'PORTAINER_URL_NOT_CONFIGURED' });
|
||||
}
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
@@ -1737,8 +2296,9 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
||||
|
||||
try {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
const stacksRes = await axiosInstance.get('/api/stacks');
|
||||
const filteredStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID);
|
||||
const filteredStacks = stacksRes.data.filter(stack => String(stack.EndpointId) === String(endpointId));
|
||||
|
||||
console.log("📦 Redeploy ALL für folgende Stacks:");
|
||||
filteredStacks.forEach(s => console.log(` - ${s.Name}`));
|
||||
@@ -1758,7 +2318,7 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
stackName: '---',
|
||||
status: 'started',
|
||||
message: `Redeploy ALL gestartet für: ${stackSummary}`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
|
||||
@@ -1789,7 +2349,7 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
stackName: '---',
|
||||
status: 'success',
|
||||
message: 'Redeploy ALL abgeschlossen',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
|
||||
@@ -1801,7 +2361,7 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
stackName: '---',
|
||||
status: 'error',
|
||||
message,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.ALL
|
||||
});
|
||||
console.error('❌ Fehler bei Redeploy ALL:', message);
|
||||
@@ -1820,6 +2380,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
}
|
||||
|
||||
try {
|
||||
const endpointId = requireActiveEndpointId();
|
||||
const normalizedIds = stackIds.map((id) => String(id));
|
||||
const { filteredStacks } = await loadStackCollections();
|
||||
const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack]));
|
||||
@@ -1850,7 +2411,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
stackName: `Auswahl (${stackIds.length})`,
|
||||
status: 'started',
|
||||
message: `Redeploy Auswahl gestartet für: ${summaryText}`,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
|
||||
@@ -1860,7 +2421,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
stackName: `Auswahl (${stackIds.length})`,
|
||||
status: 'success',
|
||||
message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' });
|
||||
@@ -1888,7 +2449,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
stackName: `Auswahl (${stackIds.length})`,
|
||||
status: 'success',
|
||||
message: 'Redeploy Auswahl abgeschlossen',
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
|
||||
@@ -1900,7 +2461,7 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
stackName: `Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`,
|
||||
status: 'error',
|
||||
message,
|
||||
endpoint: ENDPOINT_ID,
|
||||
endpoint: endpointId,
|
||||
redeployType: REDEPLOY_TYPES.SELECTION
|
||||
});
|
||||
console.error('❌ Fehler bei Redeploy Auswahl:', message);
|
||||
@@ -1911,4 +2472,3 @@ app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) =>
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Server läuft auf Port ${PORT}`);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -32,8 +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>
|
||||
<script type="module" crossorigin src="/assets/index-61a55789.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-8ee0592b.css">
|
||||
<script type="module" crossorigin src="/assets/index-999a8a11.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-4350b70a.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
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 selectAllEndpoints = db.prepare(`
|
||||
SELECT e.*, s.name as server_name, s.url as server_url
|
||||
FROM endpoints e
|
||||
INNER JOIN servers s ON s.id = e.server_id
|
||||
ORDER BY e.id ASC
|
||||
`);
|
||||
const selectEndpointById = db.prepare('SELECT * FROM endpoints WHERE id = ?');
|
||||
const selectEndpointByExternalId = db.prepare('SELECT * FROM endpoints WHERE external_id = ?');
|
||||
const selectEndpointByServerAndExternal = db.prepare('SELECT * FROM endpoints WHERE server_id = ? AND external_id = ?');
|
||||
const selectEndpointsByServerId = db.prepare('SELECT * FROM endpoints WHERE server_id = ?');
|
||||
const insertEndpoint = db.prepare(`
|
||||
INSERT INTO endpoints (server_id, name, external_id, is_default)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const updateEndpoint = db.prepare(`
|
||||
UPDATE endpoints
|
||||
SET name = ?, external_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
const deleteEndpointStmt = db.prepare('DELETE FROM endpoints 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 clearDefaultEndpointStmt = db.prepare('UPDATE endpoints SET is_default = 0 WHERE is_default != 0');
|
||||
const setDefaultEndpointStmt = db.prepare('UPDATE endpoints SET is_default = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
|
||||
const selectDefaultEndpointStmt = db.prepare(`
|
||||
SELECT e.*, s.name as server_name, s.url as server_url
|
||||
FROM endpoints e
|
||||
INNER JOIN servers s ON s.id = e.server_id
|
||||
WHERE e.is_default = 1
|
||||
LIMIT 1
|
||||
`);
|
||||
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
|
||||
const countEndpointsStmt = db.prepare('SELECT COUNT(*) as count FROM endpoints');
|
||||
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
|
||||
const selectFirstEndpointStmt = db.prepare(`
|
||||
SELECT e.*, s.name as server_name, s.url as server_url
|
||||
FROM endpoints e
|
||||
INNER JOIN servers s ON s.id = e.server_id
|
||||
ORDER BY e.id ASC
|
||||
LIMIT 1
|
||||
`);
|
||||
const transactionalSetDefaultEndpoint = db.transaction((endpointId) => {
|
||||
clearDefaultEndpointStmt.run();
|
||||
setDefaultEndpointStmt.run(endpointId);
|
||||
});
|
||||
|
||||
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 ensureEndpoint({ serverId, name, externalId, makeDefault = false }) {
|
||||
if (!serverId) {
|
||||
const error = new Error('SERVER_REQUIRED');
|
||||
error.code = 'SERVER_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const server = selectServerById.get(serverId);
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const trimmedExternal = String(externalId ?? '').trim();
|
||||
if (!trimmedExternal) {
|
||||
const error = new Error('ENDPOINT_EXTERNAL_ID_REQUIRED');
|
||||
error.code = 'ENDPOINT_EXTERNAL_ID_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedName = String(name || `Endpoint ${trimmedExternal}`).trim();
|
||||
if (!normalizedName) {
|
||||
const error = new Error('ENDPOINT_NAME_REQUIRED');
|
||||
error.code = 'ENDPOINT_NAME_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let existing = selectEndpointByServerAndExternal.get(serverId, trimmedExternal);
|
||||
if (existing) {
|
||||
if (existing.name !== normalizedName || existing.external_id !== trimmedExternal) {
|
||||
updateEndpoint.run(normalizedName, trimmedExternal, existing.id);
|
||||
existing = selectEndpointById.get(existing.id);
|
||||
console.log(`ℹ️ [Setup] Endpoint aktualisiert: ${existing.name} (${existing.id}) für Server ${server.name} (${server.id})`);
|
||||
}
|
||||
} else {
|
||||
const isDefault = makeDefault || countEndpointsStmt.get().count === 0 ? 1 : 0;
|
||||
const result = insertEndpoint.run(serverId, normalizedName, trimmedExternal, isDefault);
|
||||
existing = selectEndpointById.get(result.lastInsertRowid);
|
||||
console.log(`✅ [Setup] Endpoint angelegt: ${existing.name} (${existing.id}) für Server ${server.name} (${server.id})`);
|
||||
}
|
||||
|
||||
if (makeDefault && existing && !existing.is_default) {
|
||||
transactionalSetDefaultEndpoint(existing.id);
|
||||
existing = selectEndpointById.get(existing.id);
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
function removeEndpoint(endpointId) {
|
||||
const id = Number(endpointId);
|
||||
if (!Number.isFinite(id)) {
|
||||
const error = new Error('ENDPOINT_ID_INVALID');
|
||||
error.code = 'ENDPOINT_ID_INVALID';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const endpoint = selectEndpointById.get(id);
|
||||
if (!endpoint) {
|
||||
const error = new Error('ENDPOINT_NOT_FOUND');
|
||||
error.code = 'ENDPOINT_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const wasDefault = Boolean(endpoint.is_default);
|
||||
deleteEndpointStmt.run(id);
|
||||
|
||||
if (wasDefault) {
|
||||
const fallback = selectFirstEndpointStmt.get();
|
||||
if (fallback) {
|
||||
transactionalSetDefaultEndpoint(fallback.id);
|
||||
console.log(`ℹ️ [Setup] Neuer Standard-Endpoint gesetzt: ${fallback.name} (${fallback.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🗑️ [Setup] Endpoint entfernt: ${endpoint.name} (${endpoint.id}) für Server ${endpoint.server_id}`);
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
removed: true
|
||||
};
|
||||
}
|
||||
|
||||
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 setDefaultEndpoint(endpointId) {
|
||||
if (!endpointId) return null;
|
||||
const endpoint = selectEndpointById.get(endpointId);
|
||||
if (!endpoint) {
|
||||
const error = new Error('ENDPOINT_NOT_FOUND');
|
||||
error.code = 'ENDPOINT_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
transactionalSetDefaultEndpoint(endpointId);
|
||||
return selectEndpointById.get(endpointId);
|
||||
}
|
||||
|
||||
function getDefaultEndpoint() {
|
||||
let endpoint = selectDefaultEndpointStmt.get();
|
||||
if (!endpoint) {
|
||||
endpoint = selectFirstEndpointStmt.get();
|
||||
if (endpoint) {
|
||||
transactionalSetDefaultEndpoint(endpoint.id);
|
||||
endpoint = selectDefaultEndpointStmt.get();
|
||||
}
|
||||
}
|
||||
return endpoint || null;
|
||||
}
|
||||
|
||||
function getActiveEndpointExternalId() {
|
||||
const endpoint = getDefaultEndpoint();
|
||||
return endpoint ? endpoint.external_id : null;
|
||||
}
|
||||
|
||||
function getActiveApiKey() {
|
||||
const defaultEndpoint = getDefaultEndpoint();
|
||||
if (defaultEndpoint) {
|
||||
const row = selectApiKeyByServerId.get(defaultEndpoint.server_id);
|
||||
const key = decryptApiKey(row);
|
||||
if (key) return key;
|
||||
}
|
||||
|
||||
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 endpoint = getDefaultEndpoint();
|
||||
if (endpoint) {
|
||||
const normalizedEndpointUrl = endpoint.server_url ? normalizeUrl(endpoint.server_url) : '';
|
||||
if (normalizedEndpointUrl) {
|
||||
return normalizedEndpointUrl;
|
||||
}
|
||||
const server = selectServerById.get(endpoint.server_id);
|
||||
if (server?.url) {
|
||||
const normalizedServerUrl = normalizeUrl(server.url);
|
||||
if (normalizedServerUrl) {
|
||||
return normalizedServerUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 relatedEndpoints = selectEndpointsByServerId.all(id);
|
||||
const hadDefaultEndpoint = relatedEndpoints.some((endpoint) => endpoint.is_default);
|
||||
const existingApiKey = selectApiKeyByServerId.get(id);
|
||||
|
||||
deleteServerStmt.run(id);
|
||||
|
||||
if (existingApiKey) {
|
||||
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
|
||||
}
|
||||
|
||||
if (hadDefaultEndpoint) {
|
||||
const fallback = getDefaultEndpoint();
|
||||
if (fallback) {
|
||||
console.log(`ℹ️ [Setup] Neuer Standard-Endpoint gesetzt: ${fallback.name} (${fallback.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id}) – entfernte Endpoints: ${relatedEndpoints.length}`);
|
||||
|
||||
return {
|
||||
server,
|
||||
removed: true,
|
||||
endpointsRemoved: relatedEndpoints.length
|
||||
};
|
||||
}
|
||||
|
||||
function hasEndpoint() {
|
||||
const { count } = countEndpointsStmt.get();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function hasApiKey() {
|
||||
const { count } = countApiKeysStmt.get();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function hasCompleteSetup() {
|
||||
return hasSuperuser() && hasServer() && hasEndpoint() && hasApiKey();
|
||||
}
|
||||
|
||||
function ensureDefaultsFromEnv() {
|
||||
const envServerUrlRaw = process.env.PORTAINER_URL;
|
||||
const envServerName = process.env.PORTAINER_SERVER_NAME;
|
||||
const envEndpointIdRaw = process.env.PORTAINER_ENDPOINT_ID;
|
||||
const envEndpointName = process.env.PORTAINER_ENDPOINT_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 trimmedEndpointId = typeof envEndpointIdRaw === 'string' ? envEndpointIdRaw.trim() : '';
|
||||
if (trimmedEndpointId) {
|
||||
const existingEndpoint = selectEndpointByExternalId.get(trimmedEndpointId);
|
||||
if (!existingEndpoint) {
|
||||
const targetServer = server || selectFirstServerStmt.get();
|
||||
if (targetServer) {
|
||||
try {
|
||||
const endpointName = envEndpointName || `Endpoint ${trimmedEndpointId}`;
|
||||
ensureEndpoint({
|
||||
serverId: targetServer.id,
|
||||
name: endpointName,
|
||||
externalId: trimmedEndpointId,
|
||||
makeDefault: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('⚠️ [Setup] Konnte Endpoint aus Umgebungsvariablen nicht anlegen:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ [Setup] Endpoint aus Umgebungsvariablen benötigt einen vorhandenen Server.');
|
||||
}
|
||||
} else if (!existingEndpoint.is_default) {
|
||||
transactionalSetDefaultEndpoint(existingEndpoint.id);
|
||||
}
|
||||
}
|
||||
|
||||
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 endpoints = selectAllEndpoints.all();
|
||||
const defaultEndpoint = getDefaultEndpoint();
|
||||
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 rawEnvEndpointName = typeof process.env.PORTAINER_ENDPOINT_NAME === 'string' ? process.env.PORTAINER_ENDPOINT_NAME : '';
|
||||
const envEndpointName = rawEnvEndpointName.trim();
|
||||
const rawEnvEndpointId = typeof process.env.PORTAINER_ENDPOINT_ID === 'string' ? process.env.PORTAINER_ENDPOINT_ID : '';
|
||||
const envEndpointId = rawEnvEndpointId.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 endpointRequired = endpoints.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 && !endpointRequired && !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)
|
||||
},
|
||||
endpoints: {
|
||||
count: endpoints.length,
|
||||
items: endpoints,
|
||||
requireInput: endpointRequired,
|
||||
envProvided: Boolean(envEndpointId),
|
||||
default: defaultEndpoint
|
||||
},
|
||||
apiKeys: {
|
||||
count: apiKeyCount,
|
||||
items: apiKeyItems,
|
||||
requireInput: apiKeyRequired,
|
||||
envProvided: envApiKeyProvided,
|
||||
envValue: rawEnvApiKey
|
||||
},
|
||||
requirements: {
|
||||
superuser: !superuserExists,
|
||||
server: serverRequired,
|
||||
endpoint: endpointRequired,
|
||||
apiKey: apiKeyRequired
|
||||
},
|
||||
setupComplete,
|
||||
envDefaults: {
|
||||
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
|
||||
serverNameFromEnv: rawEnvServerName,
|
||||
serverUrl: envServerUrl,
|
||||
endpointName: envEndpointName || (envEndpointId ? `Endpoint ${envEndpointId}` : ''),
|
||||
endpointNameFromEnv: rawEnvEndpointName,
|
||||
endpointExternalId: envEndpointId,
|
||||
apiKeyProvided: envApiKeyProvided,
|
||||
apiKeyValue: rawEnvApiKey,
|
||||
superuserUsername: envSuperuserUsernameRaw,
|
||||
superuserEmail: envSuperuserEmailRaw,
|
||||
superuserPassword: envSuperuserPasswordRaw
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const createEndpointWithDefaultTransaction = db.transaction(({ serverInput, endpointInput }) => {
|
||||
let server = null;
|
||||
|
||||
if (endpointInput?.serverId) {
|
||||
const byId = selectServerById.get(endpointInput.serverId);
|
||||
if (!byId) {
|
||||
const error = new Error('SERVER_NOT_FOUND');
|
||||
error.code = 'SERVER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
server = byId;
|
||||
}
|
||||
|
||||
if (serverInput) {
|
||||
server = ensureServer(serverInput);
|
||||
}
|
||||
|
||||
if (!server) {
|
||||
server = selectFirstServerStmt.get();
|
||||
}
|
||||
|
||||
if (!server) {
|
||||
const error = new Error('SERVER_DETAILS_REQUIRED');
|
||||
error.code = 'SERVER_DETAILS_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!endpointInput) {
|
||||
const error = new Error('ENDPOINT_DETAILS_REQUIRED');
|
||||
error.code = 'ENDPOINT_DETAILS_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const endpoint = ensureEndpoint({
|
||||
serverId: server.id,
|
||||
name: endpointInput.name,
|
||||
externalId: endpointInput.externalId,
|
||||
makeDefault: true
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
endpoint
|
||||
};
|
||||
});
|
||||
|
||||
function completeSetup({ server: serverInput, endpoint: endpointInput }) {
|
||||
const result = createEndpointWithDefaultTransaction({ serverInput, endpointInput });
|
||||
return {
|
||||
server: result.server,
|
||||
endpoint: result.endpoint,
|
||||
defaultEndpoint: getDefaultEndpoint()
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
ensureDefaultsFromEnv,
|
||||
ensureServer,
|
||||
ensureEndpoint,
|
||||
setServerApiKey,
|
||||
setDefaultEndpoint,
|
||||
getDefaultEndpoint,
|
||||
getActiveEndpointExternalId,
|
||||
getActiveApiKey,
|
||||
getActiveServerUrl,
|
||||
hasServer,
|
||||
hasEndpoint,
|
||||
hasApiKey,
|
||||
hasCompleteSetup,
|
||||
getSetupStatus,
|
||||
completeSetup,
|
||||
removeEndpoint,
|
||||
removeServer
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -133,7 +133,31 @@ export default function MaintenanceProvider({ children }) {
|
||||
|
||||
const fetchSuperuserStatus = useCallback(async () => {
|
||||
const response = await axios.get("/api/auth/superuser/status");
|
||||
return response.data ?? { exists: false, user: null };
|
||||
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 deleteSetupEndpoint = useCallback(async (endpointId) => {
|
||||
const response = await axios.delete(`/api/setup/endpoints/${endpointId}`);
|
||||
return response.data ?? { success: false };
|
||||
}, []);
|
||||
|
||||
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 removeSuperuserAccount = useCallback(async () => {
|
||||
@@ -159,8 +183,12 @@ export default function MaintenanceProvider({ children }) {
|
||||
deleteSshConfig,
|
||||
testSshConnection,
|
||||
fetchSuperuserStatus,
|
||||
fetchSetupStatus,
|
||||
deleteSetupEndpoint,
|
||||
deleteSetupServer,
|
||||
updateSetupApiKey,
|
||||
removeSuperuserAccount
|
||||
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, removeSuperuserAccount]);
|
||||
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, fetchSetupStatus, deleteSetupEndpoint, deleteSetupServer, updateSetupApiKey, removeSuperuserAccount]);
|
||||
|
||||
return (
|
||||
<MaintenanceContext.Provider value={value}>
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Navbar, Footer } from "@/widgets/layout";
|
||||
import routes from "@/routes";
|
||||
import { RegSuperuser } from "@/pages/auth/regsuperuser";
|
||||
|
||||
export function Auth() {
|
||||
const navbarRoutes = [
|
||||
{
|
||||
@@ -36,7 +34,6 @@ export function Auth() {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full">
|
||||
<Routes>
|
||||
<Route path="/regsuperuser" element={<RegSuperuser />} />
|
||||
{routes.map(
|
||||
({ layout, pages }) =>
|
||||
layout === "auth" &&
|
||||
|
||||
@@ -16,53 +16,112 @@ export function Dashboard() {
|
||||
const { sidenavType } = controller;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [superuserRequired, setSuperuserRequired] = useState(false);
|
||||
const [statusChecked, setStatusChecked] = useState(false);
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
const [setupIncomplete, setSetupIncomplete] = useState(true);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
const checkSuperuserStatus = useCallback(async () => {
|
||||
setStatusChecked(false);
|
||||
const checkSetupStatus = useCallback(async () => {
|
||||
setSetupChecked(false);
|
||||
try {
|
||||
const response = await fetch("/api/auth/superuser/status");
|
||||
const response = await fetch("/api/setup/status", { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Error("STATUS_REQUEST_FAILED");
|
||||
}
|
||||
const data = await response.json();
|
||||
setSuperuserRequired(!data.exists);
|
||||
setSetupIncomplete(!data.setupComplete);
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Superuser] Statusprüfung fehlgeschlagen:", error);
|
||||
setSuperuserRequired(true);
|
||||
console.error("⚠️ [Setup] Statusprüfung fehlgeschlagen:", error);
|
||||
setSetupIncomplete(true);
|
||||
} finally {
|
||||
setStatusChecked(true);
|
||||
setSetupChecked(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkSuperuserStatus();
|
||||
}, [checkSuperuserStatus]);
|
||||
const checkSession = useCallback(async () => {
|
||||
setAuthChecked(false);
|
||||
try {
|
||||
const response = await fetch("/api/auth/session", { credentials: "include" });
|
||||
if (response.status === 403) {
|
||||
setSetupIncomplete(true);
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setIsAuthenticated(Boolean(data?.user));
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Auth] Sessionprüfung fehlgeschlagen:", error);
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setAuthChecked(true);
|
||||
}
|
||||
}, [setSetupIncomplete, setIsAuthenticated, setAuthChecked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!statusChecked) return;
|
||||
if (superuserRequired) {
|
||||
if (location.pathname !== "/auth/regsuperuser") {
|
||||
navigate("/auth/regsuperuser", { replace: true });
|
||||
checkSetupStatus();
|
||||
}, [checkSetupStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupChecked || setupIncomplete) return;
|
||||
checkSession();
|
||||
}, [setupChecked, setupIncomplete, checkSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupChecked) return;
|
||||
|
||||
if (setupIncomplete) {
|
||||
if (location.pathname !== "/setup") {
|
||||
navigate("/setup", { replace: true });
|
||||
}
|
||||
} else if (location.pathname === "/auth/regsuperuser") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authChecked) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (location.pathname !== "/auth/sign-in") {
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (location.pathname === "/setup" || location.pathname.startsWith("/auth/")) {
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
}
|
||||
}, [superuserRequired, statusChecked, location.pathname, navigate]);
|
||||
}, [setupChecked, setupIncomplete, authChecked, isAuthenticated, location.pathname, navigate]);
|
||||
|
||||
if (!statusChecked) {
|
||||
if (!setupChecked) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Lade Systemstatus ...</span>
|
||||
<span className="text-blue-gray-500">Pruefe Systemkonfiguration ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (superuserRequired) {
|
||||
if (setupIncomplete) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Superuser-Einrichtung erforderlich ...</span>
|
||||
<span className="text-blue-gray-500">Setup erforderlich ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authChecked) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -20,6 +21,8 @@ import ToastProvider from "@/components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
|
||||
import PageProvider from "@/components/PageProvider.jsx";
|
||||
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "@/pages/auth/sign-in";
|
||||
export * from "@/pages/auth/sign-up";
|
||||
export * from "@/pages/auth/regsuperuser";
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
Input,
|
||||
Button,
|
||||
Alert,
|
||||
} from "@material-tailwind/react";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function RegSuperuser({ onCompleted }) {
|
||||
const navigate = useNavigate();
|
||||
const [checkingStatus, setCheckingStatus] = useState(true);
|
||||
const [form, setForm] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleChange = (field) => (event) => {
|
||||
setForm((prev) => ({ ...prev, [field]: event.target.value }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const controller = new AbortController();
|
||||
|
||||
const verifyStatus = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/superuser/status", { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error("STATUS_REQUEST_FAILED");
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.exists && isActive) {
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error("⚠️ [Superuser] Statusabfrage fehlgeschlagen:", err);
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
setCheckingStatus(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
verifyStatus();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/superuser/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
switch (payload.error) {
|
||||
case "MISSING_FIELDS":
|
||||
throw new Error("Bitte alle Felder ausfüllen.");
|
||||
case "USERNAME_REQUIRED":
|
||||
throw new Error("Benutzername darf nicht leer sein.");
|
||||
case "EMAIL_INVALID":
|
||||
throw new Error("Bitte eine gültige E-Mail-Adresse angeben.");
|
||||
case "PASSWORD_TOO_SHORT":
|
||||
throw new Error("Passwort muss mindestens 8 Zeichen enthalten.");
|
||||
case "SUPERUSER_EXISTS":
|
||||
throw new Error("Superuser existiert bereits.");
|
||||
default:
|
||||
throw new Error("Registrierung fehlgeschlagen. Bitte erneut versuchen.");
|
||||
}
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
if (typeof onCompleted === "function") {
|
||||
onCompleted();
|
||||
} else {
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "Unbekannter Fehler");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checkingStatus) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Pruefe Superuser-Status ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center py-10">
|
||||
<Card className="w-full max-w-lg border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
floated={false}
|
||||
shadow={false}
|
||||
className="mb-0 grid place-items-start gap-2 rounded-none bg-transparent p-6"
|
||||
>
|
||||
<Typography variant="h4" color="blue-gray">
|
||||
Superuser anlegen
|
||||
</Typography>
|
||||
<Typography color="gray" className="font-normal">
|
||||
Lege den ersten Benutzer deines Systems an. Dieser verfügt über uneingeschränkte Rechte
|
||||
und kann später weitere Benutzer verwalten.
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<form className="mt-4 flex flex-col gap-6" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Benutzername"
|
||||
value={form.username}
|
||||
onChange={handleChange("username")}
|
||||
required
|
||||
disabled={loading || success}
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={handleChange("email")}
|
||||
required
|
||||
disabled={loading || success}
|
||||
/>
|
||||
<Input
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={handleChange("password")}
|
||||
required
|
||||
disabled={loading || success}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert color="green" className="border border-green-200 bg-green-50 text-green-700">
|
||||
Superuser wurde erfolgreich angelegt. Du wirst gleich weitergeleitet.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" color="blue" disabled={loading || success}>
|
||||
{loading ? "Wird angelegt..." : "Superuser erstellen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RegSuperuser.propTypes = {
|
||||
onCompleted: PropTypes.func,
|
||||
};
|
||||
|
||||
RegSuperuser.defaultProps = {
|
||||
onCompleted: undefined,
|
||||
};
|
||||
|
||||
export default RegSuperuser;
|
||||
@@ -1,29 +1,165 @@
|
||||
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";
|
||||
|
||||
export function SignIn() {
|
||||
const navigate = useNavigate();
|
||||
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 && isActive) {
|
||||
navigate("/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]);
|
||||
|
||||
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 }),
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
navigate("/setup", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
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") {
|
||||
navigate("/setup", { replace: true });
|
||||
return;
|
||||
} else {
|
||||
setError("Anmeldung fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await response.json().catch(() => ({}));
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
} catch (err) {
|
||||
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", err);
|
||||
setError("Netzwerkfehler – bitte erneut versuchen.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[identifier, password, loading, navigate]
|
||||
);
|
||||
|
||||
if (!statusChecked) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Pruefe Anmeldestatus ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="m-8 flex gap-4">
|
||||
<div className="w-full lg:w-3/5 mt-24">
|
||||
<div className="text-center">
|
||||
<Typography variant="h2" className="font-bold mb-4">Anmelden</Typography>
|
||||
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Geben Sie Ihre eMail oder Ihren Benutzernamen und Ihr Passwort ein, um sich anzumelden.</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">
|
||||
eMail oder Benutzername
|
||||
E-Mail oder Benutzername
|
||||
</Typography>
|
||||
<Input
|
||||
size="lg"
|
||||
placeholder="eMail oder Benutzername"
|
||||
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",
|
||||
@@ -33,39 +169,34 @@ export function SignIn() {
|
||||
Passwort
|
||||
</Typography>
|
||||
<Input
|
||||
type="passwort"
|
||||
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>
|
||||
<Button className="mt-6" fullWidth>
|
||||
Anmelden
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-2 mt-6">
|
||||
<Typography variant="small" className="font-medium text-gray-900">
|
||||
<a href="#">
|
||||
Forgot Password
|
||||
</a>
|
||||
</Typography>
|
||||
</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>
|
||||
</Typography> */}
|
||||
</form>
|
||||
|
||||
{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>
|
||||
</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="/img/pattern.png" className="h-full w-full object-cover rounded-3xl" />
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Button,
|
||||
Switch
|
||||
Switch,
|
||||
Input
|
||||
} from "@material-tailwind/react";
|
||||
|
||||
const UPDATE_STATUS_LABELS = {
|
||||
@@ -125,6 +126,10 @@ export function Maintenance() {
|
||||
deleteSshConfig,
|
||||
testSshConnection,
|
||||
fetchSuperuserStatus,
|
||||
fetchSetupStatus,
|
||||
deleteSetupEndpoint,
|
||||
deleteSetupServer,
|
||||
updateSetupApiKey,
|
||||
removeSuperuserAccount
|
||||
} = useMaintenance();
|
||||
|
||||
@@ -189,6 +194,13 @@ export function Maintenance() {
|
||||
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 [endpointDeleteId, setEndpointDeleteId] = useState(null);
|
||||
|
||||
const [duplicates, setDuplicates] = useState([]);
|
||||
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
|
||||
@@ -226,6 +238,30 @@ export function Maintenance() {
|
||||
}
|
||||
}, [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;
|
||||
@@ -318,6 +354,10 @@ export function Maintenance() {
|
||||
loadSuperuserStatus({ silent: true });
|
||||
}, [loadSuperuserStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSetupResources();
|
||||
}, [loadSetupResources]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPortainerStatus();
|
||||
}, [fetchPortainerStatus]);
|
||||
@@ -326,6 +366,14 @@ export function Maintenance() {
|
||||
fetchDuplicates();
|
||||
}, [fetchDuplicates]);
|
||||
|
||||
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
|
||||
const setupEndpoints = useMemo(() => setupResources?.endpoints?.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 totals = useMemo(() => {
|
||||
const groups = Array.isArray(duplicates) ? duplicates.length : 0;
|
||||
const duplicateCount = Array.isArray(duplicates)
|
||||
@@ -499,6 +547,135 @@ export function Maintenance() {
|
||||
setSuperuserDeleteLoading(false);
|
||||
}
|
||||
}, [superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
|
||||
|
||||
const handleDeleteEndpoint = useCallback(async (endpointId) => {
|
||||
if (endpointDeleteId === endpointId) {
|
||||
return;
|
||||
}
|
||||
const target = setupEndpoints.find((entry) => entry.id === endpointId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const label = target.name || `Endpoint ${target.external_id}`;
|
||||
const confirmMessage = `Endpoint "${label}" wirklich löschen?${target.is_default ? "\nDieser Endpoint ist aktuell als Standard markiert." : ""}`;
|
||||
if (typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(confirmMessage);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEndpointDeleteId(endpointId);
|
||||
try {
|
||||
await deleteSetupEndpoint(endpointId);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Endpoint gelöscht",
|
||||
description: `Endpoint "${label}" wurde entfernt.`
|
||||
});
|
||||
await loadSetupResources({ silent: true });
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Endpoint konnte nicht gelöscht werden.";
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Endpoint löschen fehlgeschlagen",
|
||||
description: message
|
||||
});
|
||||
} finally {
|
||||
setEndpointDeleteId(null);
|
||||
}
|
||||
}, [deleteSetupEndpoint, endpointDeleteId, loadSetupResources, setupEndpoints, showToast]);
|
||||
|
||||
const handleDeleteServer = useCallback(async (serverId) => {
|
||||
if (serverDeleteId === serverId) {
|
||||
return;
|
||||
}
|
||||
const target = setupServers.find((entry) => entry.id === serverId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const linkedEndpoints = setupEndpoints.filter((entry) => entry.server_id === serverId);
|
||||
const label = target.name || target.url || `Server ${serverId}`;
|
||||
const confirmMessage = [
|
||||
`Server "${label}" wirklich löschen?`,
|
||||
linkedEndpoints.length ? `Dabei werden ${linkedEndpoints.length} zugeordnete Endpoint(s) entfernt.` : null,
|
||||
"Das System benötigt anschließend erneut einen gültigen Server/Endpoint 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);
|
||||
}
|
||||
}, [deleteSetupServer, loadSetupResources, serverDeleteId, setupEndpoints, setupServers, showToast]);
|
||||
|
||||
const handleApiKeyDraftChange = useCallback((serverId, value) => {
|
||||
setApiKeyDrafts((prev) => ({
|
||||
...prev,
|
||||
[serverId]: value
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleApiKeyUpdate = useCallback(async (serverId) => {
|
||||
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, loadSetupResources, showToast, updateSetupApiKey]);
|
||||
|
||||
const handleMaintenanceToggle = useCallback(async (nextActive) => {
|
||||
if (maintenanceLoading || maintenanceToggleLoading) return;
|
||||
if (nextActive === maintenanceActive) return;
|
||||
@@ -706,6 +883,181 @@ export function Maintenance() {
|
||||
{maintenanceToggleLoading && (
|
||||
<p className="text-xs text-stormGrey-500">Wartungsmodus wird aktualisiert…</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="white"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Server & Endpoints</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={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={setupResourcesLoading || serverDeleteId === server.id || endpointDeleteId !== null || apiKeyUpdatingId === server.id}
|
||||
>
|
||||
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
|
||||
</Button>
|
||||
<Button
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => handleApiKeyUpdate(server.id)}
|
||||
disabled={setupResourcesLoading || apiKeyUpdatingId === server.id}
|
||||
>
|
||||
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
|
||||
Endpoints
|
||||
</Typography>
|
||||
<Typography variant="small" color="blue-gray">
|
||||
{setupEndpoints.length} vorhanden
|
||||
</Typography>
|
||||
</div>
|
||||
{setupEndpoints.length === 0 ? (
|
||||
<p className="text-sm text-stormGrey-500">
|
||||
Es sind derzeit keine Endpoints hinterlegt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{setupEndpoints.map((endpoint) => (
|
||||
<div
|
||||
key={endpoint.id}
|
||||
className="flex flex-col gap-2 rounded-md border border-blue-gray-100 p-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-stormGrey-900">
|
||||
{endpoint.name || `Endpoint ${endpoint.external_id}`}
|
||||
</p>
|
||||
{endpoint.is_default ? (
|
||||
<span className="rounded-full bg-arcticBlue-100 px-2 py-0.5 text-[10px] font-semibold text-arcticBlue-700">
|
||||
Standard
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-stormGrey-500">
|
||||
ID: {endpoint.external_id} · Server: {endpoint.server_name || endpoint.server_url}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="text"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteEndpoint(endpoint.id)}
|
||||
disabled={setupResourcesLoading || endpointDeleteId === endpoint.id || serverDeleteId !== null}
|
||||
>
|
||||
{endpointDeleteId === endpoint.id ? "Lösche…" : "Löschen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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, Endpoint und API-Key erneut festzulegen.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => loadSetupResources()}
|
||||
disabled={setupResourcesLoading}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -1288,4 +1640,4 @@ export function Maintenance() {
|
||||
|
||||
}
|
||||
|
||||
export default Maintenance;
|
||||
export default Maintenance;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
Avatar,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Progress,
|
||||
Collapse,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Select,
|
||||
Option,
|
||||
Input,
|
||||
useSelect
|
||||
} from "@material-tailwind/react";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
|
||||
export function Usergroups() {
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default Usergroups;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
Avatar,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Progress,
|
||||
Collapse,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Select,
|
||||
Option,
|
||||
Input,
|
||||
useSelect
|
||||
} from "@material-tailwind/react";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
|
||||
export function Users() {
|
||||
|
||||
}
|
||||
|
||||
export default Users;
|
||||
@@ -0,0 +1 @@
|
||||
export { Setup as default, Setup } from "./setup";
|
||||
@@ -0,0 +1,471 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Typography,
|
||||
Input,
|
||||
Button,
|
||||
Alert,
|
||||
} from "@material-tailwind/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const initialFormState = {
|
||||
superuser: {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
server: {
|
||||
name: "",
|
||||
url: "",
|
||||
},
|
||||
endpoint: {
|
||||
name: "",
|
||||
externalId: "",
|
||||
serverId: "",
|
||||
},
|
||||
apiKey: ""
|
||||
};
|
||||
|
||||
export function Setup() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||
const [fetchError, setFetchError] = useState(null);
|
||||
const [form, setForm] = useState(initialFormState);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const [submitSuccess, setSubmitSuccess] = 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;
|
||||
}
|
||||
|
||||
setForm((prev) => ({
|
||||
superuser: {
|
||||
username: data.envDefaults?.superuserUsername?.length ? data.envDefaults.superuserUsername : prev.superuser.username,
|
||||
email: data.envDefaults?.superuserEmail?.length ? data.envDefaults.superuserEmail : prev.superuser.email,
|
||||
password: data.envDefaults?.superuserPassword?.length ? data.envDefaults.superuserPassword : prev.superuser.password,
|
||||
},
|
||||
server: {
|
||||
name: data.envDefaults?.serverName || prev.server.name,
|
||||
url: data.envDefaults?.serverUrl?.length ? data.envDefaults.serverUrl : prev.server.url,
|
||||
},
|
||||
endpoint: {
|
||||
name: data.envDefaults?.endpointName || prev.endpoint.name,
|
||||
externalId: data.envDefaults?.endpointExternalId?.length ? data.envDefaults.endpointExternalId : prev.endpoint.externalId,
|
||||
serverId: prev.endpoint.serverId || ""
|
||||
},
|
||||
apiKey: data.envDefaults?.apiKeyValue?.length ? data.envDefaults.apiKeyValue : prev.apiKey
|
||||
}));
|
||||
} 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 envSuperuserUsername = envDefaults.superuserUsername ?? "";
|
||||
const envSuperuserEmail = envDefaults.superuserEmail ?? "";
|
||||
const envSuperuserPassword = envDefaults.superuserPassword ?? "";
|
||||
const envServerUrl = envDefaults.serverUrl ?? "";
|
||||
const envEndpointExternalId = envDefaults.endpointExternalId ?? "";
|
||||
const envApiKeyValue = envDefaults.apiKeyValue ?? "";
|
||||
|
||||
const requireSuperuser = Boolean(status?.requirements?.superuser);
|
||||
const requireServer = Boolean(status?.requirements?.server);
|
||||
const requireEndpoint = Boolean(status?.requirements?.endpoint);
|
||||
const requireApiKey = Boolean(status?.requirements?.apiKey);
|
||||
const hasAnyApiKey = Boolean(status?.apiKeys?.count);
|
||||
|
||||
const serverEnvProvided = Boolean(envServerUrl || status?.servers?.envProvided);
|
||||
const endpointEnvProvided = Boolean(envEndpointExternalId || status?.endpoints?.envProvided);
|
||||
const apiKeyEnvProvided = Boolean(envApiKeyValue || status?.apiKeys?.envProvided);
|
||||
|
||||
const showServerSection = requireServer || serverEnvProvided;
|
||||
const showEndpointSection = requireEndpoint || endpointEnvProvided;
|
||||
const showApiKeyField = requireApiKey || !hasAnyApiKey || apiKeyEnvProvided;
|
||||
|
||||
const superuserUsernameReadOnly = Boolean(envSuperuserUsername);
|
||||
const superuserEmailReadOnly = Boolean(envSuperuserEmail);
|
||||
const superuserPasswordReadOnly = Boolean(envSuperuserPassword);
|
||||
const serverNameReadOnly = Boolean(envDefaults.serverNameFromEnv);
|
||||
const serverUrlReadOnly = Boolean(envServerUrl);
|
||||
const endpointNameReadOnly = Boolean(envDefaults.endpointNameFromEnv);
|
||||
const endpointExternalIdReadOnly = Boolean(envEndpointExternalId);
|
||||
const apiKeyReadOnly = Boolean(envApiKeyValue);
|
||||
|
||||
const handleInputChange = useCallback((section, field, { locked = false } = {}) => (event) => {
|
||||
if (locked) {
|
||||
return;
|
||||
}
|
||||
const value = event.target.value;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const validationErrors = useMemo(() => {
|
||||
if (!status) return [];
|
||||
const errors = [];
|
||||
|
||||
if (requireSuperuser) {
|
||||
const { username, email, password } = form.superuser;
|
||||
if (!username.trim()) errors.push("Benutzername ist erforderlich.");
|
||||
if (!email.trim()) errors.push("E-Mail-Adresse ist erforderlich.");
|
||||
if (!password.trim()) errors.push("Passwort ist erforderlich.");
|
||||
}
|
||||
|
||||
if (requireServer && !form.server.url.trim()) {
|
||||
errors.push("Server-URL ist erforderlich.");
|
||||
}
|
||||
|
||||
if (requireEndpoint && !form.endpoint.externalId.trim()) {
|
||||
errors.push("Endpoint-ID ist erforderlich.");
|
||||
}
|
||||
|
||||
if (showApiKeyField && requireApiKey && !form.apiKey.trim()) {
|
||||
errors.push("API-Key ist erforderlich.");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}, [form, requireSuperuser, requireServer, requireEndpoint, showApiKeyField, requireApiKey, status]);
|
||||
|
||||
const handleSubmit = useCallback(async (event) => {
|
||||
event.preventDefault();
|
||||
if (submitting || !status) return;
|
||||
|
||||
setSubmitError(null);
|
||||
setSubmitSuccess(false);
|
||||
|
||||
if (validationErrors.length) {
|
||||
setSubmitError(validationErrors.join(" "));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
|
||||
if (requireSuperuser) {
|
||||
payload.superuser = {
|
||||
username: form.superuser.username.trim(),
|
||||
email: form.superuser.email.trim(),
|
||||
password: form.superuser.password
|
||||
};
|
||||
}
|
||||
|
||||
if (showServerSection) {
|
||||
const serverName = form.server.name.trim();
|
||||
const serverUrl = form.server.url.trim();
|
||||
if (requireServer || (!serverNameReadOnly && serverName) || (!serverUrlReadOnly && serverUrl)) {
|
||||
payload.server = {
|
||||
name: serverName,
|
||||
url: serverUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (showEndpointSection) {
|
||||
const endpointName = form.endpoint.name.trim();
|
||||
const endpointExternalId = form.endpoint.externalId.trim();
|
||||
const endpointServerId = form.endpoint.serverId ? Number(form.endpoint.serverId) : undefined;
|
||||
if (requireEndpoint || (!endpointNameReadOnly && endpointName) || (!endpointExternalIdReadOnly && endpointExternalId) || endpointServerId) {
|
||||
payload.endpoint = {
|
||||
name: endpointName || null,
|
||||
externalId: endpointExternalId || null,
|
||||
serverId: endpointServerId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeyValue = form.apiKey.trim();
|
||||
if (showApiKeyField || apiKeyValue) {
|
||||
payload.apiKey = { value: apiKeyValue };
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
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(() => ({}));
|
||||
switch (payload.error) {
|
||||
case "SERVER_DETAILS_REQUIRED":
|
||||
throw new Error("Bitte gib eine gültige Server-URL an.");
|
||||
case "ENDPOINT_DETAILS_REQUIRED":
|
||||
throw new Error("Bitte gib eine gültige Endpoint-ID an.");
|
||||
case "API_KEY_REQUIRED":
|
||||
throw new Error("Bitte gib einen gültigen API-Key an.");
|
||||
case "USERNAME_REQUIRED":
|
||||
throw new Error("Benutzername wird benötigt.");
|
||||
case "EMAIL_INVALID":
|
||||
throw new Error("Bitte gib eine gültige E-Mail-Adresse an.");
|
||||
case "PASSWORD_TOO_SHORT":
|
||||
throw new Error("Passwort muss mindestens 8 Zeichen enthalten.");
|
||||
case "INVALID_PASSWORD":
|
||||
throw new Error("Das Passwort ist ungültig.");
|
||||
case "API_KEY_ENCRYPT_FAILED":
|
||||
throw new Error("API-Key konnte nicht verschlüsselt werden. Bitte erneut versuchen.");
|
||||
case "SUPERUSER_EXISTS":
|
||||
throw new Error("Der Superuser wurde bereits angelegt.");
|
||||
default:
|
||||
throw new Error("Setup konnte nicht abgeschlossen werden. Bitte erneut versuchen.");
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
setStatus(result.status);
|
||||
setSubmitSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Setup] Abschluss fehlgeschlagen:", error);
|
||||
setSubmitError(error.message || "Setup fehlgeschlagen.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [form, navigate, requireSuperuser, requireServer, requireEndpoint, showServerSection, showEndpointSection, showApiKeyField, status, submitting, validationErrors, serverNameReadOnly, serverUrlReadOnly, endpointNameReadOnly, endpointExternalIdReadOnly]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const superuserSection = requireSuperuser ? (
|
||||
<>
|
||||
<Typography variant="h6" color="blue-gray" className="font-semibold">
|
||||
Superuser
|
||||
</Typography>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Input
|
||||
label="Benutzername"
|
||||
required
|
||||
value={form.superuser.username}
|
||||
onChange={handleInputChange("superuser", "username", { locked: superuserUsernameReadOnly })}
|
||||
disabled={submitting}
|
||||
readOnly={superuserUsernameReadOnly}
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail-Adresse"
|
||||
required
|
||||
type="email"
|
||||
value={form.superuser.email}
|
||||
onChange={handleInputChange("superuser", "email", { locked: superuserEmailReadOnly })}
|
||||
disabled={submitting}
|
||||
readOnly={superuserEmailReadOnly}
|
||||
/>
|
||||
<Input
|
||||
label="Passwort"
|
||||
required
|
||||
type="password"
|
||||
value={form.superuser.password}
|
||||
onChange={handleInputChange("superuser", "password", { locked: superuserPasswordReadOnly })}
|
||||
disabled={submitting}
|
||||
readOnly={superuserPasswordReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Alert color="green" className="border border-green-200 bg-green-50 text-green-700">
|
||||
Superuser ist bereits vorhanden.
|
||||
</Alert>
|
||||
);
|
||||
|
||||
const serverSection = showServerSection ? (
|
||||
<>
|
||||
<Typography variant="h6" color="blue-gray" className="mt-6 font-semibold">
|
||||
Server
|
||||
</Typography>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Input
|
||||
label="Server-Name"
|
||||
value={form.server.name}
|
||||
onChange={handleInputChange("server", "name", { locked: serverNameReadOnly })}
|
||||
readOnly={serverNameReadOnly}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<Input
|
||||
label="Server-URL"
|
||||
required={requireServer}
|
||||
value={form.server.url}
|
||||
onChange={handleInputChange("server", "url", { locked: serverUrlReadOnly })}
|
||||
readOnly={serverUrlReadOnly}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const endpointSection = showEndpointSection ? (
|
||||
<>
|
||||
<Typography variant="h6" color="blue-gray" className="mt-6 font-semibold">
|
||||
Endpoint
|
||||
</Typography>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Input
|
||||
label="Endpoint-Name"
|
||||
value={form.endpoint.name}
|
||||
onChange={handleInputChange("endpoint", "name", { locked: endpointNameReadOnly })}
|
||||
readOnly={endpointNameReadOnly}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<Input
|
||||
label="Endpoint-ID"
|
||||
required={requireEndpoint}
|
||||
value={form.endpoint.externalId}
|
||||
onChange={handleInputChange("endpoint", "externalId", { locked: endpointExternalIdReadOnly })}
|
||||
readOnly={endpointExternalIdReadOnly}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const apiKeySection = showApiKeyField ? (
|
||||
<>
|
||||
<Typography variant="h6" color="blue-gray" className="mt-6 font-semibold">
|
||||
API-Key
|
||||
</Typography>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Input
|
||||
label="API-Key"
|
||||
required={requireApiKey}
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(event) => {
|
||||
if (apiKeyReadOnly) return;
|
||||
setForm((prev) => ({ ...prev, apiKey: event.target.value }));
|
||||
}}
|
||||
readOnly={apiKeyReadOnly}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const showEnvHint = Boolean(
|
||||
status?.superuser?.envProvided ||
|
||||
serverEnvProvided ||
|
||||
endpointEnvProvided ||
|
||||
apiKeyEnvProvided
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center py-10">
|
||||
<Card className="w-full max-w-lg border border-blue-gray-100 shadow-sm">
|
||||
<CardHeader
|
||||
floated={false}
|
||||
shadow={false}
|
||||
className="mb-0 grid place-items-start gap-2 rounded-none bg-transparent p-6"
|
||||
>
|
||||
<Typography variant="h4" color="blue-gray">
|
||||
System Setup
|
||||
</Typography>
|
||||
<Typography color="gray" className="font-normal">
|
||||
Lege einen Superuser sowie mindestens einen Server mit Endpoint und API-Key fest, um StackPulse zu starten.
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<form className="mt-4 flex flex-col gap-6" onSubmit={handleSubmit}>
|
||||
{showEnvHint && (
|
||||
<Alert color="blue" className="border border-blue-200 bg-blue-50 text-blue-700">
|
||||
Teile der Konfiguration stammen bereits aus den Umgebungsvariablen.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{superuserSection}
|
||||
{serverSection}
|
||||
{endpointSection}
|
||||
{apiKeySection}
|
||||
|
||||
{submitError && (
|
||||
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
|
||||
{submitError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{submitSuccess && (
|
||||
<Alert color="green" className="border border-green-200 bg-green-50 text-green-700">
|
||||
Setup abgeschlossen. Du wirst zum Login weitergeleitet ...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" color="blue" disabled={submitting || submitSuccess}>
|
||||
{submitting ? "Setup läuft ..." : "Setup abschließen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Setup;
|
||||
+16
-2
@@ -3,9 +3,11 @@ import {
|
||||
WrenchScrewdriverIcon,
|
||||
ListBulletIcon,
|
||||
ServerStackIcon,
|
||||
RectangleStackIcon
|
||||
RectangleStackIcon,
|
||||
UserIcon,
|
||||
UserGroupIcon
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Stacks, Maintenance, Logs } from "@/pages/dashboard";
|
||||
import { Stacks, Maintenance, Logs, Users, Usergroups } from "@/pages/dashboard";
|
||||
import { SignIn, SignUp } from "@/pages/auth";
|
||||
|
||||
const icon = {
|
||||
@@ -34,6 +36,18 @@ export const routes = [
|
||||
path: "/logs",
|
||||
element: <Logs />,
|
||||
},
|
||||
{
|
||||
icon: <UserIcon {...icon} />,
|
||||
name: "benutzer",
|
||||
path: "/users",
|
||||
element: <Users />,
|
||||
},
|
||||
{
|
||||
icon: <UserGroupIcon {...icon} />,
|
||||
name: "rechtegruppen",
|
||||
path: "/usergroups",
|
||||
element: <Usergroups />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user