v0.5 Push
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & Endpoints</span>
|
||||
<span>Server & 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+668
-318
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user