Superuser
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
PORTAINER_URL=Your_Portainer_Server_Adress
|
||||
PORTAINER_API_KEY=Your_Portainer_API_Key
|
||||
PORTAINER_ENDPOINT_ID=Your_Portainer_Endpoint_ID
|
||||
SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
||||
SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
||||
SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
|
||||
SELF_STACK_ID=Your_StackPulse_Stack_ID
|
||||
@@ -0,0 +1,212 @@
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
export const SUPERUSER_GROUP_NAME = 'superuser';
|
||||
|
||||
const selectGroupByName = db.prepare('SELECT * FROM user_groups WHERE name = ?');
|
||||
const insertGroup = db.prepare('INSERT INTO user_groups (name, description) VALUES (?, ?)');
|
||||
|
||||
const selectSuperuser = db.prepare(`
|
||||
SELECT u.*
|
||||
FROM users u
|
||||
INNER JOIN user_group_memberships m ON m.user_id = u.id
|
||||
INNER JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE g.name = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const selectUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||
const selectUserByEmail = db.prepare('SELECT * FROM users WHERE email = ?');
|
||||
const selectUserById = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (username, email, password_hash, password_salt, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
const updateUser = db.prepare(`
|
||||
UPDATE users
|
||||
SET username = ?, email = ?, password_hash = ?, password_salt = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const insertMembership = db.prepare(`
|
||||
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
const removeMembershipsForGroup = db.prepare(`
|
||||
DELETE FROM user_group_memberships
|
||||
WHERE group_id = ?
|
||||
`);
|
||||
|
||||
const selectUserIdsForGroup = db.prepare(`
|
||||
SELECT u.id
|
||||
FROM users u
|
||||
INNER JOIN user_group_memberships m ON m.user_id = u.id
|
||||
WHERE m.group_id = ?
|
||||
`);
|
||||
|
||||
const deleteUserById = db.prepare('DELETE FROM users WHERE id = ?');
|
||||
const deleteGroupById = db.prepare('DELETE FROM user_groups WHERE id = ?');
|
||||
|
||||
const countSuperusers = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM user_group_memberships m
|
||||
INNER JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE g.name = ?
|
||||
`);
|
||||
|
||||
const creationTransaction = db.transaction(({ username, email, passwordHash, passwordSalt, groupId }) => {
|
||||
const existingUser = selectUserByUsername.get(username) || selectUserByEmail.get(email);
|
||||
|
||||
let userId;
|
||||
if (existingUser) {
|
||||
updateUser.run(username, email, passwordHash, passwordSalt, existingUser.id);
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
const result = insertUser.run(username, email, passwordHash, passwordSalt);
|
||||
userId = result.lastInsertRowid;
|
||||
}
|
||||
|
||||
removeMembershipsForGroup.run(groupId);
|
||||
insertMembership.run(userId, groupId, SUPERUSER_GROUP_NAME);
|
||||
|
||||
return selectUserById.get(userId);
|
||||
});
|
||||
|
||||
function hashPassword(password) {
|
||||
if (!password || typeof password !== 'string') {
|
||||
throw new Error('INVALID_PASSWORD');
|
||||
}
|
||||
const trimmed = password.trim();
|
||||
if (trimmed.length < 8) {
|
||||
throw new Error('PASSWORD_TOO_SHORT');
|
||||
}
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
|
||||
return { salt, hash };
|
||||
}
|
||||
|
||||
function ensureGroup(name, description = null) {
|
||||
let group = selectGroupByName.get(name);
|
||||
if (group) return group;
|
||||
insertGroup.run(name, description);
|
||||
group = selectGroupByName.get(name);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function hasSuperuser() {
|
||||
const { count } = countSuperusers.get(SUPERUSER_GROUP_NAME);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function createOrUpdateSuperuser({ username, email, password }) {
|
||||
const normalizedUsername = String(username || '').trim();
|
||||
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||
|
||||
if (!normalizedUsername) {
|
||||
throw new Error('USERNAME_REQUIRED');
|
||||
}
|
||||
|
||||
if (!normalizedEmail || !normalizedEmail.includes('@')) {
|
||||
throw new Error('EMAIL_INVALID');
|
||||
}
|
||||
|
||||
const { hash, salt } = hashPassword(password);
|
||||
const group = ensureGroup(SUPERUSER_GROUP_NAME, 'System Superuser');
|
||||
|
||||
const user = creationTransaction({
|
||||
username: normalizedUsername,
|
||||
email: normalizedEmail,
|
||||
passwordHash: hash,
|
||||
passwordSalt: salt,
|
||||
groupId: group.id
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
};
|
||||
}
|
||||
|
||||
const deletionTransaction = db.transaction((groupId) => {
|
||||
const members = selectUserIdsForGroup.all(groupId);
|
||||
|
||||
members.forEach(({ id }) => {
|
||||
deleteUserById.run(id);
|
||||
});
|
||||
|
||||
const { changes: groupChanges } = deleteGroupById.run(groupId);
|
||||
|
||||
return {
|
||||
usersRemoved: members.length,
|
||||
groupRemoved: groupChanges > 0
|
||||
};
|
||||
});
|
||||
|
||||
export function ensureSuperuserFromEnv() {
|
||||
const username = process.env.SUPERUSER_USERNAME;
|
||||
const email = process.env.SUPERUSER_EMAIL;
|
||||
const password = process.env.SUPERUSER_PASSWORD;
|
||||
|
||||
if (hasSuperuser()) {
|
||||
if (username || email || password) {
|
||||
console.log('ℹ️ Superuser existiert bereits - Umgebungsvariablen werden ignoriert.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
createOrUpdateSuperuser({ username, email, password });
|
||||
console.log('✅ Superuser aus Umgebungsvariablen angelegt.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('⚠️ Superuser konnte nicht aus Umgebungsvariablen erzeugt werden:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSuperuser({ username, email, password }) {
|
||||
if (hasSuperuser()) {
|
||||
const err = new Error('SUPERUSER_ALREADY_EXISTS');
|
||||
err.code = 'SUPERUSER_ALREADY_EXISTS';
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
return createOrUpdateSuperuser({ username, email, password });
|
||||
} catch (error) {
|
||||
if (error.message === 'USERNAME_REQUIRED' || error.message === 'EMAIL_INVALID' || error.message === 'PASSWORD_TOO_SHORT') {
|
||||
error.code = error.message;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSuperuser() {
|
||||
const group = selectGroupByName.get(SUPERUSER_GROUP_NAME);
|
||||
|
||||
if (!group) {
|
||||
const error = new Error('SUPERUSER_NOT_FOUND');
|
||||
error.code = 'SUPERUSER_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return deletionTransaction(group.id);
|
||||
}
|
||||
|
||||
export function getSuperuserSummary() {
|
||||
const user = selectSuperuser.get(SUPERUSER_GROUP_NAME);
|
||||
if (!user) return null;
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
};
|
||||
}
|
||||
+67
-9
@@ -1,5 +1,7 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
const createRedeployLogsTable = `
|
||||
CREATE TABLE IF NOT EXISTS redeploy_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -13,7 +15,60 @@ CREATE TABLE IF NOT EXISTS redeploy_logs (
|
||||
);
|
||||
`;
|
||||
|
||||
const createSettingsTable = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
const createUsersTable = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_salt TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
const createUserGroupsTable = `
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
const createUserGroupMembershipsTable = `
|
||||
CREATE TABLE IF NOT EXISTS user_group_memberships (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
const createUserSettingsTable = `
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER NOT NULL,
|
||||
setting_key TEXT NOT NULL,
|
||||
setting_value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, setting_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
db.exec(createRedeployLogsTable);
|
||||
|
||||
@@ -30,17 +85,20 @@ try {
|
||||
|
||||
console.log('✅ redeploy_logs table ready');
|
||||
|
||||
const createSettingsTable = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
db.exec(createSettingsTable);
|
||||
|
||||
console.log('✅ settings table ready');
|
||||
|
||||
db.exec(createUsersTable);
|
||||
console.log('✅ users table ready');
|
||||
|
||||
db.exec(createUserGroupsTable);
|
||||
console.log('✅ user_groups table ready');
|
||||
|
||||
db.exec(createUserGroupMembershipsTable);
|
||||
console.log('✅ user_group_memberships table ready');
|
||||
|
||||
db.exec(createUserSettingsTable);
|
||||
console.log('✅ user_settings table ready');
|
||||
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 {
|
||||
logRedeployEvent,
|
||||
buildLogFilter,
|
||||
@@ -23,6 +24,8 @@ import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState
|
||||
|
||||
dotenv.config();
|
||||
|
||||
ensureSuperuserFromEnv();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -1115,6 +1118,61 @@ const redeployStackById = async (stackId, redeployType) => {
|
||||
|
||||
// --- API Endpoints ---
|
||||
|
||||
// Superuser Setup
|
||||
app.get('/api/auth/superuser/status', (req, res) => {
|
||||
const exists = hasSuperuser();
|
||||
const user = exists ? getSuperuserSummary() : null;
|
||||
res.json({ exists, user });
|
||||
});
|
||||
|
||||
app.post('/api/auth/superuser/register', (req, res) => {
|
||||
if (hasSuperuser()) {
|
||||
return res.status(409).json({ error: 'SUPERUSER_EXISTS' });
|
||||
}
|
||||
|
||||
const { username, email, password } = req.body || {};
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({ error: 'MISSING_FIELDS' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = registerSuperuser({ username, email, password });
|
||||
res.status(201).json({ user });
|
||||
} catch (error) {
|
||||
switch (error.code) {
|
||||
case 'USERNAME_REQUIRED':
|
||||
return res.status(400).json({ error: 'USERNAME_REQUIRED' });
|
||||
case 'EMAIL_INVALID':
|
||||
return res.status(400).json({ error: 'EMAIL_INVALID' });
|
||||
case 'PASSWORD_TOO_SHORT':
|
||||
return res.status(400).json({ error: 'PASSWORD_TOO_SHORT' });
|
||||
case 'SUPERUSER_ALREADY_EXISTS':
|
||||
return res.status(409).json({ error: 'SUPERUSER_EXISTS' });
|
||||
default:
|
||||
console.error('⚠️ Fehler beim Registrieren des Superusers:', error);
|
||||
return res.status(500).json({ error: 'INTERNAL_ERROR' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/auth/superuser', (req, res) => {
|
||||
if (!hasSuperuser()) {
|
||||
return res.status(404).json({ error: 'SUPERUSER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = removeSuperuser();
|
||||
res.json({ success: true, usersRemoved: result.usersRemoved, groupRemoved: result.groupRemoved });
|
||||
} catch (error) {
|
||||
if (error.code === 'SUPERUSER_NOT_FOUND') {
|
||||
return res.status(404).json({ error: 'SUPERUSER_NOT_FOUND' });
|
||||
}
|
||||
console.error('⚠️ Fehler beim Löschen des Superusers:', error);
|
||||
res.status(500).json({ error: 'INTERNAL_ERROR' });
|
||||
}
|
||||
});
|
||||
|
||||
// Stacks abrufen
|
||||
app.get('/api/stacks', maintenanceGuard, async (req, res) => {
|
||||
console.log("ℹ️ [API] GET /api/stacks: Abruf gestartet");
|
||||
|
||||
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-4797da57.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-53559ba1.css">
|
||||
<script type="module" crossorigin src="/assets/index-61a55789.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-8ee0592b.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,6 +13,10 @@ services:
|
||||
PORTAINER_API_KEY: "Your_Portainer_API_Key"
|
||||
PORTAINER_ENDPOINT_ID: "Your_Portainer_Endpoint_ID"
|
||||
SELF_STACK_ID: "Stackpulse ID"
|
||||
SUPERUSER_USERNAME: "Your_Superuser_Username" (optional)
|
||||
SUPERUSER_EMAIL: "Your_Superuser_Email" (optional)
|
||||
SUPERUSER_PASSWORD: "Your_Superuser_Password" (optional)
|
||||
SELF_STACK_ID: "Your_StackPulse_Stack_ID"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -131,6 +131,16 @@ export default function MaintenanceProvider({ children }) {
|
||||
return response.data;
|
||||
}, []);
|
||||
|
||||
const fetchSuperuserStatus = useCallback(async () => {
|
||||
const response = await axios.get("/api/auth/superuser/status");
|
||||
return response.data ?? { exists: false, user: null };
|
||||
}, []);
|
||||
|
||||
const removeSuperuserAccount = useCallback(async () => {
|
||||
const response = await axios.delete("/api/auth/superuser");
|
||||
return response.data ?? { success: false };
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
maintenance: state.maintenance,
|
||||
update: state.update,
|
||||
@@ -147,8 +157,10 @@ export default function MaintenanceProvider({ children }) {
|
||||
resetScript,
|
||||
saveSshConfig,
|
||||
deleteSshConfig,
|
||||
testSshConnection
|
||||
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
|
||||
testSshConnection,
|
||||
fetchSuperuserStatus,
|
||||
removeSuperuserAccount
|
||||
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, removeSuperuserAccount]);
|
||||
|
||||
return (
|
||||
<MaintenanceContext.Provider value={value}>
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 = [
|
||||
@@ -35,6 +36,7 @@ export function Auth() {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full">
|
||||
<Routes>
|
||||
<Route path="/regsuperuser" element={<RegSuperuser />} />
|
||||
{routes.map(
|
||||
({ layout, pages }) =>
|
||||
layout === "auth" &&
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Routes, Route, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Cog6ToothIcon } from "@heroicons/react/24/solid";
|
||||
import { IconButton } from "@material-tailwind/react";
|
||||
import {
|
||||
@@ -13,6 +14,58 @@ import { useMaterialTailwindController, setOpenConfigurator } from "@/components
|
||||
export function Dashboard() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { sidenavType } = controller;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [superuserRequired, setSuperuserRequired] = useState(false);
|
||||
const [statusChecked, setStatusChecked] = useState(false);
|
||||
|
||||
const checkSuperuserStatus = useCallback(async () => {
|
||||
setStatusChecked(false);
|
||||
try {
|
||||
const response = await fetch("/api/auth/superuser/status");
|
||||
if (!response.ok) {
|
||||
throw new Error("STATUS_REQUEST_FAILED");
|
||||
}
|
||||
const data = await response.json();
|
||||
setSuperuserRequired(!data.exists);
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Superuser] Statusprüfung fehlgeschlagen:", error);
|
||||
setSuperuserRequired(true);
|
||||
} finally {
|
||||
setStatusChecked(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkSuperuserStatus();
|
||||
}, [checkSuperuserStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!statusChecked) return;
|
||||
if (superuserRequired) {
|
||||
if (location.pathname !== "/auth/regsuperuser") {
|
||||
navigate("/auth/regsuperuser", { replace: true });
|
||||
}
|
||||
} else if (location.pathname === "/auth/regsuperuser") {
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
}
|
||||
}, [superuserRequired, statusChecked, location.pathname, 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">Lade Systemstatus ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (superuserRequired) {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-blue-gray-50/50">
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "@/pages/auth/sign-in";
|
||||
export * from "@/pages/auth/sign-up";
|
||||
export * from "@/pages/auth/regsuperuser";
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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;
|
||||
@@ -13,27 +13,27 @@ export function SignIn() {
|
||||
<section className="m-8 flex gap-4">
|
||||
<div className="w-full lg:w-3/5 mt-24">
|
||||
<div className="text-center">
|
||||
<Typography variant="h2" className="font-bold mb-4">Sign In</Typography>
|
||||
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Enter your email and password to Sign In.</Typography>
|
||||
<Typography variant="h2" className="font-bold mb-4">Anmelden</Typography>
|
||||
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Geben Sie Ihre eMail oder Ihren Benutzernamen und Ihr Passwort ein, um sich anzumelden.</Typography>
|
||||
</div>
|
||||
<form className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2">
|
||||
<div className="mb-1 flex flex-col gap-6">
|
||||
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
|
||||
Your email
|
||||
eMail oder Benutzername
|
||||
</Typography>
|
||||
<Input
|
||||
size="lg"
|
||||
placeholder="name@mail.com"
|
||||
placeholder="eMail oder Benutzername"
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
labelProps={{
|
||||
className: "before:content-none after:content-none",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
|
||||
Password
|
||||
Passwort
|
||||
</Typography>
|
||||
<Input
|
||||
type="password"
|
||||
type="passwort"
|
||||
size="lg"
|
||||
placeholder="********"
|
||||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||
@@ -42,73 +42,20 @@ export function SignIn() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
label={
|
||||
<Typography
|
||||
variant="small"
|
||||
color="gray"
|
||||
className="flex items-center justify-start font-medium"
|
||||
>
|
||||
I agree the
|
||||
<a
|
||||
href="#"
|
||||
className="font-normal text-black transition-colors hover:text-gray-900 underline"
|
||||
>
|
||||
Terms and Conditions
|
||||
</a>
|
||||
</Typography>
|
||||
}
|
||||
containerProps={{ className: "-ml-2.5" }}
|
||||
/>
|
||||
<Button className="mt-6" fullWidth>
|
||||
Sign In
|
||||
Anmelden
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mt-6">
|
||||
<Checkbox
|
||||
label={
|
||||
<Typography
|
||||
variant="small"
|
||||
color="gray"
|
||||
className="flex items-center justify-start font-medium"
|
||||
>
|
||||
Subscribe me to newsletter
|
||||
</Typography>
|
||||
}
|
||||
containerProps={{ className: "-ml-2.5" }}
|
||||
/>
|
||||
<Typography variant="small" className="font-medium text-gray-900">
|
||||
<a href="#">
|
||||
Forgot Password
|
||||
</a>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="space-y-4 mt-8">
|
||||
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_1156_824)">
|
||||
<path d="M16.3442 8.18429C16.3442 7.64047 16.3001 7.09371 16.206 6.55872H8.66016V9.63937H12.9813C12.802 10.6329 12.2258 11.5119 11.3822 12.0704V14.0693H13.9602C15.4741 12.6759 16.3442 10.6182 16.3442 8.18429Z" fill="#4285F4" />
|
||||
<path d="M8.65974 16.0006C10.8174 16.0006 12.637 15.2922 13.9627 14.0693L11.3847 12.0704C10.6675 12.5584 9.7415 12.8347 8.66268 12.8347C6.5756 12.8347 4.80598 11.4266 4.17104 9.53357H1.51074V11.5942C2.86882 14.2956 5.63494 16.0006 8.65974 16.0006Z" fill="#34A853" />
|
||||
<path d="M4.16852 9.53356C3.83341 8.53999 3.83341 7.46411 4.16852 6.47054V4.40991H1.51116C0.376489 6.67043 0.376489 9.33367 1.51116 11.5942L4.16852 9.53356Z" fill="#FBBC04" />
|
||||
<path d="M8.65974 3.16644C9.80029 3.1488 10.9026 3.57798 11.7286 4.36578L14.0127 2.08174C12.5664 0.72367 10.6469 -0.0229773 8.65974 0.000539111C5.63494 0.000539111 2.86882 1.70548 1.51074 4.40987L4.1681 6.4705C4.8001 4.57449 6.57266 3.16644 8.65974 3.16644Z" fill="#EA4335" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1156_824">
|
||||
<rect width="16" height="16" fill="white" transform="translate(0.5)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>Sign in With Google</span>
|
||||
</Button>
|
||||
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
|
||||
<img src="/img/twitter-logo.svg" height={24} width={24} alt="" />
|
||||
<span>Sign in With Twitter</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Typography variant="paragraph" className="text-center text-blue-gray-500 font-medium mt-4">
|
||||
{/* <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>
|
||||
</Typography> */}
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,9 @@ export function Maintenance() {
|
||||
resetScript,
|
||||
saveSshConfig,
|
||||
deleteSshConfig,
|
||||
testSshConnection
|
||||
testSshConnection,
|
||||
fetchSuperuserStatus,
|
||||
removeSuperuserAccount
|
||||
} = useMaintenance();
|
||||
|
||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||
@@ -182,6 +184,12 @@ export function Maintenance() {
|
||||
const scriptIsDirty = scriptConfig ? scriptDraft !== scriptBaseline : false;
|
||||
const scriptSourceLabel = scriptConfig?.source === "custom" ? "Benutzerdefiniert" : "Standard";
|
||||
|
||||
const [superuserStatusLoading, setSuperuserStatusLoading] = useState(true);
|
||||
const [superuserExists, setSuperuserExists] = useState(false);
|
||||
const [superuserSummary, setSuperuserSummary] = useState(null);
|
||||
const [superuserStatusError, setSuperuserStatusError] = useState("");
|
||||
const [superuserDeleteLoading, setSuperuserDeleteLoading] = useState(false);
|
||||
|
||||
const [duplicates, setDuplicates] = useState([]);
|
||||
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
|
||||
const [duplicatesError, setDuplicatesError] = useState("");
|
||||
@@ -197,6 +205,27 @@ export function Maintenance() {
|
||||
const [portainerUpdatedAt, setPortainerUpdatedAt] = useState(null);
|
||||
const portainerRequestRef = useRef(null);
|
||||
|
||||
const loadSuperuserStatus = useCallback(async ({ silent = false } = {}) => {
|
||||
setSuperuserStatusLoading(true);
|
||||
setSuperuserStatusError("");
|
||||
try {
|
||||
const data = await fetchSuperuserStatus();
|
||||
const exists = Boolean(data?.exists);
|
||||
setSuperuserExists(exists);
|
||||
setSuperuserSummary(exists ? data.user ?? null : null);
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.error || err.message || "Superuser-Status konnte nicht geladen werden";
|
||||
setSuperuserExists(false);
|
||||
setSuperuserSummary(null);
|
||||
setSuperuserStatusError(message);
|
||||
if (!silent) {
|
||||
showToast({ variant: "error", title: "Statusabfrage fehlgeschlagen", description: message });
|
||||
}
|
||||
} finally {
|
||||
setSuperuserStatusLoading(false);
|
||||
}
|
||||
}, [fetchSuperuserStatus, showToast]);
|
||||
|
||||
const fetchPortainerStatus = useCallback(async ({ silent = false } = {}) => {
|
||||
if (portainerRequestRef.current) {
|
||||
return portainerRequestRef.current;
|
||||
@@ -285,6 +314,10 @@ export function Maintenance() {
|
||||
return requestPromise;
|
||||
}, [maintenanceActive, updateRunning, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSuperuserStatus({ silent: true });
|
||||
}, [loadSuperuserStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPortainerStatus();
|
||||
}, [fetchPortainerStatus]);
|
||||
@@ -423,6 +456,49 @@ export function Maintenance() {
|
||||
}
|
||||
}, [deleteSshConfig, showToast]);
|
||||
|
||||
const handleSuperuserDelete = useCallback(async () => {
|
||||
if (superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(
|
||||
"Superuser und zugehörige Gruppe wirklich löschen?\nDies setzt die Superuser-Einrichtung zurück."
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setSuperuserDeleteLoading(true);
|
||||
const response = await removeSuperuserAccount();
|
||||
const removedUsers = Number(response?.usersRemoved ?? 0);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Superuser gelöscht",
|
||||
description: removedUsers > 1
|
||||
? `${removedUsers} Konten aus der Superuser-Gruppe wurden entfernt.`
|
||||
: "Superuser-Konto wurde entfernt."
|
||||
});
|
||||
await loadSuperuserStatus({ silent: true });
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
if (status === 404) {
|
||||
await loadSuperuserStatus({ silent: true });
|
||||
showToast({
|
||||
variant: "info",
|
||||
title: "Kein Superuser vorhanden",
|
||||
description: "Es ist kein Superuser mehr vorhanden."
|
||||
});
|
||||
} else {
|
||||
const message = err.response?.data?.error || err.message || "Superuser konnte nicht gelöscht werden";
|
||||
showToast({ variant: "error", title: "Löschen fehlgeschlagen", description: message });
|
||||
}
|
||||
} finally {
|
||||
setSuperuserDeleteLoading(false);
|
||||
}
|
||||
}, [superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
|
||||
const handleMaintenanceToggle = useCallback(async (nextActive) => {
|
||||
if (maintenanceLoading || maintenanceToggleLoading) return;
|
||||
if (nextActive === maintenanceActive) return;
|
||||
@@ -633,6 +709,63 @@ export function Maintenance() {
|
||||
</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>Superuser</span>
|
||||
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col gap-4 p-4">
|
||||
<div className="space-y-2">
|
||||
{superuserStatusLoading && (
|
||||
<p className="text-sm text-stormGrey-500">Status wird geladen…</p>
|
||||
)}
|
||||
{!superuserStatusLoading && superuserStatusError && (
|
||||
<p className="text-sm text-sunsetCoral-600">{superuserStatusError}</p>
|
||||
)}
|
||||
{!superuserStatusLoading && !superuserStatusError && superuserExists && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">Superuser ist angelegt.</p>
|
||||
<p className="text-xs text-stormGrey-500">
|
||||
Benutzername: <span className="font-medium text-stormGrey-900">{superuserSummary?.username ?? "unbekannt"}</span>
|
||||
{superuserSummary?.email ? ` - ${superuserSummary.email}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!superuserStatusLoading && !superuserStatusError && !superuserExists && (
|
||||
<p className="text-sm">Es ist kein Superuser vorhanden.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
onClick={() => loadSuperuserStatus()}
|
||||
disabled={superuserStatusLoading || superuserDeleteLoading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Status aktualisieren
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleSuperuserDelete}
|
||||
disabled={superuserStatusLoading || superuserDeleteLoading || !superuserExists}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{superuserDeleteLoading ? "Wird gelöscht…" : "Superuser löschen"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-stormGrey-500">
|
||||
Entfernt das Superuser-Konto samt zugehöriger Gruppe. Danach kann der Erstbenutzer bei Bedarf erneut angelegt werden.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||
<Typography
|
||||
|
||||
+21
-19
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
Square3Stack3DIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
ListBulletIcon
|
||||
ListBulletIcon,
|
||||
ServerStackIcon,
|
||||
RectangleStackIcon
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Stacks, Maintenance, Logs } from "@/pages/dashboard";
|
||||
import { SignIn, SignUp } from "@/pages/auth";
|
||||
@@ -34,24 +36,24 @@ export const routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// title: "auth pages",
|
||||
// layout: "auth",
|
||||
// pages: [
|
||||
// {
|
||||
// icon: <ServerStackIcon {...icon} />,
|
||||
// name: "sign in",
|
||||
// path: "/sign-in",
|
||||
// element: <SignIn />,
|
||||
// },
|
||||
// {
|
||||
// icon: <RectangleStackIcon {...icon} />,
|
||||
// name: "sign up",
|
||||
// path: "/sign-up",
|
||||
// element: <SignUp />,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
title: "auth pages",
|
||||
layout: "auth",
|
||||
pages: [
|
||||
{
|
||||
icon: <ServerStackIcon {...icon} />,
|
||||
name: "sign in",
|
||||
path: "/sign-in",
|
||||
element: <SignIn />,
|
||||
},
|
||||
{
|
||||
icon: <RectangleStackIcon {...icon} />,
|
||||
name: "sign up",
|
||||
path: "/sign-up",
|
||||
element: <SignUp />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
||||
Reference in New Issue
Block a user