Wrap modals in portal and use zIndex

Use ModalPortal and zIndex values from useRegisterModal across modal components to avoid hardcoded z-50 stacking. Updated ExecutionModeModal, LXCSettingsModal, PublicKeyModal, ReleaseNotesModal, SetupModal, StorageSelectionModal, and TextViewer to import ModalPortal, capture the zIndex returned by useRegisterModal, wrap modal markup in <ModalPortal>, and apply style={{ zIndex }} to backdrops; LXC result overlay uses zIndex + 10 for proper stacking. Also changed LoadingOverlay in VersionDisplay to render with createPortal(document.body) and added the import. These changes centralize stacking behavior and prevent z-index conflicts when multiple modals/overlays are present.
This commit is contained in:
CanbiZ (MickLesk)
2026-04-01 15:12:10 +02:00
parent 6427a984e7
commit 3fdf438c6d
16 changed files with 128 additions and 63 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, type ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import { useAuth } from './AuthProvider';
import { AuthModal } from './AuthModal';
import { SetupModal } from './SetupModal';
@@ -9,45 +9,17 @@ interface AuthGuardProps {
children: ReactNode;
}
interface AuthConfig {
username: string | null;
enabled: boolean;
hasCredentials: boolean;
setupCompleted: boolean;
}
export function AuthGuard({ children }: AuthGuardProps) {
const { isAuthenticated, isLoading } = useAuth();
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
const [configLoading, setConfigLoading] = useState(true);
const [setupCompleted, setSetupCompleted] = useState(false);
const { isAuthenticated, isLoading, setupCompleted, authEnabled, refreshConfig } = useAuth();
const [localSetupCompleted, setLocalSetupCompleted] = useState(false);
const handleSetupComplete = async () => {
setSetupCompleted(true);
// Refresh auth config without reloading the page
await fetchAuthConfig();
setLocalSetupCompleted(true);
await refreshConfig();
};
const fetchAuthConfig = async () => {
try {
const response = await fetch('/api/settings/auth-credentials');
if (response.ok) {
const config = await response.json() as AuthConfig;
setAuthConfig(config);
}
} catch (error) {
console.error('Error fetching auth config:', error);
} finally {
setConfigLoading(false);
}
};
useEffect(() => {
void fetchAuthConfig();
}, []);
// Show loading while checking auth status
if (isLoading || configLoading) {
// Show loading while AuthProvider is still checking
if (isLoading || setupCompleted === null) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
@@ -59,12 +31,12 @@ export function AuthGuard({ children }: AuthGuardProps) {
}
// Show setup modal if setup has not been completed yet
if (authConfig && !authConfig.setupCompleted && !setupCompleted) {
if (!setupCompleted && !localSetupCompleted) {
return <SetupModal isOpen={true} onComplete={handleSetupComplete} />;
}
// Show auth modal if auth is enabled but user is not authenticated
if (authConfig && authConfig.enabled && !isAuthenticated) {
if (authEnabled && !isAuthenticated) {
return <AuthModal isOpen={true} />;
}

View File

@@ -14,9 +14,12 @@ interface AuthContextType {
username: string | null;
isLoading: boolean;
expirationTime: number | null;
setupCompleted: boolean | null;
authEnabled: boolean | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<void>;
refreshConfig: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -30,6 +33,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
const [username, setUsername] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [expirationTime, setExpirationTime] = useState<number | null>(null);
const [setupCompleted, setSetupCompleted] = useState<boolean | null>(null);
const [authEnabled, setAuthEnabled] = useState<boolean | null>(null);
const checkAuthInternal = async (retryCount = 0) => {
try {
@@ -41,6 +46,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
enabled: boolean;
};
setSetupCompleted(setupData.setupCompleted);
setAuthEnabled(setupData.enabled);
// If setup is not completed or auth is disabled, don't verify
if (!setupData.setupCompleted || !setupData.enabled) {
setIsAuthenticated(false);
@@ -99,6 +107,22 @@ export function AuthProvider({ children }: AuthProviderProps) {
return checkAuthInternal(0);
}, []);
const refreshConfig = useCallback(async () => {
try {
const response = await fetch("/api/settings/auth-credentials");
if (response.ok) {
const data = (await response.json()) as {
setupCompleted: boolean;
enabled: boolean;
};
setSetupCompleted(data.setupCompleted);
setAuthEnabled(data.enabled);
}
} catch (error) {
console.error("Error refreshing auth config:", error);
}
}, []);
const login = async (
username: string,
password: string,
@@ -158,9 +182,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
username,
isLoading,
expirationTime,
setupCompleted,
authEnabled,
login,
logout,
checkAuth,
refreshConfig,
}}
>
{children}

View File

@@ -13,6 +13,34 @@ interface CategorySidebarProps {
}
// Icon mapping for categories
const categoryIconColorMap: Record<string, string> = {
server: "text-blue-500",
monitor: "text-sky-400",
box: "text-orange-400",
shield: "text-green-500",
"shield-check": "text-green-500",
key: "text-yellow-500",
archive: "text-amber-400",
database: "text-indigo-500",
"chart-bar": "text-emerald-500",
template: "text-violet-500",
"folder-open": "text-cyan-500",
"document-text": "text-slate-400",
film: "text-rose-500",
download: "text-cyan-500",
"video-camera": "text-pink-500",
home: "text-lime-500",
wifi: "text-fuchsia-500",
"chat-alt": "text-sky-500",
clock: "text-orange-500",
code: "text-green-400",
"external-link": "text-blue-400",
sparkles: "text-purple-500",
"currency-dollar": "text-emerald-400",
puzzle: "text-pink-400",
office: "text-stone-500",
};
const CategoryIcon = ({
iconName,
className = "w-5 h-5",

View File

@@ -7,7 +7,7 @@ import { Button } from './ui/button';
import { ColorCodedDropdown } from './ColorCodedDropdown';
import { SettingsModal } from './SettingsModal';
import { ConfigurationModal, type EnvVars } from './ConfigurationModal';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider';
interface ExecutionModeModalProps {
@@ -19,7 +19,7 @@ interface ExecutionModeModalProps {
}
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, script }: ExecutionModeModalProps) {
useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
const zIndex = useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -95,7 +95,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr
return (
<>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<ModalPortal>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4" style={{ zIndex }}>
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
@@ -275,6 +276,7 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr
</div>
</div>
</div>
</ModalPortal>
{/* Server Settings Modal */}
<SettingsModal

View File

@@ -13,6 +13,7 @@ import { LXCSettingsModal } from "./LXCSettingsModal";
import { StorageSelectionModal } from "./StorageSelectionModal";
import { BackupWarningModal } from "./BackupWarningModal";
import { CloneCountInputModal } from "./CloneCountInputModal";
import { ModalPortal } from "./modal/ModalStackProvider";
import type { Storage } from "~/server/services/storageService";
import { getContrastColor } from "../../lib/colorUtils";
import {
@@ -2634,6 +2635,7 @@ export function InstalledScriptsTab() {
{/* Backup Prompt Modal */}
{showBackupPrompt && (
<ModalPortal>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
<div className="border-border flex items-center justify-center border-b p-6">
@@ -2695,6 +2697,7 @@ export function InstalledScriptsTab() {
</div>
</div>
</div>
</ModalPortal>
)}
{/* Storage Selection Modal */}

View File

@@ -9,7 +9,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon';
import { LoadingModal } from './LoadingModal';
import { ConfirmationModal } from './ConfirmationModal';
import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider';
interface InstalledScript {
id: number;
@@ -42,7 +42,7 @@ interface LXCSettingsModalProps {
}
export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: LXCSettingsModalProps) {
useRegisterModal(isOpen, { id: 'lxc-settings-modal', allowEscape: true, onClose });
const zIndex = useRegisterModal(isOpen, { id: 'lxc-settings-modal', allowEscape: true, onClose });
const [activeTab, setActiveTab] = useState<string>('common');
const [showConfirmation, setShowConfirmation] = useState(false);
const [showResultModal, setShowResultModal] = useState(false);
@@ -262,7 +262,8 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
return (
<>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<ModalPortal>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4" style={{ zIndex }}>
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
@@ -667,6 +668,7 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
</div>
</div>
</div>
</ModalPortal>
{/* Confirmation Modal */}
<ConfirmationModal
@@ -697,7 +699,7 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
{/* Result Modal */}
{showResultModal && resultType && resultMessage && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center" style={{ zIndex: zIndex + 10 }}>
<div className="bg-card text-card-foreground rounded-lg shadow-xl max-w-md w-full mx-4 border border-border">
<div className="p-6">
<div className="flex items-center gap-3 mb-4">

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import { X, Copy, Check, Server, Globe } from 'lucide-react';
import { Button } from './ui/button';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider';
interface PublicKeyModalProps {
isOpen: boolean;
@@ -14,7 +14,7 @@ interface PublicKeyModalProps {
}
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
useRegisterModal(isOpen, { id: 'public-key-modal', allowEscape: true, onClose });
const zIndex = useRegisterModal(isOpen, { id: 'public-key-modal', allowEscape: true, onClose });
const [copied, setCopied] = useState(false);
const [commandCopied, setCommandCopied] = useState(false);
@@ -94,7 +94,8 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
};
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<ModalPortal>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4" style={{ zIndex }}>
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
@@ -215,5 +216,6 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
</div>
</div>
</div>
</ModalPortal>
);
}

View File

@@ -5,7 +5,7 @@ import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -35,7 +35,7 @@ const markVersionAsSeen = (version: string): void => {
};
export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) {
useRegisterModal(isOpen, { id: 'release-notes-modal', allowEscape: true, onClose });
const zIndex = useRegisterModal(isOpen, { id: 'release-notes-modal', allowEscape: true, onClose });
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, {
enabled: isOpen
@@ -66,7 +66,8 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
const releases: Release[] = releasesData?.success ? releasesData.releases ?? [] : [];
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<ModalPortal>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4" style={{ zIndex }}>
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
@@ -215,6 +216,7 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
</div>
</div>
</div>
</ModalPortal>
);
}

View File

@@ -380,13 +380,14 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
.filter((s) => s?.date_created)
.sort((a, b) =>
(b?.date_created ?? "").localeCompare(a?.date_created ?? ""),
);
)
.slice(0, 15);
break;
case "updated":
scripts = scripts
.filter((s) => s?.updateable)
.filter((s) => s?.date_updated)
.sort((a, b) =>
(b?.date_created ?? "").localeCompare(a?.date_created ?? ""),
(b?.date_updated ?? "").localeCompare(a?.date_updated ?? ""),
);
break;
case "dev":

View File

@@ -5,7 +5,7 @@ import { Button } from './ui/button';
import { Input } from './ui/input';
import { Toggle } from './ui/toggle';
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider';
interface SetupModalProps {
isOpen: boolean;
@@ -13,7 +13,7 @@ interface SetupModalProps {
}
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
useRegisterModal(isOpen, { id: 'setup-modal', allowEscape: true, onClose: () => null });
const zIndex = useRegisterModal(isOpen, { id: 'setup-modal', allowEscape: true, onClose: () => null });
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@@ -84,7 +84,8 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<ModalPortal>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4" style={{ zIndex }}>
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
@@ -202,5 +203,6 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
</div>
</div>
</div>
</ModalPortal>
);
}

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { Database, RefreshCw, CheckCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider';
import type { Storage } from '~/server/services/storageService';
interface StorageSelectionModalProps {
@@ -33,7 +33,7 @@ export function StorageSelectionModal({
}: StorageSelectionModalProps) {
const [selectedStorage, setSelectedStorage] = useState<Storage | null>(null);
useRegisterModal(isOpen, { id: 'storage-selection-modal', allowEscape: true, onClose });
const zIndex = useRegisterModal(isOpen, { id: 'storage-selection-modal', allowEscape: true, onClose });
if (!isOpen) return null;
@@ -53,7 +53,8 @@ export function StorageSelectionModal({
const filteredStorages = filterFn ? storages.filter(filterFn) : storages.filter(s => s.supportsBackup);
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<ModalPortal>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4" style={{ zIndex }}>
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
@@ -171,6 +172,7 @@ export function StorageSelectionModal({
</div>
</div>
</div>
</ModalPortal>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Button } from "./ui/button";
import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider";
import type { Script } from "../../types/script";
interface TextViewerProps {
@@ -182,11 +183,15 @@ export function TextViewer({
}
};
const zIndex = useRegisterModal(isOpen, { id: 'text-viewer-modal', allowEscape: true, onClose });
if (!isOpen) return null;
return (
<ModalPortal>
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
style={{ zIndex }}
onClick={handleBackdropClick}
>
<div className="bg-card border-border mx-4 flex max-h-[90vh] w-full max-w-6xl flex-col rounded-lg border shadow-xl sm:mx-0">
@@ -372,5 +377,6 @@ export function TextViewer({
</div>
</div>
</div>
</ModalPortal>
);
}

View File

@@ -8,6 +8,7 @@ import { UpdateConfirmationModal } from "./UpdateConfirmationModal";
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
import { useState, useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
interface VersionDisplayProps {
onOpenReleaseNotes?: () => void;
@@ -29,7 +30,7 @@ function LoadingOverlay({
}, [logs]);
return (
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-card rounded-lg p-8 shadow-2xl border border-border max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
<div className="flex flex-col items-center space-y-4">
@@ -74,10 +75,10 @@ function LoadingOverlay({
</div>
</div>
</div>
</div>
</div>,
document.body,
);
}
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
const [isUpdating, setIsUpdating] = useState(false);

View File

@@ -479,9 +479,19 @@ async function isVM(scriptId: number, containerId: string, serverId: number | nu
}
}
// Cache for batch container type detection avoids repeated SSH calls
const containerTypeCacheByServer = new Map<number, { data: Map<string, boolean>; expiry: number }>();
const CONTAINER_TYPE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Helper function to batch detect container types for all containers on a server
// Returns a Map of container_id -> isVM (true for VM, false for LXC)
async function batchDetectContainerTypes(server: Server): Promise<Map<string, boolean>> {
// Check cache first
const cached = containerTypeCacheByServer.get(server.id);
if (cached && Date.now() < cached.expiry) {
return cached.data;
}
const containerTypeMap = new Map<string, boolean>();
try {
@@ -580,6 +590,9 @@ async function batchDetectContainerTypes(server: Server): Promise<Map<string, bo
// Return empty map on error - individual checks will fall back to isVM()
}
// Store in cache
containerTypeCacheByServer.set(server.id, { data: containerTypeMap, expiry: Date.now() + CONTAINER_TYPE_CACHE_TTL });
return containerTypeMap;
}

View File

@@ -66,6 +66,7 @@ function pbCardToScriptCard(pb: PBScriptCard): ScriptCard {
website: pb.website,
categoryNames: pb.categories.map((c) => c.name),
date_created: pb.script_created,
date_updated: pb.script_updated,
interface_port: pb.port,
is_dev: pb.is_dev,
is_disabled: pb.is_disabled,

View File

@@ -76,6 +76,7 @@ export interface ScriptCard {
/** Category names for display / filtering. */
categoryNames?: string[];
date_created?: string;
date_updated?: string;
os?: string;
version?: string;
interface_port?: number | null;