v0.5 Push

This commit is contained in:
root
2025-11-12 10:48:25 +00:00
parent fc778da204
commit 81b7613c73
30 changed files with 1853 additions and 1306 deletions
+9 -1
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import {
Card,
@@ -12,6 +12,7 @@ import {
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/components/ToastProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const PHRASE_WORD_COUNT = 8;
@@ -36,6 +37,7 @@ const buildPhrasePayload = (words = []) => {
export function ForgotPassword() {
const navigate = useNavigate();
const { showToast } = useToast();
const { user } = useAuth();
const [username, setUsername] = useState("");
const [phraseInputs, setPhraseInputs] = useState(Array(PHRASE_WORD_COUNT).fill(""));
@@ -50,6 +52,12 @@ export function ForgotPassword() {
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState("");
useEffect(() => {
if (user) {
navigate("/dashboard/stacks", { replace: true });
}
}, [user, navigate]);
const phraseWords = useMemo(() => {
if (fileContent.trim()) {
return extractWordsFromText(fileContent);
+32 -1
View File
@@ -26,10 +26,41 @@ import {
ordersOverviewData,
} from "@/data";
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/24/solid";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
export function Home() {
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
return (
<div className="mt-12">
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100 mb-8">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<div className="mb-12 grid gap-y-10 gap-x-6 md:grid-cols-2 xl:grid-cols-4">
{statisticsCardsData.map(({ icon, title, footer, ...rest }) => (
<StatisticsCard
@@ -87,7 +118,7 @@ export function Home() {
</div>
<Menu placement="left-start">
<MenuHandler>
<IconButton size="sm" variant="text" color="blue-gray">
<IconButton size="sm" variant="text" color="blue-gray" disabled={maintenanceLocked}>
<EllipsisVerticalIcon
strokeWidth={3}
fill="currenColor"
+17 -16
View File
@@ -693,18 +693,19 @@ export function Logs() {
const toggleOpen = () => setOpen((cur) => !cur);
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
{(maintenanceActive || updateRunning) && (<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
)}
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
</div>
)}
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
@@ -738,12 +739,12 @@ export function Logs() {
<>
<Button
onClick={() => handleExport('txt')}
disabled={actionLoading || loading}
disabled={maintenanceLocked || actionLoading || loading}
className="w-full md:flex-1">
Export TXT</Button>
<Button
onClick={() => handleExport('sql')}
disabled={actionLoading || loading}
disabled={maintenanceLocked || actionLoading || loading}
className="w-full md:flex-1">
Export SQL</Button>
</>
@@ -751,7 +752,7 @@ export function Logs() {
{canDeleteLogs && (
<Button
onClick={handleDeleteFiltered}
disabled={actionLoading || loading || logs.length === 0}
disabled={maintenanceLocked || actionLoading || loading || logs.length === 0}
className="w-full md:flex-1 bg-sunsetCoral-500 hover:bg-sunsetCoral-600">
Angezeigte löschen</Button>
)}
@@ -1075,7 +1076,7 @@ export function Logs() {
}}
variant="static"
label="Kontext-ID"
placeholder="z. B. Endpoint-ID"
placeholder="z. B. Server-ID"
/>
</div>
</div>
@@ -1141,7 +1142,7 @@ export function Logs() {
<div className="flex-1 min-w-[180px]">
<Button
onClick={handleResetFilters}
disabled={actionLoading || loading}
disabled={maintenanceLocked || actionLoading || loading}
className="w-full"
>
Zurücksetzen
@@ -1298,7 +1299,7 @@ export function Logs() {
<Typography variant="small">
<button
onClick={() => handleDeleteLog(log.id)}
disabled={actionLoading}
disabled={maintenanceLocked || actionLoading}
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60"
>
Löschen
+168 -112
View File
@@ -10,7 +10,8 @@ import {
CardBody,
Button,
Switch,
Input
Input,
Alert
} from "@material-tailwind/react";
const UPDATE_STATUS_LABELS = {
@@ -128,9 +129,9 @@ export function Maintenance() {
testSshConnection,
fetchSuperuserStatus,
fetchSetupStatus,
deleteSetupEndpoint,
deleteSetupServer,
updateSetupApiKey,
updateSelfStackId,
removeSuperuserAccount
} = useMaintenance();
@@ -140,6 +141,7 @@ export function Maintenance() {
const canViewServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "read"));
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "full"));
const canDeleteServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-delete", "full"));
const canDeleteSuperuser = Boolean(isSuperuserAccount || hasPermission("maintenance-superuser-delete", "full"));
const canViewPortainer = Boolean(isSuperuserAccount || hasPermission("maintenance-portainer", "read"));
const canManagePortainerUpdate = Boolean(isSuperuserAccount || hasPermission("maintenance-update", "full"));
const canViewSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "read"));
@@ -151,6 +153,7 @@ export function Maintenance() {
const maintenanceMessage = maintenanceMeta?.message;
const maintenanceExtraType = maintenanceMeta?.extra?.type;
const updateRunning = Boolean(updateState?.running);
const maintenanceLocked = maintenanceActive || updateRunning;
const [scriptDraft, setScriptDraft] = useState("");
const [scriptSaving, setScriptSaving] = useState(false);
@@ -214,7 +217,9 @@ export function Maintenance() {
const [apiKeyDrafts, setApiKeyDrafts] = useState({});
const [apiKeyUpdatingId, setApiKeyUpdatingId] = useState(null);
const [serverDeleteId, setServerDeleteId] = useState(null);
const [endpointDeleteId, setEndpointDeleteId] = useState(null);
const [selfStackDraft, setSelfStackDraft] = useState("");
const [selfStackSaving, setSelfStackSaving] = useState(false);
const [selfStackError, setSelfStackError] = useState("");
const [duplicates, setDuplicates] = useState([]);
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
@@ -381,12 +386,21 @@ export function Maintenance() {
}, [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 selfStackInfo = setupResources?.selfStack ?? null;
const currentSelfStackValue = selfStackInfo?.current ?? "";
const envSelfStackValue = setupResources?.envDefaults?.selfStackId ?? "";
useEffect(() => {
setSelfStackDraft(currentSelfStackValue || "");
setSelfStackError("");
}, [currentSelfStackValue]);
const selfStackDirty = selfStackDraft !== currentSelfStackValue;
const totals = useMemo(() => {
const groups = Array.isArray(duplicates) ? duplicates.length : 0;
@@ -525,7 +539,7 @@ export function Maintenance() {
}, [canManageSsh, deleteSshConfig, showToast]);
const handleSuperuserDelete = useCallback(async () => {
if (superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
if (!canDeleteSuperuser || superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
return;
}
@@ -566,48 +580,7 @@ export function Maintenance() {
} finally {
setSuperuserDeleteLoading(false);
}
}, [superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
const handleDeleteEndpoint = useCallback(async (endpointId) => {
if (!canDeleteServers) {
return;
}
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);
}
}, [canDeleteServers, deleteSetupEndpoint, endpointDeleteId, loadSetupResources, setupEndpoints, showToast]);
}, [canDeleteSuperuser, superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
const handleDeleteServer = useCallback(async (serverId) => {
if (!canDeleteServers) {
@@ -620,12 +593,10 @@ export function Maintenance() {
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."
"Das System benötigt anschließend erneut einen gültigen Server im Setup."
].filter(Boolean).join("\n");
if (typeof window !== "undefined") {
@@ -654,7 +625,7 @@ export function Maintenance() {
} finally {
setServerDeleteId(null);
}
}, [canDeleteServers, deleteSetupServer, loadSetupResources, serverDeleteId, setupEndpoints, setupServers, showToast]);
}, [canDeleteServers, deleteSetupServer, loadSetupResources, serverDeleteId, setupServers, showToast]);
const handleApiKeyDraftChange = useCallback((serverId, value) => {
if (!canEditServers) return;
@@ -706,6 +677,79 @@ export function Maintenance() {
}
}, [apiKeyDrafts, apiKeyUpdatingId, canEditServers, loadSetupResources, showToast, updateSetupApiKey]);
const handleSelfStackDraftChange = useCallback((value) => {
if (!canEditServers) return;
setSelfStackDraft(value);
setSelfStackError("");
}, [canEditServers]);
const handleSelfStackSave = useCallback(async () => {
if (!canEditServers || !selfStackDirty) {
return;
}
setSelfStackSaving(true);
setSelfStackError("");
const normalizedValue = typeof selfStackDraft === "string" ? selfStackDraft.trim() : "";
try {
await updateSelfStackId(normalizedValue);
showToast({
variant: "success",
title: "Self-Stack-ID gespeichert",
description: normalizedValue
? `Self-Stack-ID wurde auf "${normalizedValue}" gesetzt.`
: "Self-Stack-ID wurde entfernt."
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Self-Stack-ID konnte nicht aktualisiert werden.";
setSelfStackError(message);
showToast({
variant: "error",
title: "Self-Stack-ID",
description: message
});
} finally {
setSelfStackSaving(false);
}
}, [canEditServers, loadSetupResources, selfStackDirty, selfStackDraft, showToast, updateSelfStackId]);
const handleSelfStackRemove = useCallback(async () => {
if (!canEditServers) {
return;
}
if (!selfStackDraft && !currentSelfStackValue) {
return;
}
if (typeof window !== "undefined") {
const confirmed = window.confirm("Self-Stack-ID wirklich entfernen?");
if (!confirmed) {
return;
}
}
setSelfStackSaving(true);
setSelfStackError("");
try {
await updateSelfStackId("");
setSelfStackDraft("");
showToast({
variant: "success",
title: "Self-Stack-ID entfernt",
description: "Die Self-Stack-ID wurde gelöscht."
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Self-Stack-ID konnte nicht entfernt werden.";
setSelfStackError(message);
showToast({
variant: "error",
title: "Self-Stack-ID",
description: message
});
} finally {
setSelfStackSaving(false);
}
}, [canEditServers, currentSelfStackValue, loadSetupResources, selfStackDraft, showToast, updateSelfStackId]);
const handleMaintenanceToggle = useCallback(async (nextActive) => {
if (!canControlMaintenance) return;
if (maintenanceLoading || maintenanceToggleLoading) return;
@@ -935,7 +979,7 @@ export function Maintenance() {
color="white"
className="flex items-center justify-between"
>
<span>Server &amp; Endpoints</span>
<span>Server &amp; API-Keys</span>
</Typography>
</CardHeader>
@@ -1008,7 +1052,7 @@ export function Maintenance() {
color="red"
size="sm"
onClick={() => handleDeleteServer(server.id)}
disabled={!canDeleteServers || setupResourcesLoading || serverDeleteId === server.id || endpointDeleteId !== null || apiKeyUpdatingId === server.id}
disabled={maintenanceLocked || !canDeleteServers || setupResourcesLoading || serverDeleteId === server.id || apiKeyUpdatingId === server.id}
>
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
</Button>
@@ -1016,7 +1060,7 @@ export function Maintenance() {
color="blue"
size="sm"
onClick={() => handleApiKeyUpdate(server.id)}
disabled={!canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
disabled={maintenanceLocked || !canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
>
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
</Button>
@@ -1029,61 +1073,74 @@ export function Maintenance() {
)}
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 border-t border-blue-gray-50 pt-4">
<div className="flex flex-col gap-1">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Endpoints
Self-Stack-ID
</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 className="text-xs text-stormGrey-500">
Optional lass das Feld leer, wenn keine Self-Stack-ID benötigt wird.
</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={!canDeleteServers || setupResourcesLoading || endpointDeleteId === endpoint.id || serverDeleteId !== null}
>
{endpointDeleteId === endpoint.id ? "Lösche…" : "Löschen"}
</Button>
</div>
</div>
))}
</div>
{selfStackInfo?.envProvided && (
<p className="text-xs text-stormGrey-500">
Hinweis: Eine Umgebungsvariable liefert bereits einen Standardwert.
</p>
)}
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<div className="flex-1">
<Input
label="Self-Stack-ID"
value={selfStackDraft}
onChange={(event) => handleSelfStackDraftChange(event.target.value)}
disabled={!canEditServers || setupResourcesLoading || selfStackSaving}
/>
</div>
{canEditServers && (
<div className="flex items-center justify-end gap-2">
<Button
variant="text"
color="red"
size="sm"
onClick={handleSelfStackRemove}
disabled={maintenanceLocked || selfStackSaving || (!currentSelfStackValue && !selfStackDraft)}
>
Entfernen
</Button>
<Button
color="blue"
size="sm"
onClick={handleSelfStackSave}
disabled={maintenanceLocked || selfStackSaving || setupResourcesLoading || !selfStackDirty}
>
{selfStackSaving ? "Speichere…" : "Self-Stack-ID speichern"}
</Button>
</div>
)}
</div>
{!canEditServers && (
<p className="text-xs text-stormGrey-500">
Bearbeitung benötigt das Gruppenrecht Server-Sektion.
</p>
)}
<p className="text-xs text-stormGrey-500">
Aktuell gespeichert: {currentSelfStackValue || "nicht gesetzt"}
</p>
{envSelfStackValue && (
<p className="text-xs text-stormGrey-500">
Vorgabe aus Umgebung: {envSelfStackValue}
</p>
)}
{selfStackError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{selfStackError}
</Alert>
)}
</div>
{!setupComplete && (
<Alert color="amber" className="border border-amber-200 bg-amber-50 text-amber-800">
Das Setup ist aktuell unvollständig. Bitte öffne den Setup-Bereich, um Server, Endpoint und API-Key erneut festzulegen.
Das Setup ist aktuell unvollständig. Bitte öffne den Setup-Bereich, um Server und API-Key erneut festzulegen.
</Alert>
)}
@@ -1093,7 +1150,7 @@ export function Maintenance() {
color="gray"
size="sm"
onClick={() => loadSetupResources()}
disabled={setupResourcesLoading}
disabled={maintenanceLocked || setupResourcesLoading}
>
Aktualisieren
</Button>
@@ -1142,7 +1199,7 @@ export function Maintenance() {
variant="outlined"
color="gray"
onClick={() => loadSuperuserStatus()}
disabled={superuserStatusLoading || superuserDeleteLoading}
disabled={maintenanceLocked || superuserStatusLoading || superuserDeleteLoading}
className="w-full sm:w-auto"
>
Status aktualisieren
@@ -1150,7 +1207,7 @@ export function Maintenance() {
<Button
color="red"
onClick={handleSuperuserDelete}
disabled={superuserStatusLoading || superuserDeleteLoading || !superuserExists}
disabled={maintenanceLocked || !canDeleteSuperuser || superuserStatusLoading || superuserDeleteLoading || !superuserExists}
className="w-full sm:w-auto"
>
{superuserDeleteLoading ? "Wird gelöscht…" : "Superuser löschen"}
@@ -1203,7 +1260,7 @@ export function Maintenance() {
<div className="flex items-center gap-3">
<Button
onClick={() => fetchPortainerStatus({ silent: false })}
disabled={portainerLoading || portainerRefreshing}
disabled={maintenanceLocked || portainerLoading || portainerRefreshing}
className="w-full">
Status aktualisieren
</Button>
@@ -1424,14 +1481,14 @@ export function Maintenance() {
<Button
onClick={handleSshSaveConfig}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
className="mt-3 w-full">
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
</Button>
<Button
onClick={handleSshTestConnection}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
className="w-full bg-arcticBlue-500 hover:bg-arcticBlue-600">
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
</Button>
@@ -1439,7 +1496,7 @@ export function Maintenance() {
<Button
onClick={handleSshDeleteConfig}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || maintenanceLocked}
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500"
>
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
@@ -1469,14 +1526,14 @@ export function Maintenance() {
<div className="mt-3 grid gap-2">
<Button
onClick={handleScriptSave}
disabled={!canManageSsh || !scriptIsDirty || scriptSaving || updateRunning}
disabled={!canManageSsh || !scriptIsDirty || scriptSaving || maintenanceLocked}
className="mt-3 w-full">
Speichern
</Button>
<Button
color="purple"
onClick={handleScriptReset}
disabled={!canManageSsh || !scriptConfig || scriptConfig.source !== "custom" || scriptSaving || updateRunning}
disabled={!canManageSsh || !scriptConfig || scriptConfig.source !== "custom" || scriptSaving || maintenanceLocked}
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500">
Standard wiederherstellen
</Button>
@@ -1552,7 +1609,7 @@ export function Maintenance() {
<Button
onClick={handleTriggerUpdate}
disabled={!canManagePortainerUpdate || disableUpdateButton || updateActionLoading}
disabled={maintenanceLocked || !canManagePortainerUpdate || disableUpdateButton || updateActionLoading}
className="mt-3 w-full">
{updateActionLoading ? "Update wird gestartet…" : "Portainer aktualisieren"}
</Button>
@@ -1649,7 +1706,7 @@ export function Maintenance() {
</span>
</div>
<p className="text-sm text-gray-300">
Behaltener Stack: ID {canonicalId} (Endpoint {entry?.canonical?.EndpointId ?? "-"})
Behaltener Stack: ID {canonicalId}
</p>
<p className="text-xs text-gray-500">
Typ: {resolveStackType(entry?.canonical?.Type)} Erstellt: {formatCreatedAt(entry?.canonical?.Created)}
@@ -1674,7 +1731,6 @@ export function Maintenance() {
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-semibold text-white">ID: {duplicate.Id}</span>
<span>Endpoint: {duplicate.EndpointId ?? "-"}</span>
<span>Typ: {resolveStackType(duplicate.Type)}</span>
<span>Erstellt: {formatCreatedAt(duplicate.Created)}</span>
</div>
@@ -11,6 +11,15 @@ import {
import { useNavigate } from "react-router-dom";
import { ArrowDownTrayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { useAuth } from "@/components/AuthProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
const formatWordsAsText = (words = []) => {
if (!Array.isArray(words) || words.length === 0) {
@@ -37,6 +46,7 @@ const groupWords = (words = []) => {
export function SecurityPhrase() {
const navigate = useNavigate();
const { user, setSession, refreshSession } = useAuth();
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [words, setWords] = useState([]);
@@ -44,6 +54,11 @@ export function SecurityPhrase() {
const [downloaded, setDownloaded] = useState(false);
const requiresDownload = Boolean(user?.requiresSecurityPhraseDownload);
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const groupedWords = useMemo(() => groupWords(words), [words]);
@@ -83,6 +98,11 @@ export function SecurityPhrase() {
navigate("/dashboard/stacks", { replace: true });
return;
}
if (err?.response?.status === 401) {
await refreshSession();
navigate("/auth/sign-in", { replace: true });
return;
}
const message = err?.response?.data?.error || err?.message || "Sicherheitsschlüssel konnte nicht geladen werden.";
setError(message);
@@ -133,6 +153,11 @@ export function SecurityPhrase() {
setDownloaded(Boolean(downloadedAt));
navigate("/dashboard/stacks", { replace: true });
} catch (err) {
if (err?.response?.status === 401) {
await refreshSession();
navigate("/auth/sign-in", { replace: true });
return;
}
const message = err?.response?.data?.error || err?.message || "Download konnte nicht bestätigt werden.";
setError(message);
} finally {
@@ -223,7 +248,7 @@ export function SecurityPhrase() {
size="md"
className="flex items-center gap-2 normal-case"
onClick={handleDownload}
disabled={saving || downloaded || words.length === 0}
disabled={maintenanceLocked || saving || downloaded || words.length === 0}
>
<ArrowDownTrayIcon className="h-5 w-5" />
Sicherheitsschlüssel herunterladen
+12 -11
View File
@@ -819,18 +819,19 @@ export function Stacks() {
<div className="mt-12 mb-8 flex flex-col gap-12">
{(maintenanceActive || updateRunning) && (<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
)}
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
</div>
)}
<Card>
@@ -1072,7 +1073,7 @@ export function Stacks() {
) : (canRedeploySingle ? (
<Button
onClick={() => handleRedeploy(stack.Id)}
disabled={isBusy}
disabled={maintenanceLocked || isBusy}
className="disabled:opacity-50 disabled:cursor-not-allowed"
>
Redeploy
+52 -16
View File
@@ -21,6 +21,15 @@ import { useAuth } from "@/components/AuthProvider.jsx";
const _ = AVATAR_COLORS.join(" ");
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
return [];
@@ -95,7 +104,7 @@ const buildInitialFormValues = (user) => {
export function UserDetails() {
const { userId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const { hasPermission, user: authUser } = useAuth();
const [user, setUser] = useState(null);
@@ -115,7 +124,11 @@ export function UserDetails() {
const [securityPhraseError, setSecurityPhraseError] = useState("");
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
const maintenanceActive = Boolean(maintenance?.active);
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const canEditUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "full"));
const canReadUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "read"));
@@ -132,6 +145,8 @@ export function UserDetails() {
}
return user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser");
}, [user]);
const canCurrentUserEditSuperuser = authUser?.isSuperuser && user?.id && Number(authUser.id) === Number(user.id);
const superuserFieldsLocked = isSuperuserUser && !canCurrentUserEditSuperuser;
const numericUserId = useMemo(() => {
const asNumber = Number(userId);
@@ -161,7 +176,7 @@ export function UserDetails() {
setSecurityPhraseWords([]);
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
setSecurityPhraseError("");
const initialValues = buildInitialFormValues(item);
const initialValues = buildInitialFormValues(item);
initialFormValuesRef.current = { ...initialValues };
setFormValues(initialValues);
setSaveError("");
@@ -436,7 +451,7 @@ export function UserDetails() {
}, [canRenewSecurityPhrase, fetchSecurityPhrase, renewingSecurityPhrase, securityPhraseLoading]);
const handleRenewSecurityPhrase = useCallback(async () => {
if (!canRenewSecurityPhrase || !numericUserId || maintenanceActive || renewingSecurityPhrase) {
if (!canRenewSecurityPhrase || !numericUserId || maintenanceLocked || renewingSecurityPhrase) {
return;
}
@@ -472,7 +487,7 @@ export function UserDetails() {
}, [
canRenewSecurityPhrase,
numericUserId,
maintenanceActive,
maintenanceLocked,
renewingSecurityPhrase,
showToast
]);
@@ -560,17 +575,26 @@ export function UserDetails() {
fetchSecurityPhrase();
}, [hasLoaded, canManageSecurityPhrase, fetchSecurityPhrase, numericUserId]);
const inputDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
const avatarSelectDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
const inputDisabled = maintenanceLocked || savingUser || !user || !canEditUsers;
const selectDisabled = maintenanceLocked || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
const avatarSelectDisabled = maintenanceLocked || savingUser || !user || !canEditUsers || superuserFieldsLocked;
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
return (
<>
<div className="mt-12 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert.
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
@@ -600,7 +624,7 @@ export function UserDetails() {
color="green"
className="normal-case"
onClick={handleSaveUser}
disabled={maintenanceActive || savingUser}
disabled={maintenanceLocked || savingUser}
>
{savingUser ? "Speichert ..." : "Änderungen speichern"}
</Button>
@@ -627,6 +651,16 @@ export function UserDetails() {
<Typography variant="h6" color="blue-gray" className="mb-4">
Allgemeine Einstellungen
</Typography>
{(isSuperuserUser) && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Systemgruppe Name und Beschreibung sind geschützt.
{superuserFieldsLocked && (
<span className="mt-1 block text-xs text-amber-600">
Nur der Superuser selbst kann Benutzername, E-Mail-Adresse und Passwort ändern.
</span>
)}
</div>
)}
<div className="mb-6">
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Benutzername
@@ -635,7 +669,7 @@ export function UserDetails() {
value={formValues.username}
onChange={handleUsernameChange}
placeholder="Benutzername"
disabled={inputDisabled}
disabled={inputDisabled || superuserFieldsLocked}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
@@ -651,7 +685,7 @@ export function UserDetails() {
value={formValues.email}
onChange={handleEmailChange}
placeholder="benutzer@example.com"
disabled={inputDisabled}
disabled={inputDisabled || superuserFieldsLocked}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
@@ -667,7 +701,7 @@ export function UserDetails() {
value={formValues.password}
onChange={handlePasswordChange}
placeholder="Passwort setzen"
disabled={inputDisabled}
disabled={inputDisabled || superuserFieldsLocked}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none"
@@ -787,6 +821,7 @@ export function UserDetails() {
onClick={handleReloadSecurityPhrase}
disabled={
!canRenewSecurityPhrase ||
maintenanceLocked ||
securityPhraseLoading ||
renewingSecurityPhrase
}
@@ -799,7 +834,7 @@ export function UserDetails() {
onClick={handleRenewSecurityPhrase}
disabled={
!canRenewSecurityPhrase ||
maintenanceActive ||
maintenanceLocked ||
securityPhraseLoading ||
renewingSecurityPhrase
}
@@ -824,6 +859,7 @@ export function UserDetails() {
onClick={handleReloadSecurityPhrase}
disabled={
!canRenewSecurityPhrase ||
maintenanceLocked ||
securityPhraseLoading ||
renewingSecurityPhrase
}
+174 -112
View File
@@ -24,6 +24,15 @@ import { AVATAR_COLORS } from "@/data/avatarColors.js";
const _ = AVATAR_COLORS.join(" ");
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
const mapGroup = (item) => ({
id: item?.id ?? null,
name: item?.name || "",
@@ -63,11 +72,19 @@ const LEVEL_OPTIONS = [
{ value: "read", label: "Nur lesen" },
{ value: "none", label: "Kein Zugriff" }
];
const LEVEL_PRIORITY = {
none: 0,
read: 1,
full: 2
};
const normalizePermissionLevel = (key, value) =>
key === "maintenance-server-delete" && value !== "full" ? "none" : value;
export function UserGroupDetail() {
const { groupId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const { hasPermission, user: authUser } = useAuth();
const [group, setGroup] = useState(null);
@@ -85,12 +102,17 @@ export function UserGroupDetail() {
const [permissionsLoading, setPermissionsLoading] = useState(false);
const [permissionsError, setPermissionsError] = useState("");
const maintenanceActive = Boolean(maintenance?.active);
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
const canEditGroupDetails = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
const canViewPermissionSettings = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "read"));
const canAdjustPermissions = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
const superuserGroupLocked = isSuperuserGroup && !isSuperuserAccount;
const numericGroupId = useMemo(() => {
const candidate = Number(groupId);
@@ -162,15 +184,15 @@ export function UserGroupDetail() {
const sectionGroups = Array.isArray(section.groups)
? section.groups.map((group, groupIdx) => {
const groupItems = Array.isArray(group.items)
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
: [];
return {
...group,
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
items: groupItems
};
})
const groupItems = Array.isArray(group.items)
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
: [];
return {
...group,
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
items: groupItems
};
})
: [];
return {
@@ -485,22 +507,33 @@ export function UserGroupDetail() {
return group?.avatarColor || "";
}, [formValues.avatarColor, group]);
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup || !canEditGroupDetails;
const selectDisabled = maintenanceActive || savingGroup || !group || !canEditGroupDetails;
const inputDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
const selectDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails;
const selectValue = formValues.avatarColor || "";
const handlePermissionChange = useCallback((permissionKey, value) => {
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
return;
}
const nextValue = normalizePermissionLevel(permissionKey, value);
setPermissionSelection((prev) => {
if (prev[permissionKey] === value) {
const shouldResetDelete =
permissionKey === "maintenance-server-manage" && nextValue !== "full";
if (!shouldResetDelete && prev[permissionKey] === nextValue) {
return prev;
}
return {
const nextState = {
...prev,
[permissionKey]: value
[permissionKey]: nextValue
};
if (shouldResetDelete) {
nextState["maintenance-server-delete"] = "none";
}
return nextState;
});
}, [canAdjustPermissions, isSuperuserGroup]);
@@ -509,13 +542,16 @@ export function UserGroupDetail() {
if (!permissionKey) {
return null;
}
let value = null;
if (Object.prototype.hasOwnProperty.call(permissionSelection, permissionKey)) {
return permissionSelection[permissionKey];
value = permissionSelection[permissionKey];
} else if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) {
value = permissionDefaults[permissionKey];
}
if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) {
return permissionDefaults[permissionKey];
if (typeof value === "string") {
return normalizePermissionLevel(permissionKey, value);
}
return null;
return value;
},
[permissionSelection, permissionDefaults]
);
@@ -571,6 +607,8 @@ export function UserGroupDetail() {
const rowName = `permission-${ownerKey}-${row.key}`;
const currentValue = getPermissionValue(row.key) ?? "none";
const displayValue = normalizePermissionLevel(row.key, currentValue);
const serverManageLevel = getPermissionValue("maintenance-server-manage") ?? "none";
const { addTopBorder = true } = options;
const rowClasses = [
@@ -580,10 +618,13 @@ export function UserGroupDetail() {
.filter(Boolean)
.join(" ");
const baseLevels = Array.isArray(row.availableLevels) && row.availableLevels.length
? row.availableLevels
: LEVEL_OPTIONS.map((option) => option.value);
const availableLevels = new Set(
Array.isArray(row.availableLevels) && row.availableLevels.length
? row.availableLevels
: LEVEL_OPTIONS.map((option) => option.value)
row.key === "maintenance-server-delete"
? baseLevels.filter((level) => level === "full" || level === "none")
: baseLevels
);
const levelOptions = LEVEL_OPTIONS;
@@ -595,13 +636,18 @@ export function UserGroupDetail() {
<List className="flex-col gap-1.5 md:ml-auto md:flex-row md:flex-nowrap md:w-1/2 md:items-center md:justify-end md:gap-3">
{levelOptions.map((option) => {
const optionId = `${rowName}-${option.value}`;
const checked = currentValue === option.value;
const checked = displayValue === option.value;
const requiresManageFull =
row.key === "maintenance-server-delete" &&
option.value === "full" &&
(LEVEL_PRIORITY[serverManageLevel] ?? 0) < LEVEL_PRIORITY.full;
const disabled =
!availableLevels.has(option.value) ||
permissionsLoading ||
savingGroup ||
!canAdjustPermissions ||
isSuperuserGroup;
isSuperuserGroup ||
requiresManageFull;
return (
<ListItem
@@ -610,9 +656,8 @@ export function UserGroupDetail() {
>
<label
htmlFor={optionId}
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${
disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
}`}
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
}`}
>
<ListItemPrefix className="flex-shrink-0">
<Radio
@@ -633,9 +678,8 @@ export function UserGroupDetail() {
</ListItemPrefix>
<Typography
color="blue-gray"
className={`text-sm font-medium leading-5 whitespace-nowrap ${
disabled ? "text-blue-gray-300" : "text-blue-gray-600"
}`}
className={`text-sm font-medium leading-5 whitespace-nowrap ${disabled ? "text-blue-gray-300" : "text-blue-gray-600"
}`}
>
{option.label}
</Typography>
@@ -651,9 +695,18 @@ export function UserGroupDetail() {
return (
<>
<div className="mt-12 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert.
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
@@ -683,7 +736,7 @@ export function UserGroupDetail() {
color="green"
className="normal-case"
onClick={handleSaveGroup}
disabled={maintenanceActive || savingGroup}
disabled={maintenanceLocked || savingGroup}
>
{savingGroup ? "Speichert ..." : "Änderungen speichern"}
</Button>
@@ -710,11 +763,14 @@ export function UserGroupDetail() {
<Typography variant="h6" color="blue-gray" className="mb-4">
Gruppendaten
</Typography>
{(maintenanceActive || isSuperuserGroup) && (
{isSuperuserGroup && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{maintenanceActive
? "Wartungsmodus aktiv Änderungen sind deaktiviert."
: "Systemgruppe Name und Beschreibung sind geschützt."}
Systemgruppe Name und Beschreibung sind geschützt.
{superuserGroupLocked && (
<span className="mt-1 block text-xs text-amber-600">
Nur der Superuser darf die Superuser-Gruppe bearbeiten.
</span>
)}
</div>
)}
<div className="mb-6">
@@ -810,81 +866,87 @@ export function UserGroupDetail() {
</div>
</div>
<div className="lg:col-span-2 xl:col-span-3">
<Typography variant="h6" color="blue-gray" className="mb-4">
Berechtigungen (Ansicht)
</Typography>
{isSuperuserGroup ? (
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
</div>
) : !canViewPermissionSettings ? null : (
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
{permissionsLoading && (
<div className="border-b border-blue-gray-100 px-4 py-4">
<div className="flex items-center gap-3 text-sm text-blue-gray-500">
<Spinner className="h-4 w-4" />
<span>Berechtigungen werden geladen ...</span>
</div>
{(!maintenanceActive && !updateRunning) && (
<>
<Typography variant="h6" color="blue-gray" className="mb-4">
Berechtigungen (Ansicht)
</Typography>
{isSuperuserGroup ? (
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
</div>
)}
{!permissionsLoading && permissionsError && (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-red-700">
{permissionsError}
</div>
)}
{!permissionsLoading && !permissionsError &&
(permissionSections.length === 0 ? (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-blue-gray-500">
Für diese Gruppe sind keine Berechtigungen definiert.
</div>
) : (
permissionSections.map((section, sectionIndex) => {
const sectionItems = Array.isArray(section.items) ? section.items : [];
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
return (
<div
key={section.key || sectionIndex}
className={sectionIndex === 0 ? "" : "border-t border-blue-gray-100"}
>
<div className="border-b border-blue-gray-100 px-4 py-4">
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
{section.title}
</Typography>
</div>
{sectionItems.map((row, rowIndex) =>
renderPermissionRow(row, section.key || sectionIndex, {
addTopBorder: rowIndex !== 0
})
)}
{sectionGroups.map((group, groupIdx) => {
const groupItems = Array.isArray(group.items) ? group.items : [];
const hasVisibleRows = groupItems.some(isPermissionRowVisible);
if (!hasVisibleRows) {
return null;
}
return (
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
<div className="border-b border-blue-gray-100 px-4 py-3">
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
{group.title}
</Typography>
</div>
{groupItems.map((row, rowIndex) =>
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
addTopBorder: rowIndex !== 0
})
)}
</div>
);
})}
) : !canViewPermissionSettings ? null : (
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
{permissionsLoading && (
<div className="border-b border-blue-gray-100 px-4 py-4">
<div className="flex items-center gap-3 text-sm text-blue-gray-500">
<Spinner className="h-4 w-4" />
<span>Berechtigungen werden geladen ...</span>
</div>
);
})
))}
</div>
</div>
)}
{!permissionsLoading && permissionsError && (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-red-700">
{permissionsError}
</div>
)}
{!permissionsLoading && !permissionsError &&
(permissionSections.length === 0 ? (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-blue-gray-500">
Für diese Gruppe sind keine Berechtigungen definiert.
</div>
) : (
permissionSections.map((section, sectionIndex) => {
const sectionItems = Array.isArray(section.items) ? section.items : [];
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
return (
<div
key={section.key || sectionIndex}
className={sectionIndex === 0 ? "" : "border-t border-blue-gray-100"}
>
<div className="border-b border-blue-gray-100 px-4 py-4">
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
{section.title}
</Typography>
</div>
{sectionItems.map((row, rowIndex) =>
renderPermissionRow(row, section.key || sectionIndex, {
addTopBorder: rowIndex !== 0
})
)}
{sectionGroups.map((group, groupIdx) => {
const groupItems = Array.isArray(group.items) ? group.items : [];
const hasVisibleRows = groupItems.some(isPermissionRowVisible);
if (!hasVisibleRows) {
return null;
}
return (
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
<div className="border-b border-blue-gray-100 px-4 py-3">
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
{group.title}
</Typography>
</div>
{groupItems.map((row, rowIndex) =>
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
addTopBorder: rowIndex !== 0
})
)}
</div>
);
})}
</div>
);
})
))}
</div>
)}
</>
)}
</div>
</div>
+40 -12
View File
@@ -17,6 +17,15 @@ import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
export function Usergroups() {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(false);
@@ -29,8 +38,12 @@ export function Usergroups() {
const [deletingGroupId, setDeletingGroupId] = useState(null);
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const navigate = useNavigate();
const { hasPermission } = useAuth();
@@ -190,7 +203,8 @@ export function Usergroups() {
}, []);
const handleCreateGroup = useCallback(async () => {
if (maintenanceActive) {
if (maintenanceLocked) {
setCreateGroupError("Im Wartungsmodus oder während eines Updates können keine Gruppen angelegt werden.");
return;
}
if (!canEditGroups) {
@@ -236,7 +250,7 @@ export function Usergroups() {
setCreatingGroup(false);
}
}, [
maintenanceActive,
maintenanceLocked,
canEditGroups,
newGroupName,
newGroupDescription,
@@ -245,10 +259,10 @@ export function Usergroups() {
fetchGroups
]);
const createGroupDisabled = maintenanceActive || creatingGroup;
const createGroupDisabled = maintenanceLocked || creatingGroup;
const handleDeleteGroup = useCallback(async (group) => {
if (maintenanceActive || !group?.id) {
if (maintenanceLocked || !group?.id) {
return;
}
@@ -322,13 +336,22 @@ export function Usergroups() {
} finally {
setDeletingGroupId(null);
}
}, [maintenanceActive, canDeleteGroups, showToast, fetchGroups]);
}, [maintenanceLocked, canDeleteGroups, showToast, fetchGroups]);
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<Card className="border border-blue-gray-100 shadow-sm">
@@ -374,7 +397,12 @@ export function Usergroups() {
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewGroupForm} disabled={creatingGroup}>
<Button
variant="text"
color="blue-gray"
onClick={resetNewGroupForm}
disabled={creatingGroup || maintenanceLocked}
>
Formular zurücksetzen
</Button>
</div>
@@ -498,7 +526,7 @@ export function Usergroups() {
color="red"
onClick={() => handleDeleteGroup(group)}
disabled={
maintenanceActive ||
maintenanceLocked ||
deletingGroupId === group.id ||
Number(group.memberCount) > 0
}
+49 -21
View File
@@ -17,6 +17,15 @@ import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
"activating-maintenance": "Wartungsmodus aktivieren",
"executing-script": "Skript wird ausgeführt",
waiting: "Warte auf Portainer",
completed: "Abgeschlossen",
failed: "Fehlgeschlagen"
};
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
return [];
@@ -61,8 +70,12 @@ export function Users() {
const [togglingUserId, setTogglingUserId] = useState(null);
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const { maintenance: maintenanceMeta, update: updateState } = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const maintenanceLocked = maintenanceActive || updateRunning;
const navigate = useNavigate();
const noop = useCallback(() => { }, []);
const { hasPermission } = useAuth();
@@ -253,7 +266,7 @@ export function Users() {
}, []);
const handleDeleteUser = useCallback(async (user) => {
if (maintenanceActive || !user?.id) {
if (maintenanceLocked || !user?.id) {
return;
}
if (!canDeleteUsers) {
@@ -314,7 +327,7 @@ export function Users() {
} finally {
setDeletingUserId(null);
}
}, [maintenanceActive, canDeleteUsers, showToast, fetchUsers]);
}, [maintenanceLocked, canDeleteUsers, showToast, fetchUsers]);
const handleToggleUserStatus = useCallback(async (user) => {
if (!user?.id) {
@@ -323,11 +336,11 @@ export function Users() {
if (!canManageUsers) {
return;
}
if (maintenanceActive) {
if (maintenanceLocked) {
showToast({
variant: "error",
title: "Aktion nicht möglich",
description: "Im Wartungsmodus können keine Statusänderungen durchgeführt werden."
description: "Im Wartungsmodus oder während eines Updates sind Statusänderungen gesperrt."
});
return;
}
@@ -386,10 +399,11 @@ export function Users() {
} finally {
setTogglingUserId(null);
}
}, [maintenanceActive, canManageUsers, showToast, fetchUsers]);
}, [maintenanceLocked, canManageUsers, showToast, fetchUsers]);
const handleCreateUser = useCallback(async () => {
if (maintenanceActive) {
if (maintenanceLocked) {
setCreateError("Im Wartungsmodus oder während eines Updates können keine Benutzer angelegt werden.");
return;
}
if (!canManageUsers) {
@@ -467,7 +481,7 @@ export function Users() {
setCreatingUser(false);
}
}, [
maintenanceActive,
maintenanceLocked,
canManageUsers,
newUsername,
newEmail,
@@ -479,8 +493,8 @@ export function Users() {
]);
const hasSelectableGroups = availableGroups.length > 0;
const createDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || !hasSelectableGroups;
const createDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
const groupSelectDisabled = maintenanceLocked || creatingUser || groupsLoading || !hasSelectableGroups;
const renderSelectedGroup = useCallback(
(element) => {
if (element && element.props && element.props.children) {
@@ -497,9 +511,18 @@ export function Users() {
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
{(maintenanceActive || updateRunning) && (
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
</span>
{updateRunning && (
<span className="text-xs text-indigo-900">
Phase: {updateStageLabel}
</span>
)}
</div>
</div>
)}
<Card>
@@ -539,14 +562,14 @@ export function Users() {
label="Benutzername"
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
disabled={maintenanceActive || creatingUser}
disabled={maintenanceLocked || creatingUser}
crossOrigin=""
/>
<Input
label="E-Mail (optional)"
value={newEmail}
onChange={(event) => setNewEmail(event.target.value)}
disabled={maintenanceActive || creatingUser}
disabled={maintenanceLocked || creatingUser}
crossOrigin=""
/>
<Input
@@ -554,7 +577,7 @@ export function Users() {
label="Passwort"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
disabled={maintenanceActive || creatingUser}
disabled={maintenanceLocked || creatingUser}
crossOrigin=""
/>
<Select
@@ -582,7 +605,12 @@ export function Users() {
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewUserForm} disabled={creatingUser}>
<Button
variant="text"
color="blue-gray"
onClick={resetNewUserForm}
disabled={creatingUser || maintenanceLocked}
>
Formular zurücksetzen
</Button>
</div>
@@ -694,10 +722,10 @@ export function Users() {
{canManageUsers ? (
<button
type="button"
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceActive ? "cursor-not-allowed" : "cursor-pointer"}`}
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceLocked ? "cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => handleToggleUserStatus(user)}
disabled={
maintenanceActive ||
maintenanceLocked ||
togglingUserId === user.id ||
(Array.isArray(user.groups) &&
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
@@ -746,7 +774,7 @@ export function Users() {
variant="text"
color="red"
onClick={() => handleDeleteUser(user)}
disabled={maintenanceActive || deletingUserId === user.id}
disabled={maintenanceLocked || deletingUserId === user.id}
>
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
</Button>
File diff suppressed because it is too large Load Diff