chore: updating/match themes and split admin settings
This commit is contained in:
@@ -429,13 +429,59 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
activeSessions.set(sessionId, sshSession);
|
activeSessions.set(sessionId, sshSession);
|
||||||
|
|
||||||
// Detect or use provided shell
|
// Validate or detect shell
|
||||||
const detectedShell =
|
let shellToUse = shell || "bash";
|
||||||
shell || (await detectShell(sshSession, containerId));
|
|
||||||
sshSession.shell = detectedShell;
|
// If a shell is explicitly provided, verify it exists in the container
|
||||||
|
if (shell) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
client.exec(
|
||||||
|
`docker exec ${containerId} which ${shell}`,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
let output = "";
|
||||||
|
stream.on("data", (data: Buffer) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code: number) => {
|
||||||
|
if (code === 0 && output.trim()) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Shell ${shell} not available`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", () => {
|
||||||
|
// Ignore stderr
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Requested shell not found, detect available shell
|
||||||
|
dockerConsoleLogger.warn(
|
||||||
|
`Requested shell ${shell} not found, detecting available shell`,
|
||||||
|
{
|
||||||
|
operation: "shell_validation",
|
||||||
|
sessionId,
|
||||||
|
containerId,
|
||||||
|
requestedShell: shell,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
shellToUse = await detectShell(sshSession, containerId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No shell specified, detect available shell
|
||||||
|
shellToUse = await detectShell(sshSession, containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sshSession.shell = shellToUse;
|
||||||
|
|
||||||
// Create docker exec PTY
|
// Create docker exec PTY
|
||||||
const execCommand = `docker exec -it ${containerId} /bin/${detectedShell}`;
|
const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`;
|
||||||
|
|
||||||
client.exec(
|
client.exec(
|
||||||
execCommand,
|
execCommand,
|
||||||
@@ -482,14 +528,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.stderr.on("data", (data: Buffer) => {
|
stream.stderr.on("data", (data: Buffer) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
// Log stderr but don't send to terminal to avoid duplicate error messages
|
||||||
ws.send(
|
dockerConsoleLogger.debug("Docker exec stderr", {
|
||||||
JSON.stringify({
|
operation: "docker_exec_stderr",
|
||||||
type: "output",
|
sessionId,
|
||||||
data: data.toString("utf8"),
|
containerId,
|
||||||
}),
|
data: data.toString("utf8"),
|
||||||
);
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on("close", () => {
|
stream.on("close", () => {
|
||||||
@@ -512,7 +557,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "connected",
|
type: "connected",
|
||||||
data: { shell: detectedShell },
|
data: {
|
||||||
|
shell: shellToUse,
|
||||||
|
requestedShell: shell,
|
||||||
|
shellChanged: shell && shell !== shellToUse,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -520,7 +569,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
operation: "console_start",
|
operation: "console_start",
|
||||||
sessionId,
|
sessionId,
|
||||||
containerId,
|
containerId,
|
||||||
shell: detectedShell,
|
shell: shellToUse,
|
||||||
|
requestedShell: shell,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
"bg-elevated text-foreground flex flex-col gap-6 rounded-lg border-2 border-edge py-6 shadow-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -52,15 +52,15 @@
|
|||||||
--bg-base: #fcfcfc;
|
--bg-base: #fcfcfc;
|
||||||
--bg-elevated: #ffffff;
|
--bg-elevated: #ffffff;
|
||||||
--bg-surface: #f3f4f6;
|
--bg-surface: #f3f4f6;
|
||||||
--bg-surface-hover: #e5e7eb; /* Panel hover - replaces dark-bg-panel-hover */
|
--bg-surface-hover: #e5e7eb; /* Panel hover - replaces dark-bg-panel-hover */
|
||||||
--bg-input: #ffffff;
|
--bg-input: #ffffff;
|
||||||
--bg-deepest: #e5e7eb;
|
--bg-deepest: #e5e7eb;
|
||||||
--bg-header: #eeeeef;
|
--bg-header: #eeeeef;
|
||||||
--bg-button: #f3f4f6;
|
--bg-button: #f3f4f6;
|
||||||
--bg-active: #e5e7eb;
|
--bg-active: #e5e7eb;
|
||||||
--bg-light: #fafafa; /* Light background - replaces dark-bg-light */
|
--bg-light: #fafafa; /* Light background - replaces dark-bg-light */
|
||||||
--bg-subtle: #f5f5f5; /* Very light background - replaces dark-bg-very-light */
|
--bg-subtle: #f5f5f5; /* Very light background - replaces dark-bg-very-light */
|
||||||
--bg-interact: #d1d5db; /* Interactive/active state - replaces dark-active */
|
--bg-interact: #d1d5db; /* Interactive/active state - replaces dark-active */
|
||||||
--border-base: #e5e7eb;
|
--border-base: #e5e7eb;
|
||||||
--border-panel: #d1d5db;
|
--border-panel: #d1d5db;
|
||||||
--border-subtle: #f3f4f6;
|
--border-subtle: #f3f4f6;
|
||||||
@@ -203,19 +203,18 @@
|
|||||||
--sidebar-border: #ffffff1a;
|
--sidebar-border: #ffffff1a;
|
||||||
--sidebar-ring: #71717a;
|
--sidebar-ring: #71717a;
|
||||||
|
|
||||||
/* NEW SEMANTIC VARIABLES - Dark Mode Background Overrides */
|
|
||||||
--bg-base: #18181b;
|
--bg-base: #18181b;
|
||||||
--bg-elevated: #0e0e10;
|
--bg-elevated: #0e0e10;
|
||||||
--bg-surface: #1b1b1e;
|
--bg-surface: #1b1b1e;
|
||||||
--bg-surface-hover: #232327; /* Panel hover */
|
--bg-surface-hover: #232327;
|
||||||
--bg-input: #222225;
|
--bg-input: #222225;
|
||||||
--bg-deepest: #09090b;
|
--bg-deepest: #09090b;
|
||||||
--bg-header: #131316;
|
--bg-header: #131316;
|
||||||
--bg-button: #23232a;
|
--bg-button: #23232a;
|
||||||
--bg-active: #1d1d1f;
|
--bg-active: #1d1d1f;
|
||||||
--bg-light: #141416; /* Light background */
|
--bg-light: #141416;
|
||||||
--bg-subtle: #101014; /* Very light background */
|
--bg-subtle: #101014;
|
||||||
--bg-interact: #2a2a2c; /* Interactive/active state */
|
--bg-interact: #2a2a2c;
|
||||||
--border-base: #303032;
|
--border-base: #303032;
|
||||||
--border-panel: #222224;
|
--border-panel: #222224;
|
||||||
--border-subtle: #5a5a5d;
|
--border-subtle: #5a5a5d;
|
||||||
@@ -227,8 +226,8 @@
|
|||||||
--border-active: #2d2d30;
|
--border-active: #2d2d30;
|
||||||
|
|
||||||
/* NEW SEMANTIC VARIABLES - Dark Mode Text Color Overrides */
|
/* NEW SEMANTIC VARIABLES - Dark Mode Text Color Overrides */
|
||||||
--foreground-secondary: #d1d5db; /* Matches text-gray-300 */
|
--foreground-secondary: #d1d5db; /* Matches text-gray-300 */
|
||||||
--foreground-subtle: #6b7280; /* Matches text-gray-500 */
|
--foreground-subtle: #6b7280; /* Matches text-gray-500 */
|
||||||
|
|
||||||
/* Scrollbar Colors - Dark Mode */
|
/* Scrollbar Colors - Dark Mode */
|
||||||
--scrollbar-thumb: #434345;
|
--scrollbar-thumb: #434345;
|
||||||
|
|||||||
@@ -1591,6 +1591,10 @@
|
|||||||
"permissionsChangedSuccessfully": "Permissions changed successfully",
|
"permissionsChangedSuccessfully": "Permissions changed successfully",
|
||||||
"failedToChangePermissions": "Failed to change permissions"
|
"failedToChangePermissions": "Failed to change permissions"
|
||||||
},
|
},
|
||||||
|
"tunnel": {
|
||||||
|
"noTunnelsConfigured": "No Tunnels Configured",
|
||||||
|
"configureTunnelsInHostSettings": "Configure tunnel connections in the Host Manager to get started"
|
||||||
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"title": "SSH Tunnels",
|
"title": "SSH Tunnels",
|
||||||
"noSshTunnels": "No SSH Tunnels",
|
"noSshTunnels": "No SSH Tunnels",
|
||||||
@@ -1601,6 +1605,7 @@
|
|||||||
"disconnecting": "Disconnecting...",
|
"disconnecting": "Disconnecting...",
|
||||||
"unknownTunnelStatus": "Unknown",
|
"unknownTunnelStatus": "Unknown",
|
||||||
"statusUnknown": "Unknown",
|
"statusUnknown": "Unknown",
|
||||||
|
"unknown": "Unknown",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"retrying": "Retrying",
|
"retrying": "Retrying",
|
||||||
@@ -2333,5 +2338,95 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"hostManager": "Host Manager",
|
"hostManager": "Host Manager",
|
||||||
"pressToToggle": "Press Left Shift twice to open the command palette"
|
"pressToToggle": "Press Left Shift twice to open the command palette"
|
||||||
|
},
|
||||||
|
"docker": {
|
||||||
|
"notEnabled": "Docker is not enabled for this host",
|
||||||
|
"validating": "Validating Docker...",
|
||||||
|
"connectingToHost": "Connecting to host...",
|
||||||
|
"error": "Error",
|
||||||
|
"errorCode": "Error code: {{code}}",
|
||||||
|
"version": "Docker {{version}}",
|
||||||
|
"containerStarted": "Container {{name}} started",
|
||||||
|
"failedToStartContainer": "Failed to start container {{name}}",
|
||||||
|
"containerStopped": "Container {{name}} stopped",
|
||||||
|
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||||
|
"containerRestarted": "Container {{name}} restarted",
|
||||||
|
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||||
|
"containerPaused": "Container {{name}} paused",
|
||||||
|
"containerUnpaused": "Container {{name}} unpaused",
|
||||||
|
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||||
|
"containerRemoved": "Container {{name}} removed",
|
||||||
|
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||||
|
"image": "Image",
|
||||||
|
"idLabel": "ID",
|
||||||
|
"ports": "Ports",
|
||||||
|
"noPorts": "No ports",
|
||||||
|
"created": "Created",
|
||||||
|
"start": "Start",
|
||||||
|
"stop": "Stop",
|
||||||
|
"pause": "Pause",
|
||||||
|
"unpause": "Unpause",
|
||||||
|
"restart": "Restart",
|
||||||
|
"remove": "Remove",
|
||||||
|
"removeContainer": "Remove Container",
|
||||||
|
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||||
|
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||||
|
"removing": "Removing...",
|
||||||
|
"noContainersFound": "No containers found",
|
||||||
|
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||||
|
"searchPlaceholder": "Search containers...",
|
||||||
|
"filterByStatusPlaceholder": "Filter by status",
|
||||||
|
"allContainersCount": "All ({{count}})",
|
||||||
|
"statusCount": "{{status}} ({{count}})",
|
||||||
|
"noContainersMatchFilters": "No containers match your filters",
|
||||||
|
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||||
|
"containerMustBeRunningToViewStats": "Container must be running to view statistics",
|
||||||
|
"failedToFetchStats": "Failed to fetch container statistics",
|
||||||
|
"containerNotRunning": "Container not running",
|
||||||
|
"startContainerToViewStats": "Start the container to view statistics",
|
||||||
|
"loadingStats": "Loading statistics...",
|
||||||
|
"errorLoadingStats": "Error loading statistics",
|
||||||
|
"noStatsAvailable": "No statistics available",
|
||||||
|
"cpuUsage": "CPU Usage",
|
||||||
|
"current": "Current",
|
||||||
|
"memoryUsage": "Memory Usage",
|
||||||
|
"usedLimit": "Used / Limit",
|
||||||
|
"percentage": "Percentage",
|
||||||
|
"networkIo": "Network I/O",
|
||||||
|
"input": "Input",
|
||||||
|
"output": "Output",
|
||||||
|
"blockIo": "Block I/O",
|
||||||
|
"read": "Read",
|
||||||
|
"write": "Write",
|
||||||
|
"pids": "PIDs",
|
||||||
|
"containerInformation": "Container Information",
|
||||||
|
"name": "Name",
|
||||||
|
"id": "ID",
|
||||||
|
"state": "State",
|
||||||
|
"disconnectedFromContainer": "Disconnected from container",
|
||||||
|
"containerMustBeRunning": "Container must be running to access console",
|
||||||
|
"authenticationRequired": "Authentication required",
|
||||||
|
"connectedTo": "Connected to {{containerName}}",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"consoleError": "Console error",
|
||||||
|
"errorMessage": "Error: {{message}}",
|
||||||
|
"failedToConnect": "Failed to connect to container",
|
||||||
|
"console": "Console",
|
||||||
|
"selectShell": "Select shell",
|
||||||
|
"bash": "Bash",
|
||||||
|
"sh": "sh",
|
||||||
|
"ash": "ash",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"connect": "Connect",
|
||||||
|
"disconnect": "Disconnect",
|
||||||
|
"notConnected": "Not connected",
|
||||||
|
"clickToConnect": "Click connect to start a shell session",
|
||||||
|
"connectingTo": "Connecting to {{containerName}}...",
|
||||||
|
"containerNotFound": "Container not found",
|
||||||
|
"backToList": "Back to List",
|
||||||
|
"logs": "Logs",
|
||||||
|
"stats": "Stats",
|
||||||
|
"consoleTab": "Console",
|
||||||
|
"startContainerToAccess": "Start the container to access the console"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,16 +6,16 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog.tsx";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { PasswordInput } from "@/components/ui/password-input";
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UserPlus, AlertCircle } from "lucide-react";
|
import { UserPlus, AlertCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { registerUser } from "@/ui/main-axios";
|
import { registerUser } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface CreateUserDialogProps {
|
interface CreateUserDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -95,7 +95,7 @@ export function CreateUserDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<UserPlus className="w-5 h-5" />
|
<UserPlus className="w-5 h-5" />
|
||||||
144
src/ui/desktop/admin/dialogs/LinkAccountDialog.tsx
Normal file
144
src/ui/desktop/admin/dialogs/LinkAccountDialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog.tsx";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { linkOIDCToPasswordAccount } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface LinkAccountDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
oidcUser: { id: string; username: string } | null;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkAccountDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
oidcUser,
|
||||||
|
onSuccess,
|
||||||
|
}: LinkAccountDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [linkTargetUsername, setLinkTargetUsername] = useState("");
|
||||||
|
const [linkLoading, setLinkLoading] = useState(false);
|
||||||
|
|
||||||
|
// Reset form when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setLinkTargetUsername("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleLinkSubmit = async () => {
|
||||||
|
if (!oidcUser || !linkTargetUsername.trim()) {
|
||||||
|
toast.error("Target username is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinkLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await linkOIDCToPasswordAccount(
|
||||||
|
oidcUser.id,
|
||||||
|
linkTargetUsername.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
result.message ||
|
||||||
|
`OIDC user ${oidcUser.username} linked to ${linkTargetUsername}`,
|
||||||
|
);
|
||||||
|
setLinkTargetUsername("");
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as {
|
||||||
|
response?: { data?: { error?: string; code?: string } };
|
||||||
|
};
|
||||||
|
toast.error(err.response?.data?.error || "Failed to link accounts");
|
||||||
|
} finally {
|
||||||
|
setLinkLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Link2 className="w-5 h-5" />
|
||||||
|
{t("admin.linkOidcToPasswordAccount")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
{t("admin.linkOidcToPasswordAccountDescription", {
|
||||||
|
username: oidcUser?.username,
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>{t("admin.linkOidcWarningTitle")}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("admin.linkOidcWarningDescription")}
|
||||||
|
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||||
|
<li>{t("admin.linkOidcActionDeleteUser")}</li>
|
||||||
|
<li>{t("admin.linkOidcActionAddCapability")}</li>
|
||||||
|
<li>{t("admin.linkOidcActionDualAuth")}</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="link-target-username"
|
||||||
|
className="text-base font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{t("admin.linkTargetUsernameLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="link-target-username"
|
||||||
|
value={linkTargetUsername}
|
||||||
|
onChange={(e) => setLinkTargetUsername(e.target.value)}
|
||||||
|
placeholder={t("admin.linkTargetUsernamePlaceholder")}
|
||||||
|
disabled={linkLoading}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && linkTargetUsername.trim()) {
|
||||||
|
handleLinkSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={linkLoading}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleLinkSubmit}
|
||||||
|
disabled={linkLoading || !linkTargetUsername.trim()}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{linkLoading
|
||||||
|
? t("admin.linkingAccounts")
|
||||||
|
: t("admin.linkAccountsButton")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,13 +6,13 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog.tsx";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge.tsx";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch.tsx";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
UserCog,
|
UserCog,
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
import {
|
import {
|
||||||
getUserRoles,
|
getUserRoles,
|
||||||
getRoles,
|
getRoles,
|
||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
deleteUser,
|
deleteUser,
|
||||||
type UserRole,
|
type UserRole,
|
||||||
type Role,
|
type Role,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -354,7 +354,7 @@ export function UserEditDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-3xl bg-dark-bg border-2 border-dark-border">
|
<DialogContent className="max-w-3xl bg-canvas border-2 border-edge">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<UserCog className="w-5 h-5" />
|
<UserCog className="w-5 h-5" />
|
||||||
@@ -367,7 +367,7 @@ export function UserEditDialog({
|
|||||||
|
|
||||||
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto thin-scrollbar pr-2">
|
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto thin-scrollbar pr-2">
|
||||||
{/* READ-ONLY INFO SECTION */}
|
{/* READ-ONLY INFO SECTION */}
|
||||||
<div className="grid grid-cols-2 gap-4 p-4 bg-dark-bg-panel rounded-lg border border-dark-border">
|
<div className="grid grid-cols-2 gap-4 p-4 bg-surface rounded-lg border border-edge">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-muted-foreground text-xs">
|
<Label className="text-muted-foreground text-xs">
|
||||||
{t("admin.username")}
|
{t("admin.username")}
|
||||||
@@ -408,7 +408,7 @@ export function UserEditDialog({
|
|||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
{t("admin.adminPrivileges")}
|
{t("admin.adminPrivileges")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-center justify-between p-3 border border-dark-border rounded-lg bg-dark-bg-panel">
|
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium">{t("admin.administratorRole")}</p>
|
<p className="font-medium">{t("admin.administratorRole")}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -487,7 +487,7 @@ export function UserEditDialog({
|
|||||||
{userRoles.map((role) => (
|
{userRoles.map((role) => (
|
||||||
<div
|
<div
|
||||||
key={role.roleId}
|
key={role.roleId}
|
||||||
className="flex items-center justify-between p-3 border border-dark-border rounded-lg bg-dark-bg-panel"
|
className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">
|
<p className="font-medium text-sm">
|
||||||
@@ -508,7 +508,7 @@ export function UserEditDialog({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemoveRole(role.roleId)}
|
onClick={() => handleRemoveRole(role.roleId)}
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -566,7 +566,7 @@ export function UserEditDialog({
|
|||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
{t("admin.sessionManagement")}
|
{t("admin.sessionManagement")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-center justify-between p-3 border border-dark-border rounded-lg bg-dark-bg-panel">
|
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-sm">
|
<p className="font-medium text-sm">
|
||||||
{t("admin.revokeAllSessions")}
|
{t("admin.revokeAllSessions")}
|
||||||
319
src/ui/desktop/admin/widgets/DatabaseSecurityTab.tsx
Normal file
319
src/ui/desktop/admin/widgets/DatabaseSecurityTab.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||||
|
import { Download, Upload } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { isElectron } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface DatabaseSecurityTabProps {
|
||||||
|
currentUser: {
|
||||||
|
is_oidc: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatabaseSecurityTab({
|
||||||
|
currentUser,
|
||||||
|
}: DatabaseSecurityTabProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [exportLoading, setExportLoading] = React.useState(false);
|
||||||
|
const [importLoading, setImportLoading] = React.useState(false);
|
||||||
|
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||||
|
const [exportPassword, setExportPassword] = React.useState("");
|
||||||
|
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
|
||||||
|
const [importPassword, setImportPassword] = React.useState("");
|
||||||
|
|
||||||
|
const requiresImportPassword = React.useMemo(
|
||||||
|
() => !currentUser?.is_oidc,
|
||||||
|
[currentUser?.is_oidc],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExportDatabase = async () => {
|
||||||
|
if (!showPasswordInput) {
|
||||||
|
setShowPasswordInput(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exportPassword.trim()) {
|
||||||
|
toast.error(t("admin.passwordRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExportLoading(true);
|
||||||
|
try {
|
||||||
|
const isDev =
|
||||||
|
!isElectron() &&
|
||||||
|
process.env.NODE_ENV === "development" &&
|
||||||
|
(window.location.port === "3000" ||
|
||||||
|
window.location.port === "5173" ||
|
||||||
|
window.location.port === "" ||
|
||||||
|
window.location.hostname === "localhost" ||
|
||||||
|
window.location.hostname === "127.0.0.1");
|
||||||
|
|
||||||
|
const apiUrl = isElectron()
|
||||||
|
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export`
|
||||||
|
: isDev
|
||||||
|
? `http://localhost:30001/database/export`
|
||||||
|
: `${window.location.protocol}//${window.location.host}/database/export`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ password: exportPassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const contentDisposition = response.headers.get("content-disposition");
|
||||||
|
const filename =
|
||||||
|
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
||||||
|
"termix-export.sqlite";
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
toast.success(t("admin.databaseExportedSuccessfully"));
|
||||||
|
setExportPassword("");
|
||||||
|
setShowPasswordInput(false);
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
if (error.code === "PASSWORD_REQUIRED") {
|
||||||
|
toast.error(t("admin.passwordRequired"));
|
||||||
|
} else {
|
||||||
|
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.databaseExportFailed"));
|
||||||
|
} finally {
|
||||||
|
setExportLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportDatabase = async () => {
|
||||||
|
if (!importFile) {
|
||||||
|
toast.error(t("admin.pleaseSelectImportFile"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresImportPassword && !importPassword.trim()) {
|
||||||
|
toast.error(t("admin.passwordRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportLoading(true);
|
||||||
|
try {
|
||||||
|
const isDev =
|
||||||
|
!isElectron() &&
|
||||||
|
process.env.NODE_ENV === "development" &&
|
||||||
|
(window.location.port === "3000" ||
|
||||||
|
window.location.port === "5173" ||
|
||||||
|
window.location.port === "" ||
|
||||||
|
window.location.hostname === "localhost" ||
|
||||||
|
window.location.hostname === "127.0.0.1");
|
||||||
|
|
||||||
|
const apiUrl = isElectron()
|
||||||
|
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/import`
|
||||||
|
: isDev
|
||||||
|
? `http://localhost:30001/database/import`
|
||||||
|
: `${window.location.protocol}//${window.location.host}/database/import`;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", importFile);
|
||||||
|
if (requiresImportPassword) {
|
||||||
|
formData.append("password", importPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
const summary = result.summary;
|
||||||
|
const imported =
|
||||||
|
summary.sshHostsImported +
|
||||||
|
summary.sshCredentialsImported +
|
||||||
|
summary.fileManagerItemsImported +
|
||||||
|
summary.dismissedAlertsImported +
|
||||||
|
(summary.settingsImported || 0);
|
||||||
|
const skipped = summary.skippedItems;
|
||||||
|
|
||||||
|
const details = [];
|
||||||
|
if (summary.sshHostsImported > 0)
|
||||||
|
details.push(`${summary.sshHostsImported} SSH hosts`);
|
||||||
|
if (summary.sshCredentialsImported > 0)
|
||||||
|
details.push(`${summary.sshCredentialsImported} credentials`);
|
||||||
|
if (summary.fileManagerItemsImported > 0)
|
||||||
|
details.push(
|
||||||
|
`${summary.fileManagerItemsImported} file manager items`,
|
||||||
|
);
|
||||||
|
if (summary.dismissedAlertsImported > 0)
|
||||||
|
details.push(`${summary.dismissedAlertsImported} alerts`);
|
||||||
|
if (summary.settingsImported > 0)
|
||||||
|
details.push(`${summary.settingsImported} settings`);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
|
||||||
|
);
|
||||||
|
setImportFile(null);
|
||||||
|
setImportPassword("");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
`${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
if (error.code === "PASSWORD_REQUIRED") {
|
||||||
|
toast.error(t("admin.passwordRequired"));
|
||||||
|
} else {
|
||||||
|
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.databaseImportFailed"));
|
||||||
|
} finally {
|
||||||
|
setImportLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">{t("admin.databaseSecurity")}</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="p-4 border rounded-lg bg-surface">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Download className="h-4 w-4 text-blue-500" />
|
||||||
|
<h4 className="font-semibold">{t("admin.export")}</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("admin.exportDescription")}
|
||||||
|
</p>
|
||||||
|
{showPasswordInput && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="export-password">Password</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="export-password"
|
||||||
|
value={exportPassword}
|
||||||
|
onChange={(e) => setExportPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleExportDatabase();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleExportDatabase}
|
||||||
|
disabled={exportLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{exportLoading
|
||||||
|
? t("admin.exporting")
|
||||||
|
: showPasswordInput
|
||||||
|
? t("admin.confirmExport")
|
||||||
|
: t("admin.export")}
|
||||||
|
</Button>
|
||||||
|
{showPasswordInput && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasswordInput(false);
|
||||||
|
setExportPassword("");
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border rounded-lg bg-surface">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4 text-green-500" />
|
||||||
|
<h4 className="font-semibold">{t("admin.import")}</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("admin.importDescription")}
|
||||||
|
</p>
|
||||||
|
<div className="relative inline-block w-full mb-2">
|
||||||
|
<input
|
||||||
|
id="import-file-upload"
|
||||||
|
type="file"
|
||||||
|
accept=".sqlite,.db"
|
||||||
|
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="truncate"
|
||||||
|
title={importFile?.name || t("admin.pleaseSelectImportFile")}
|
||||||
|
>
|
||||||
|
{importFile
|
||||||
|
? importFile.name
|
||||||
|
: t("admin.pleaseSelectImportFile")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{importFile && requiresImportPassword && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="import-password">Password</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="import-password"
|
||||||
|
value={importPassword}
|
||||||
|
onChange={(e) => setImportPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleImportDatabase();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleImportDatabase}
|
||||||
|
disabled={
|
||||||
|
importLoading ||
|
||||||
|
!importFile ||
|
||||||
|
(requiresImportPassword && !importPassword.trim())
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/ui/desktop/admin/widgets/GeneralSettingsTab.tsx
Normal file
125
src/ui/desktop/admin/widgets/GeneralSettingsTab.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
|
import {
|
||||||
|
updateRegistrationAllowed,
|
||||||
|
updatePasswordLoginAllowed,
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface GeneralSettingsTabProps {
|
||||||
|
allowRegistration: boolean;
|
||||||
|
setAllowRegistration: (value: boolean) => void;
|
||||||
|
allowPasswordLogin: boolean;
|
||||||
|
setAllowPasswordLogin: (value: boolean) => void;
|
||||||
|
oidcConfig: {
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
issuer_url: string;
|
||||||
|
authorization_url: string;
|
||||||
|
token_url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneralSettingsTab({
|
||||||
|
allowRegistration,
|
||||||
|
setAllowRegistration,
|
||||||
|
allowPasswordLogin,
|
||||||
|
setAllowPasswordLogin,
|
||||||
|
oidcConfig,
|
||||||
|
}: GeneralSettingsTabProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { confirmWithToast } = useConfirmation();
|
||||||
|
|
||||||
|
const [regLoading, setRegLoading] = React.useState(false);
|
||||||
|
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const handleToggleRegistration = async (checked: boolean) => {
|
||||||
|
setRegLoading(true);
|
||||||
|
try {
|
||||||
|
await updateRegistrationAllowed(checked);
|
||||||
|
setAllowRegistration(checked);
|
||||||
|
} finally {
|
||||||
|
setRegLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePasswordLogin = async (checked: boolean) => {
|
||||||
|
if (!checked) {
|
||||||
|
const hasOIDCConfigured =
|
||||||
|
oidcConfig.client_id &&
|
||||||
|
oidcConfig.client_secret &&
|
||||||
|
oidcConfig.issuer_url &&
|
||||||
|
oidcConfig.authorization_url &&
|
||||||
|
oidcConfig.token_url;
|
||||||
|
|
||||||
|
if (!hasOIDCConfigured) {
|
||||||
|
toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmWithToast(
|
||||||
|
t("admin.confirmDisablePasswordLogin"),
|
||||||
|
async () => {
|
||||||
|
setPasswordLoginLoading(true);
|
||||||
|
try {
|
||||||
|
await updatePasswordLoginAllowed(checked);
|
||||||
|
setAllowPasswordLogin(checked);
|
||||||
|
|
||||||
|
if (allowRegistration) {
|
||||||
|
await updateRegistrationAllowed(false);
|
||||||
|
setAllowRegistration(false);
|
||||||
|
toast.success(t("admin.passwordLoginAndRegistrationDisabled"));
|
||||||
|
} else {
|
||||||
|
toast.success(t("admin.passwordLoginDisabled"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.failedToUpdatePasswordLoginStatus"));
|
||||||
|
} finally {
|
||||||
|
setPasswordLoginLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordLoginLoading(true);
|
||||||
|
try {
|
||||||
|
await updatePasswordLoginAllowed(checked);
|
||||||
|
setAllowPasswordLogin(checked);
|
||||||
|
} finally {
|
||||||
|
setPasswordLoginLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">{t("admin.userRegistration")}</h3>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allowRegistration}
|
||||||
|
onCheckedChange={handleToggleRegistration}
|
||||||
|
disabled={regLoading || !allowPasswordLogin}
|
||||||
|
/>
|
||||||
|
{t("admin.allowNewAccountRegistration")}
|
||||||
|
{!allowPasswordLogin && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({t("admin.requiresPasswordLogin")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allowPasswordLogin}
|
||||||
|
onCheckedChange={handleTogglePasswordLogin}
|
||||||
|
disabled={passwordLoginLoading}
|
||||||
|
/>
|
||||||
|
{t("admin.allowPasswordLogin")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
src/ui/desktop/admin/widgets/OIDCSettingsTab.tsx
Normal file
319
src/ui/desktop/admin/widgets/OIDCSettingsTab.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||||
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||||
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
|
import { updateOIDCConfig, disableOIDCConfig } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface OIDCSettingsTabProps {
|
||||||
|
allowPasswordLogin: boolean;
|
||||||
|
oidcConfig: {
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
issuer_url: string;
|
||||||
|
authorization_url: string;
|
||||||
|
token_url: string;
|
||||||
|
identifier_path: string;
|
||||||
|
name_path: string;
|
||||||
|
scopes: string;
|
||||||
|
userinfo_url: string;
|
||||||
|
};
|
||||||
|
setOidcConfig: React.Dispatch<
|
||||||
|
React.SetStateAction<{
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
issuer_url: string;
|
||||||
|
authorization_url: string;
|
||||||
|
token_url: string;
|
||||||
|
identifier_path: string;
|
||||||
|
name_path: string;
|
||||||
|
scopes: string;
|
||||||
|
userinfo_url: string;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OIDCSettingsTab({
|
||||||
|
allowPasswordLogin,
|
||||||
|
oidcConfig,
|
||||||
|
setOidcConfig,
|
||||||
|
}: OIDCSettingsTabProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { confirmWithToast } = useConfirmation();
|
||||||
|
|
||||||
|
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||||
|
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setOidcLoading(true);
|
||||||
|
setOidcError(null);
|
||||||
|
|
||||||
|
const required = [
|
||||||
|
"client_id",
|
||||||
|
"client_secret",
|
||||||
|
"issuer_url",
|
||||||
|
"authorization_url",
|
||||||
|
"token_url",
|
||||||
|
];
|
||||||
|
const missing = required.filter(
|
||||||
|
(f) => !oidcConfig[f as keyof typeof oidcConfig],
|
||||||
|
);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setOidcError(
|
||||||
|
t("admin.missingRequiredFields", { fields: missing.join(", ") }),
|
||||||
|
);
|
||||||
|
setOidcLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateOIDCConfig(oidcConfig);
|
||||||
|
toast.success(t("admin.oidcConfigurationUpdated"));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setOidcError(
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data
|
||||||
|
?.error || t("admin.failedToUpdateOidcConfig"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||||
|
setOidcConfig((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetConfig = async () => {
|
||||||
|
if (!allowPasswordLogin) {
|
||||||
|
confirmWithToast(
|
||||||
|
t("admin.confirmDisableOIDCWarning"),
|
||||||
|
async () => {
|
||||||
|
const emptyConfig = {
|
||||||
|
client_id: "",
|
||||||
|
client_secret: "",
|
||||||
|
issuer_url: "",
|
||||||
|
authorization_url: "",
|
||||||
|
token_url: "",
|
||||||
|
identifier_path: "",
|
||||||
|
name_path: "",
|
||||||
|
scopes: "",
|
||||||
|
userinfo_url: "",
|
||||||
|
};
|
||||||
|
setOidcConfig(emptyConfig);
|
||||||
|
setOidcError(null);
|
||||||
|
setOidcLoading(true);
|
||||||
|
try {
|
||||||
|
await disableOIDCConfig();
|
||||||
|
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setOidcError(
|
||||||
|
(
|
||||||
|
err as {
|
||||||
|
response?: { data?: { error?: string } };
|
||||||
|
}
|
||||||
|
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyConfig = {
|
||||||
|
client_id: "",
|
||||||
|
client_secret: "",
|
||||||
|
issuer_url: "",
|
||||||
|
authorization_url: "",
|
||||||
|
token_url: "",
|
||||||
|
identifier_path: "",
|
||||||
|
name_path: "",
|
||||||
|
scopes: "",
|
||||||
|
userinfo_url: "",
|
||||||
|
};
|
||||||
|
setOidcConfig(emptyConfig);
|
||||||
|
setOidcError(null);
|
||||||
|
setOidcLoading(true);
|
||||||
|
try {
|
||||||
|
await disableOIDCConfig();
|
||||||
|
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setOidcError(
|
||||||
|
(
|
||||||
|
err as {
|
||||||
|
response?: { data?: { error?: string } };
|
||||||
|
}
|
||||||
|
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t("admin.externalAuthentication")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("admin.configureExternalProvider")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 text-xs"
|
||||||
|
onClick={() => window.open("https://docs.termix.site/oidc", "_blank")}
|
||||||
|
>
|
||||||
|
{t("common.documentation")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!allowPasswordLogin && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
|
||||||
|
<AlertDescription>{t("admin.oidcRequiredWarning")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oidcError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||||
|
<AlertDescription>{oidcError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="client_id">{t("admin.clientId")}</Label>
|
||||||
|
<Input
|
||||||
|
id="client_id"
|
||||||
|
value={oidcConfig.client_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleOIDCConfigChange("client_id", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("placeholders.clientId")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="client_secret">{t("admin.clientSecret")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="client_secret"
|
||||||
|
value={oidcConfig.client_secret}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleOIDCConfigChange("client_secret", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("placeholders.clientSecret")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="authorization_url">
|
||||||
|
{t("admin.authorizationUrl")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="authorization_url"
|
||||||
|
value={oidcConfig.authorization_url}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleOIDCConfigChange("authorization_url", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("placeholders.authUrl")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="issuer_url">{t("admin.issuerUrl")}</Label>
|
||||||
|
<Input
|
||||||
|
id="issuer_url"
|
||||||
|
value={oidcConfig.issuer_url}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleOIDCConfigChange("issuer_url", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("placeholders.redirectUrl")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="token_url">{t("admin.tokenUrl")}</Label>
|
||||||
|
<Input
|
||||||
|
id="token_url"
|
||||||
|
value={oidcConfig.token_url}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleOIDCConfigChange("token_url", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("placeholders.tokenUrl")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="identifier_path">
|
||||||
|
{t("admin.userIdentifierPath")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="identifier_path"
|
||||||
|
value={oidcConfig.identifier_path}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleOIDCConfigChange("identifier_path", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("placeholders.userIdField")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name_path">{t("admin.displayNamePath")}</Label>
|
||||||
|
<Input
|
||||||
|
id="name_path"
|
||||||
|
value={oidcConfig.name_path}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleOIDCConfigChange("name_path", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("placeholders.usernameField")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="scopes">{t("admin.scopes")}</Label>
|
||||||
|
<Input
|
||||||
|
id="scopes"
|
||||||
|
value={oidcConfig.scopes}
|
||||||
|
onChange={(e) => handleOIDCConfigChange("scopes", e.target.value)}
|
||||||
|
placeholder={t("placeholders.scopes")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="userinfo_url">{t("admin.overrideUserInfoUrl")}</Label>
|
||||||
|
<Input
|
||||||
|
id="userinfo_url"
|
||||||
|
value={oidcConfig.userinfo_url}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleOIDCConfigChange("userinfo_url", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="https://your-provider.com/application/o/userinfo/"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button type="submit" className="flex-1" disabled={oidcLoading}>
|
||||||
|
{oidcLoading ? t("admin.saving") : t("admin.saveConfiguration")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleResetConfig}
|
||||||
|
disabled={oidcLoading}
|
||||||
|
>
|
||||||
|
{t("admin.reset")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
type Role,
|
type Role,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
export function RoleManagement(): React.ReactElement {
|
export function RolesTab(): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { confirmWithToast } = useConfirmation();
|
const { confirmWithToast } = useConfirmation();
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Create/Edit Role Dialog */}
|
{/* Create/Edit Role Dialog */}
|
||||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}
|
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}
|
||||||
213
src/ui/desktop/admin/widgets/SessionManagementTab.tsx
Normal file
213
src/ui/desktop/admin/widgets/SessionManagementTab.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table.tsx";
|
||||||
|
import { Monitor, Smartphone, Globe, Trash2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
|
import {
|
||||||
|
getCookie,
|
||||||
|
revokeSession,
|
||||||
|
revokeAllUserSessions,
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
username?: string;
|
||||||
|
deviceType: string;
|
||||||
|
deviceInfo: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
lastActiveAt: string;
|
||||||
|
jwtToken: string;
|
||||||
|
isRevoked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionManagementTabProps {
|
||||||
|
sessions: Session[];
|
||||||
|
sessionsLoading: boolean;
|
||||||
|
fetchSessions: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionManagementTab({
|
||||||
|
sessions,
|
||||||
|
sessionsLoading,
|
||||||
|
fetchSessions,
|
||||||
|
}: SessionManagementTabProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { confirmWithToast } = useConfirmation();
|
||||||
|
|
||||||
|
const handleRevokeSession = async (sessionId: string) => {
|
||||||
|
const currentJWT = getCookie("jwt");
|
||||||
|
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
|
||||||
|
const isCurrentSession = currentSession?.id === sessionId;
|
||||||
|
|
||||||
|
confirmWithToast(
|
||||||
|
t("admin.confirmRevokeSession"),
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await revokeSession(sessionId);
|
||||||
|
toast.success(t("admin.sessionRevokedSuccessfully"));
|
||||||
|
|
||||||
|
if (isCurrentSession) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
fetchSessions();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.failedToRevokeSession"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeAllUserSessions = async (userId: string) => {
|
||||||
|
confirmWithToast(
|
||||||
|
t("admin.confirmRevokeAllSessions"),
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const data = await revokeAllUserSessions(userId);
|
||||||
|
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
|
||||||
|
fetchSessions();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.failedToRevokeSessions"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) =>
|
||||||
|
date.toLocaleDateString() +
|
||||||
|
" " +
|
||||||
|
date.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t("admin.sessionManagement")}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
onClick={fetchSessions}
|
||||||
|
disabled={sessionsLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{sessionsLoading ? t("admin.loading") : t("admin.refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{sessionsLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{t("admin.loadingSessions")}
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{t("admin.noActiveSessions")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("admin.device")}</TableHead>
|
||||||
|
<TableHead>{t("admin.user")}</TableHead>
|
||||||
|
<TableHead>{t("admin.created")}</TableHead>
|
||||||
|
<TableHead>{t("admin.lastActive")}</TableHead>
|
||||||
|
<TableHead>{t("admin.expires")}</TableHead>
|
||||||
|
<TableHead>{t("admin.actions")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const DeviceIcon =
|
||||||
|
session.deviceType === "desktop"
|
||||||
|
? Monitor
|
||||||
|
: session.deviceType === "mobile"
|
||||||
|
? Smartphone
|
||||||
|
: Globe;
|
||||||
|
|
||||||
|
const createdDate = new Date(session.createdAt);
|
||||||
|
const lastActiveDate = new Date(session.lastActiveAt);
|
||||||
|
const expiresDate = new Date(session.expiresAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={session.id}
|
||||||
|
className={session.isRevoked ? "opacity-50" : undefined}
|
||||||
|
>
|
||||||
|
<TableCell className="px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DeviceIcon className="h-4 w-4" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{session.deviceInfo}
|
||||||
|
</span>
|
||||||
|
{session.isRevoked && (
|
||||||
|
<span className="text-xs text-red-600">
|
||||||
|
{t("admin.revoked")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4">
|
||||||
|
{session.username || session.userId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||||
|
{formatDate(createdDate)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||||
|
{formatDate(lastActiveDate)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||||
|
{formatDate(expiresDate)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRevokeSession(session.id)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
disabled={session.isRevoked}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{session.username && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleRevokeAllUserSessions(session.userId)
|
||||||
|
}
|
||||||
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||||
|
title={t("admin.revokeAllUserSessionsTitle")}
|
||||||
|
>
|
||||||
|
{t("admin.revokeAll")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/ui/desktop/admin/widgets/UserManagementTab.tsx
Normal file
177
src/ui/desktop/admin/widgets/UserManagementTab.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table.tsx";
|
||||||
|
import { UserPlus, Edit, Trash2, Link2, Unlink } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
|
import { deleteUser } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_oidc: boolean;
|
||||||
|
password_hash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserManagementTabProps {
|
||||||
|
users: User[];
|
||||||
|
usersLoading: boolean;
|
||||||
|
allowPasswordLogin: boolean;
|
||||||
|
fetchUsers: () => void;
|
||||||
|
onCreateUser: () => void;
|
||||||
|
onEditUser: (user: User) => void;
|
||||||
|
onLinkOIDCUser: (user: { id: string; username: string }) => void;
|
||||||
|
onUnlinkOIDC: (userId: string, username: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagementTab({
|
||||||
|
users,
|
||||||
|
usersLoading,
|
||||||
|
allowPasswordLogin,
|
||||||
|
fetchUsers,
|
||||||
|
onCreateUser,
|
||||||
|
onEditUser,
|
||||||
|
onLinkOIDCUser,
|
||||||
|
onUnlinkOIDC,
|
||||||
|
}: UserManagementTabProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { confirmWithToast } = useConfirmation();
|
||||||
|
|
||||||
|
const getAuthTypeDisplay = (user: User): string => {
|
||||||
|
if (user.is_oidc && user.password_hash) {
|
||||||
|
return t("admin.dualAuth");
|
||||||
|
} else if (user.is_oidc) {
|
||||||
|
return t("admin.externalOIDC");
|
||||||
|
} else {
|
||||||
|
return t("admin.localPassword");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUserQuick = async (username: string) => {
|
||||||
|
confirmWithToast(
|
||||||
|
t("admin.deleteUser", { username }),
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await deleteUser(username);
|
||||||
|
toast.success(t("admin.userDeletedSuccessfully", { username }));
|
||||||
|
fetchUsers();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.failedToDeleteUser"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">{t("admin.userManagement")}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{allowPasswordLogin && (
|
||||||
|
<Button onClick={onCreateUser} size="sm">
|
||||||
|
<UserPlus className="h-4 w-4 mr-2" />
|
||||||
|
{t("admin.createUser")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={fetchUsers}
|
||||||
|
disabled={usersLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{usersLoading ? t("admin.loading") : t("admin.refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{usersLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{t("admin.loadingUsers")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("admin.username")}</TableHead>
|
||||||
|
<TableHead>{t("admin.authType")}</TableHead>
|
||||||
|
<TableHead>{t("admin.actions")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{user.username}
|
||||||
|
{user.is_admin && (
|
||||||
|
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||||
|
{t("admin.adminBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getAuthTypeDisplay(user)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEditUser(user)}
|
||||||
|
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||||
|
title={t("admin.manageUser")}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{user.is_oidc && !user.password_hash && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
onLinkOIDCUser({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
||||||
|
title="Link to password account"
|
||||||
|
>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{user.is_oidc && user.password_hash && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onUnlinkOIDC(user.id, user.username)}
|
||||||
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||||
|
title="Unlink OIDC (keep password only)"
|
||||||
|
>
|
||||||
|
<Unlink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteUserQuick(user.username)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
disabled={user.is_admin}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -239,7 +239,7 @@ export function CommandPalette({
|
|||||||
>
|
>
|
||||||
<Command
|
<Command
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-3/4 max-w-2xl max-h-[60vh] rounded-lg border-2 border-edge shadow-md flex flex-col",
|
"w-3/4 max-w-2xl max-h-[60vh] rounded-lg border-2 border-edge shadow-md flex flex-col bg-elevated",
|
||||||
"transition-all duration-200 ease-out",
|
"transition-all duration-200 ease-out",
|
||||||
!isOpen && "scale-95 opacity-0",
|
!isOpen && "scale-95 opacity-0",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -484,10 +484,16 @@ export function CredentialEditor({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="bg-button border border-edge-medium">
|
<TabsList className="bg-button border border-edge-medium">
|
||||||
<TabsTrigger value="general" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
<TabsTrigger
|
||||||
|
value="general"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("credentials.general")}
|
{t("credentials.general")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="authentication" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
<TabsTrigger
|
||||||
|
value="authentication"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("credentials.authentication")}
|
{t("credentials.authentication")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -693,10 +699,16 @@ export function CredentialEditor({
|
|||||||
className="flex-1 flex flex-col h-full min-h-0"
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
>
|
>
|
||||||
<TabsList className="bg-button border border-edge-medium">
|
<TabsList className="bg-button border border-edge-medium">
|
||||||
<TabsTrigger value="password" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
<TabsTrigger
|
||||||
|
value="password"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("credentials.password")}
|
{t("credentials.password")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="key" className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium">
|
<TabsTrigger
|
||||||
|
value="key"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
{t("credentials.key")}
|
{t("credentials.key")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -719,7 +731,7 @@ export function CredentialEditor({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="key">
|
<TabsContent value="key">
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="mb-3 p-3 bg-muted/20 border border-muted rounded-md">
|
<div className="mb-3 p-3 border border-muted rounded-md">
|
||||||
<FormLabel className="mb-2 font-bold block">
|
<FormLabel className="mb-2 font-bold block">
|
||||||
{t("credentials.generateKeyPair")}
|
{t("credentials.generateKeyPair")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
@@ -954,7 +966,8 @@ export function CredentialEditor({
|
|||||||
".cm-scroller": {
|
".cm-scroller": {
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
scrollbarWidth: "thin",
|
scrollbarWidth: "thin",
|
||||||
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
|
scrollbarColor:
|
||||||
|
"var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
@@ -1116,7 +1129,8 @@ export function CredentialEditor({
|
|||||||
".cm-scroller": {
|
".cm-scroller": {
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
scrollbarWidth: "thin",
|
scrollbarWidth: "thin",
|
||||||
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
|
scrollbarColor:
|
||||||
|
"var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ export function Dashboard({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="font-semibold shrink-0"
|
className="font-semibold shrink-0 !bg-canvas"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(
|
window.open(
|
||||||
@@ -419,7 +419,7 @@ export function Dashboard({
|
|||||||
{t("dashboard.github")}
|
{t("dashboard.github")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="font-semibold shrink-0"
|
className="font-semibold shrink-0 !bg-canvas"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(
|
window.open(
|
||||||
@@ -431,7 +431,7 @@ export function Dashboard({
|
|||||||
{t("dashboard.support")}
|
{t("dashboard.support")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="font-semibold shrink-0"
|
className="font-semibold shrink-0 !bg-canvas"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(
|
window.open(
|
||||||
@@ -443,7 +443,7 @@ export function Dashboard({
|
|||||||
{t("dashboard.discord")}
|
{t("dashboard.discord")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="font-semibold shrink-0"
|
className="font-semibold shrink-0 !bg-canvas"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||||
@@ -467,10 +467,7 @@ export function Dashboard({
|
|||||||
<div className="bg-canvas w-full h-auto border-2 border-edge rounded-md px-3 py-3">
|
<div className="bg-canvas w-full h-auto border-2 border-edge rounded-md px-3 py-3">
|
||||||
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
||||||
<div className="flex flex-row items-center min-w-0">
|
<div className="flex flex-row items-center min-w-0">
|
||||||
<History
|
<History size={20} className="shrink-0" />
|
||||||
size={20}
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
<p className="ml-2 leading-none truncate">
|
<p className="ml-2 leading-none truncate">
|
||||||
{t("dashboard.version")}
|
{t("dashboard.version")}
|
||||||
</p>
|
</p>
|
||||||
@@ -495,10 +492,7 @@ export function Dashboard({
|
|||||||
|
|
||||||
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
|
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
|
||||||
<div className="flex flex-row items-center min-w-0">
|
<div className="flex flex-row items-center min-w-0">
|
||||||
<Clock
|
<Clock size={20} className="shrink-0" />
|
||||||
size={20}
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
<p className="ml-2 leading-none truncate">
|
<p className="ml-2 leading-none truncate">
|
||||||
{t("dashboard.uptime")}
|
{t("dashboard.uptime")}
|
||||||
</p>
|
</p>
|
||||||
@@ -513,10 +507,7 @@ export function Dashboard({
|
|||||||
|
|
||||||
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
|
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
|
||||||
<div className="flex flex-row items-center min-w-0">
|
<div className="flex flex-row items-center min-w-0">
|
||||||
<Database
|
<Database size={20} className="shrink-0" />
|
||||||
size={20}
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
<p className="ml-2 leading-none truncate">
|
<p className="ml-2 leading-none truncate">
|
||||||
{t("dashboard.database")}
|
{t("dashboard.database")}
|
||||||
</p>
|
</p>
|
||||||
@@ -536,10 +527,7 @@ export function Dashboard({
|
|||||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||||
<div className="flex flex-row items-center min-w-0">
|
<div className="flex flex-row items-center min-w-0">
|
||||||
<Server
|
<Server size={16} className="mr-3 shrink-0" />
|
||||||
size={16}
|
|
||||||
className="mr-3 shrink-0"
|
|
||||||
/>
|
|
||||||
<p className="m-0 leading-none truncate">
|
<p className="m-0 leading-none truncate">
|
||||||
{t("dashboard.totalServers")}
|
{t("dashboard.totalServers")}
|
||||||
</p>
|
</p>
|
||||||
@@ -550,10 +538,7 @@ export function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||||
<div className="flex flex-row items-center min-w-0">
|
<div className="flex flex-row items-center min-w-0">
|
||||||
<Network
|
<Network size={16} className="mr-3 shrink-0" />
|
||||||
size={16}
|
|
||||||
className="mr-3 shrink-0"
|
|
||||||
/>
|
|
||||||
<p className="m-0 leading-none truncate">
|
<p className="m-0 leading-none truncate">
|
||||||
{t("dashboard.totalTunnels")}
|
{t("dashboard.totalTunnels")}
|
||||||
</p>
|
</p>
|
||||||
@@ -566,10 +551,7 @@ export function Dashboard({
|
|||||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||||
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
|
||||||
<div className="flex flex-row items-center min-w-0">
|
<div className="flex flex-row items-center min-w-0">
|
||||||
<Key
|
<Key size={16} className="mr-3 shrink-0" />
|
||||||
size={16}
|
|
||||||
className="mr-3 shrink-0"
|
|
||||||
/>
|
|
||||||
<p className="m-0 leading-none truncate">
|
<p className="m-0 leading-none truncate">
|
||||||
{t("dashboard.totalCredentials")}
|
{t("dashboard.totalCredentials")}
|
||||||
</p>
|
</p>
|
||||||
@@ -591,7 +573,7 @@ export function Dashboard({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-2 !border-edge h-7"
|
className="border-2 !border-edge h-7 !bg-canvas"
|
||||||
onClick={handleResetActivity}
|
onClick={handleResetActivity}
|
||||||
>
|
>
|
||||||
{t("dashboard.reset")}
|
{t("dashboard.reset")}
|
||||||
@@ -626,7 +608,7 @@ export function Dashboard({
|
|||||||
<Button
|
<Button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-edge bg-canvas min-w-0"
|
className="border-2 !border-edge !bg-canvas min-w-0"
|
||||||
onClick={() => handleActivityClick(item)}
|
onClick={() => handleActivityClick(item)}
|
||||||
>
|
>
|
||||||
{item.type === "terminal" ? (
|
{item.type === "terminal" ? (
|
||||||
@@ -654,7 +636,7 @@ export function Dashboard({
|
|||||||
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
|
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0 !bg-canvas"
|
||||||
onClick={handleAddHost}
|
onClick={handleAddHost}
|
||||||
>
|
>
|
||||||
<Server
|
<Server
|
||||||
@@ -667,7 +649,7 @@ export function Dashboard({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0 !bg-canvas"
|
||||||
onClick={handleAddCredential}
|
onClick={handleAddCredential}
|
||||||
>
|
>
|
||||||
<Key
|
<Key
|
||||||
@@ -681,7 +663,7 @@ export function Dashboard({
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0 !bg-canvas"
|
||||||
onClick={handleOpenAdminSettings}
|
onClick={handleOpenAdminSettings}
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
@@ -695,7 +677,7 @@ export function Dashboard({
|
|||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 min-w-0 !bg-canvas"
|
||||||
onClick={handleOpenUserProfile}
|
onClick={handleOpenUserProfile}
|
||||||
>
|
>
|
||||||
<User
|
<User
|
||||||
@@ -732,7 +714,7 @@ export function Dashboard({
|
|||||||
<Button
|
<Button
|
||||||
key={server.id}
|
key={server.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-edge bg-canvas h-auto p-3 min-w-0"
|
className="border-2 !border-edge bg-canvas h-auto p-3 min-w-0 !bg-canvas"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleServerStatClick(server.id, server.name)
|
handleServerStatClick(server.id, server.name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,8 +230,8 @@ export function DockerManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const containerClass = embedded
|
const containerClass = embedded
|
||||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||||
|
|
||||||
// Check if Docker is enabled
|
// Check if Docker is enabled
|
||||||
if (!currentHostConfig?.enableDocker) {
|
if (!currentHostConfig?.enableDocker) {
|
||||||
@@ -252,9 +252,7 @@ export function DockerManager({
|
|||||||
<div className="flex-1 overflow-hidden min-h-0 p-4">
|
<div className="flex-1 overflow-hidden min-h-0 p-4">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>{t("docker.notEnabled")}</AlertDescription>
|
||||||
{t("docker.notEnabled")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,7 +279,7 @@ export function DockerManager({
|
|||||||
<div className="flex-1 overflow-hidden min-h-0 p-4 flex items-center justify-center">
|
<div className="flex-1 overflow-hidden min-h-0 p-4 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<SimpleLoader size="lg" />
|
<SimpleLoader size="lg" />
|
||||||
<p className="text-gray-400 mt-4">
|
<p className="text-muted-foreground mt-4">
|
||||||
{isValidating
|
{isValidating
|
||||||
? t("docker.validating")
|
? t("docker.validating")
|
||||||
: t("docker.connectingToHost")}
|
: t("docker.connectingToHost")}
|
||||||
@@ -338,7 +336,7 @@ export function DockerManager({
|
|||||||
{currentHostConfig?.folder} / {title}
|
{currentHostConfig?.folder} / {title}
|
||||||
</h1>
|
</h1>
|
||||||
{dockerValidation?.version && (
|
{dockerValidation?.version && (
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("docker.version", { version: dockerValidation.version })}
|
{t("docker.version", { version: dockerValidation.version })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -363,7 +361,7 @@ export function DockerManager({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-gray-400">No session available</p>
|
<p className="text-muted-foreground">No session available</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -377,7 +375,7 @@ export function DockerManager({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-gray-400">
|
<p className="text-muted-foreground">
|
||||||
Select a container to view details
|
Select a container to view details
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,9 +93,18 @@ export function ConsoleTerminal({
|
|||||||
terminal.options.cursorBlink = true;
|
terminal.options.cursorBlink = true;
|
||||||
terminal.options.fontSize = 14;
|
terminal.options.fontSize = 14;
|
||||||
terminal.options.fontFamily = "monospace";
|
terminal.options.fontFamily = "monospace";
|
||||||
|
|
||||||
|
// Get theme colors from CSS variables
|
||||||
|
const backgroundColor = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--bg-elevated")
|
||||||
|
.trim();
|
||||||
|
const foregroundColor = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--foreground")
|
||||||
|
.trim();
|
||||||
|
|
||||||
terminal.options.theme = {
|
terminal.options.theme = {
|
||||||
background: "#18181b",
|
background: backgroundColor || "#ffffff",
|
||||||
foreground: "#c9d1d9",
|
foreground: foregroundColor || "#09090b",
|
||||||
};
|
};
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -152,12 +161,11 @@ export function ConsoleTerminal({
|
|||||||
if (terminal) {
|
if (terminal) {
|
||||||
try {
|
try {
|
||||||
terminal.clear();
|
terminal.clear();
|
||||||
terminal.write(`${t("docker.disconnectedFromContainer")}\r\n`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Terminal might be disposed
|
// Terminal might be disposed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [terminal]);
|
}, [terminal, t]);
|
||||||
|
|
||||||
const connect = React.useCallback(() => {
|
const connect = React.useCallback(() => {
|
||||||
if (!terminal || containerState !== "running") {
|
if (!terminal || containerState !== "running") {
|
||||||
@@ -216,7 +224,15 @@ export function ConsoleTerminal({
|
|||||||
case "connected":
|
case "connected":
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
toast.success(t("docker.connectedTo", { containerName }));
|
|
||||||
|
// Check if shell was changed due to unavailability
|
||||||
|
if (msg.data?.shellChanged) {
|
||||||
|
toast.warning(
|
||||||
|
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(t("docker.connectedTo", { containerName }));
|
||||||
|
}
|
||||||
|
|
||||||
// Fit terminal and send resize to ensure correct dimensions
|
// Fit terminal and send resize to ensure correct dimensions
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -251,7 +267,9 @@ export function ConsoleTerminal({
|
|||||||
case "error":
|
case "error":
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
toast.error(msg.message || t("docker.consoleError"));
|
toast.error(msg.message || t("docker.consoleError"));
|
||||||
terminal.write(`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`);
|
terminal.write(
|
||||||
|
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -341,9 +359,11 @@ export function ConsoleTerminal({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
|
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||||
<p className="text-gray-400 text-lg">{t("docker.containerNotRunning")}</p>
|
<p className="text-muted-foreground text-lg">
|
||||||
<p className="text-gray-500 text-sm">
|
{t("docker.containerNotRunning")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
{t("docker.startContainerToAccess")}
|
{t("docker.startContainerToAccess")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,7 +379,9 @@ export function ConsoleTerminal({
|
|||||||
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
|
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<TerminalIcon className="h-5 w-5" />
|
<TerminalIcon className="h-5 w-5" />
|
||||||
<span className="text-base font-medium">{t("docker.console")}</span>
|
<span className="text-base font-medium">
|
||||||
|
{t("docker.console")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={selectedShell}
|
value={selectedShell}
|
||||||
@@ -423,9 +445,11 @@ export function ConsoleTerminal({
|
|||||||
{!isConnected && !isConnecting && (
|
{!isConnected && !isConnecting && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
|
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||||
<p className="text-gray-400">{t("docker.notConnected")}</p>
|
<p className="text-muted-foreground">
|
||||||
<p className="text-gray-500 text-sm">
|
{t("docker.notConnected")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
{t("docker.clickToConnect")}
|
{t("docker.clickToConnect")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -437,7 +461,7 @@ export function ConsoleTerminal({
|
|||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<SimpleLoader size="lg" />
|
<SimpleLoader size="lg" />
|
||||||
<p className="text-gray-400 mt-4">
|
<p className="text-muted-foreground mt-4">
|
||||||
{t("docker.connectingTo", { containerName })}
|
{t("docker.connectingTo", { containerName })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
PlayCircle,
|
PlayCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DockerContainer } from "@/types/index.js";
|
import type { DockerContainer } from "@/types/index.js";
|
||||||
import {
|
import {
|
||||||
startDockerContainer,
|
startDockerContainer,
|
||||||
@@ -57,6 +58,7 @@ export function ContainerCard({
|
|||||||
isSelected = false,
|
isSelected = false,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: ContainerCardProps): React.ReactElement {
|
}: ContainerCardProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isStarting, setIsStarting] = React.useState(false);
|
const [isStarting, setIsStarting] = React.useState(false);
|
||||||
const [isStopping, setIsStopping] = React.useState(false);
|
const [isStopping, setIsStopping] = React.useState(false);
|
||||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||||
@@ -102,10 +104,10 @@ export function ContainerCard({
|
|||||||
badge: "bg-purple-500/20 text-purple-300 border-purple-500/30",
|
badge: "bg-purple-500/20 text-purple-300 border-purple-500/30",
|
||||||
},
|
},
|
||||||
dead: {
|
dead: {
|
||||||
bg: "bg-gray-500/10",
|
bg: "bg-muted/10",
|
||||||
border: "border-gray-500/20",
|
border: "border-muted/20",
|
||||||
text: "text-gray-400",
|
text: "text-muted-foreground",
|
||||||
badge: "bg-gray-500/20 text-gray-300 border-gray-500/30",
|
badge: "bg-muted/20 text-muted-foreground border-muted/30",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,26 +262,32 @@ export function ContainerCard({
|
|||||||
<CardContent className="space-y-3 px-4 pb-3">
|
<CardContent className="space-y-3 px-4 pb-3">
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-gray-400 min-w-[50px] text-xs">{t("docker.image")}</span>
|
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||||
<span className="truncate text-gray-200 text-xs">
|
{t("docker.image")}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-foreground text-xs">
|
||||||
{container.image}
|
{container.image}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-gray-400 min-w-[50px] text-xs">{t("docker.idLabel")}</span>
|
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||||
<span className="font-mono text-xs text-gray-200">
|
{t("docker.idLabel")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-foreground">
|
||||||
{container.id.substring(0, 12)}
|
{container.id.substring(0, 12)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="text-gray-400 min-w-[50px] text-xs shrink-0">{t("docker.ports")}</span>
|
<span className="text-muted-foreground min-w-[50px] text-xs shrink-0">
|
||||||
|
{t("docker.ports")}
|
||||||
|
</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{portsList.length > 0 ? (
|
{portsList.length > 0 ? (
|
||||||
portsList.map((port, idx) => (
|
portsList.map((port, idx) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={idx}
|
key={idx}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-xs font-mono bg-gray-500/10 text-gray-400 border-gray-500/30"
|
className="text-xs font-mono bg-muted/10 text-muted-foreground border-muted/30"
|
||||||
>
|
>
|
||||||
{port}
|
{port}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -287,7 +295,7 @@ export function ContainerCard({
|
|||||||
) : (
|
) : (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-xs bg-gray-500/10 text-gray-400 border-gray-500/30"
|
className="text-xs bg-muted/10 text-muted-foreground border-muted/30"
|
||||||
>
|
>
|
||||||
{t("docker.noPorts")}
|
{t("docker.noPorts")}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -295,8 +303,10 @@ export function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-gray-400 min-w-[50px] text-xs">{t("docker.created")}</span>
|
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||||
<span className="text-gray-200 text-xs">
|
{t("docker.created")}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground text-xs">
|
||||||
{formatCreatedDate(container.created)}
|
{formatCreatedDate(container.created)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -390,7 +400,8 @@ export function ContainerCard({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{t("docker.restart")}</TooltipContent> </Tooltip>
|
<TooltipContent>{t("docker.restart")}</TooltipContent>{" "}
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -407,7 +418,8 @@ export function ContainerCard({
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{t("docker.remove")}</TooltipContent> </Tooltip>
|
<TooltipContent>{t("docker.remove")}</TooltipContent>{" "}
|
||||||
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -431,7 +443,9 @@ export function ContainerCard({
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isRemoving}>{t("common.cancel")}</AlertDialogCancel>
|
<AlertDialogCancel disabled={isRemoving}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@/components/ui/tabs.tsx";
|
} from "@/components/ui/tabs.tsx";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DockerContainer, SSHHost } from "@/types/index.js";
|
import type { DockerContainer, SSHHost } from "@/types/index.js";
|
||||||
import { LogViewer } from "./LogViewer.tsx";
|
import { LogViewer } from "./LogViewer.tsx";
|
||||||
import { ContainerStats } from "./ContainerStats.tsx";
|
import { ContainerStats } from "./ContainerStats.tsx";
|
||||||
@@ -28,6 +29,7 @@ export function ContainerDetail({
|
|||||||
hostConfig,
|
hostConfig,
|
||||||
onBack,
|
onBack,
|
||||||
}: ContainerDetailProps): React.ReactElement {
|
}: ContainerDetailProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = React.useState("logs");
|
const [activeTab, setActiveTab] = React.useState("logs");
|
||||||
|
|
||||||
const container = containers.find((c) => c.id === containerId);
|
const container = containers.find((c) => c.id === containerId);
|
||||||
@@ -36,7 +38,9 @@ export function ContainerDetail({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="text-gray-400 text-lg">{t("docker.containerNotFound")}</p>
|
<p className="text-muted-foreground text-lg">
|
||||||
|
{t("docker.containerNotFound")}
|
||||||
|
</p>
|
||||||
<Button onClick={onBack} variant="outline">
|
<Button onClick={onBack} variant="outline">
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
{t("docker.backToList")}
|
{t("docker.backToList")}
|
||||||
@@ -56,7 +60,9 @@ export function ContainerDetail({
|
|||||||
</Button>
|
</Button>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="font-bold text-lg truncate">{container.name}</h2>
|
<h2 className="font-bold text-lg truncate">{container.name}</h2>
|
||||||
<p className="text-sm text-gray-400 truncate">{container.image}</p>
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{container.image}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="p-0.25 w-full" />
|
<Separator className="p-0.25 w-full" />
|
||||||
@@ -72,7 +78,9 @@ export function ContainerDetail({
|
|||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="logs">{t("docker.logs")}</TabsTrigger>
|
<TabsTrigger value="logs">{t("docker.logs")}</TabsTrigger>
|
||||||
<TabsTrigger value="stats">{t("docker.stats")}</TabsTrigger>
|
<TabsTrigger value="stats">{t("docker.stats")}</TabsTrigger>
|
||||||
<TabsTrigger value="console">{t("docker.consoleTab")}</TabsTrigger>
|
<TabsTrigger value="console">
|
||||||
|
{t("docker.consoleTab")}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select.tsx";
|
} from "@/components/ui/select.tsx";
|
||||||
import { Search, Filter } from "lucide-react";
|
import { Search, Filter } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DockerContainer } from "@/types/index.js";
|
import type { DockerContainer } from "@/types/index.js";
|
||||||
import { ContainerCard } from "./ContainerCard.tsx";
|
import { ContainerCard } from "./ContainerCard.tsx";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export function ContainerList({
|
|||||||
selectedContainerId = null,
|
selectedContainerId = null,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: ContainerListProps): React.ReactElement {
|
}: ContainerListProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [searchQuery, setSearchQuery] = React.useState("");
|
const [searchQuery, setSearchQuery] = React.useState("");
|
||||||
const [statusFilter, setStatusFilter] = React.useState<string>("all");
|
const [statusFilter, setStatusFilter] = React.useState<string>("all");
|
||||||
|
|
||||||
@@ -55,8 +57,12 @@ export function ContainerList({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="text-gray-400 text-lg">{t("docker.noContainersFound")}</p>
|
<p className="text-muted-foreground text-lg">
|
||||||
<p className="text-gray-500 text-sm">{t("docker.noContainersFoundHint")}</p>
|
{t("docker.noContainersFound")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("docker.noContainersFoundHint")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -67,7 +73,7 @@ export function ContainerList({
|
|||||||
{/* Search and Filter Bar */}
|
{/* Search and Filter Bar */}
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("docker.searchPlaceholder")}
|
placeholder={t("docker.searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -76,16 +82,23 @@ export function ContainerList({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 sm:min-w-[200px]">
|
<div className="flex items-center gap-2 sm:min-w-[200px]">
|
||||||
<Filter className="h-4 w-4 text-gray-400" />
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder={t("docker.filterByStatusPlaceholder")} />
|
<SelectValue
|
||||||
|
placeholder={t("docker.filterByStatusPlaceholder")}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t("docker.allContainersCount", { count: containers.length })}</SelectItem>
|
<SelectItem value="all">
|
||||||
|
{t("docker.allContainersCount", { count: containers.length })}
|
||||||
|
</SelectItem>
|
||||||
{Object.entries(statusCounts).map(([status, count]) => (
|
{Object.entries(statusCounts).map(([status, count]) => (
|
||||||
<SelectItem key={status} value={status}>
|
<SelectItem key={status} value={status}>
|
||||||
{t("docker.statusCount", { status: status.charAt(0).toUpperCase() + status.slice(1), count })}
|
{t("docker.statusCount", {
|
||||||
|
status: status.charAt(0).toUpperCase() + status.slice(1),
|
||||||
|
count,
|
||||||
|
})}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -97,8 +110,12 @@ export function ContainerList({
|
|||||||
{filteredContainers.length === 0 ? (
|
{filteredContainers.length === 0 ? (
|
||||||
<div className="flex items-center justify-center flex-1">
|
<div className="flex items-center justify-center flex-1">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="text-gray-400">{t("docker.noContainersMatchFilters")}</p>
|
<p className="text-muted-foreground">
|
||||||
<p className="text-gray-500 text-sm">{t("docker.noContainersMatchFiltersHint")}</p>
|
{t("docker.noContainersMatchFilters")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("docker.noContainersMatchFiltersHint")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ export function ContainerStats({
|
|||||||
const data = await getContainerStats(sessionId, containerId);
|
const data = await getContainerStats(sessionId, containerId);
|
||||||
setStats(data);
|
setStats(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("docker.failedToFetchStats"));
|
setError(
|
||||||
|
err instanceof Error ? err.message : t("docker.failedToFetchStats"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,11 +63,11 @@ export function ContainerStats({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<Activity className="h-12 w-12 text-gray-600 mx-auto" />
|
<Activity className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||||
<p className="text-gray-400 text-lg">
|
<p className="text-muted-foreground text-lg">
|
||||||
{t("docker.containerNotRunning")}
|
{t("docker.containerNotRunning")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{t("docker.startContainerToViewStats")}
|
{t("docker.startContainerToViewStats")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +80,9 @@ export function ContainerStats({
|
|||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<SimpleLoader size="lg" />
|
<SimpleLoader size="lg" />
|
||||||
<p className="text-gray-400 mt-4">{t("docker.loadingStats")}</p>
|
<p className="text-muted-foreground mt-4">
|
||||||
|
{t("docker.loadingStats")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -91,7 +95,7 @@ export function ContainerStats({
|
|||||||
<p className="text-red-400 text-lg">
|
<p className="text-red-400 text-lg">
|
||||||
{t("docker.errorLoadingStats")}
|
{t("docker.errorLoadingStats")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-sm">{error}</p>
|
<p className="text-muted-foreground text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -100,7 +104,7 @@ export function ContainerStats({
|
|||||||
if (!stats) {
|
if (!stats) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p className="text-gray-400">{t("docker.noStatsAvailable")}</p>
|
<p className="text-muted-foreground">{t("docker.noStatsAvailable")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,7 +125,10 @@ export function ContainerStats({
|
|||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="px-4 pb-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-400">{t("docker.current")}</span> <span className="font-mono font-semibold text-blue-300">
|
<span className="text-muted-foreground">
|
||||||
|
{t("docker.current")}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="font-mono font-semibold text-blue-400">
|
||||||
{stats.cpu}
|
{stats.cpu}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,14 +148,18 @@ export function ContainerStats({
|
|||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="px-4 pb-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-400">{t("docker.usedLimit")}</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-mono font-semibold text-purple-300">
|
{t("docker.usedLimit")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-semibold text-purple-400">
|
||||||
{stats.memoryUsed} / {stats.memoryLimit}
|
{stats.memoryUsed} / {stats.memoryLimit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-400">{t("docker.percentage")}</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-mono text-purple-300">
|
{t("docker.percentage")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-purple-400">
|
||||||
{stats.memoryPercent}
|
{stats.memoryPercent}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,12 +179,14 @@ export function ContainerStats({
|
|||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="px-4 pb-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-400">{t("docker.input")}</span>
|
<span className="text-muted-foreground">{t("docker.input")}</span>
|
||||||
<span className="font-mono text-green-300">{stats.netInput}</span>
|
<span className="font-mono text-green-400">{stats.netInput}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-400">{t("docker.output")}</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-mono text-green-300">
|
{t("docker.output")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-green-400">
|
||||||
{stats.netOutput}
|
{stats.netOutput}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,21 +205,23 @@ export function ContainerStats({
|
|||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="px-4 pb-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-400">{t("docker.read")}</span>
|
<span className="text-muted-foreground">{t("docker.read")}</span>
|
||||||
<span className="font-mono text-orange-300">
|
<span className="font-mono text-orange-400">
|
||||||
{stats.blockRead}
|
{stats.blockRead}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-400">{t("docker.write")}</span>
|
<span className="text-muted-foreground">{t("docker.write")}</span>
|
||||||
<span className="font-mono text-orange-300">
|
<span className="font-mono text-orange-400">
|
||||||
{stats.blockWrite}
|
{stats.blockWrite}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{stats.pids && (
|
{stats.pids && (
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-400">{t("docker.pids")}</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-mono text-orange-300">{stats.pids}</span>
|
{t("docker.pids")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-orange-400">{stats.pids}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -224,17 +239,17 @@ export function ContainerStats({
|
|||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="px-4 pb-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-400">{t("docker.name")}</span>
|
<span className="text-muted-foreground">{t("docker.name")}</span>
|
||||||
<span className="font-mono text-gray-200">{containerName}</span>
|
<span className="font-mono text-foreground">{containerName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-400">{t("docker.id")}</span>
|
<span className="text-muted-foreground">{t("docker.id")}</span>
|
||||||
<span className="font-mono text-sm text-gray-300">
|
<span className="font-mono text-sm text-foreground">
|
||||||
{containerId.substring(0, 12)}
|
{containerId.substring(0, 12)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-gray-400">{t("docker.state")}</span>
|
<span className="text-muted-foreground">{t("docker.state")}</span>
|
||||||
<span className="font-semibold text-green-400 capitalize">
|
<span className="font-semibold text-green-400 capitalize">
|
||||||
{containerState}
|
{containerState}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -209,13 +209,13 @@ export function LogViewer({
|
|||||||
{/* Search Filter */}
|
{/* Search Filter */}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter logs..."
|
placeholder="Filter logs..."
|
||||||
value={searchFilter}
|
value={searchFilter}
|
||||||
onChange={(e) => setSearchFilter(e.target.value)}
|
onChange={(e) => setSearchFilter(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 bg-dark-bg border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full pl-10 pr-4 py-2 border border-input rounded-md text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,9 +231,11 @@ export function LogViewer({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full overflow-auto thin-scrollbar">
|
<div className="h-full overflow-auto thin-scrollbar">
|
||||||
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-gray-200 leading-relaxed">
|
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-foreground leading-relaxed">
|
||||||
{filteredLogs || (
|
{filteredLogs || (
|
||||||
<span className="text-gray-500">No logs available</span>
|
<span className="text-muted-foreground">
|
||||||
|
No logs available
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<div ref={logsEndRef} />
|
<div ref={logsEndRef} />
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ export function ServerStats({
|
|||||||
{(metricsEnabled && showStatsUI) ||
|
{(metricsEnabled && showStatsUI) ||
|
||||||
(currentHostConfig?.quickActions &&
|
(currentHostConfig?.quickActions &&
|
||||||
currentHostConfig.quickActions.length > 0) ? (
|
currentHostConfig.quickActions.length > 0) ? (
|
||||||
<div className="rounded-lg border-2 border-edge m-3 bg-elevated p-4 overflow-y-auto thin-scrollbar relative flex-1 flex flex-col">
|
<div className="border-edge m-1 p-2 overflow-y-auto thin-scrollbar relative flex-1 flex flex-col">
|
||||||
{currentHostConfig?.quickActions &&
|
{currentHostConfig?.quickActions &&
|
||||||
currentHostConfig.quickActions.length > 0 && (
|
currentHostConfig.quickActions.length > 0 && (
|
||||||
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
|||||||
}, [metricsHistory]);
|
}, [metricsHistory]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
<Cpu className="h-5 w-5 text-blue-400" />
|
<Cpu className="h-5 w-5 text-blue-400" />
|
||||||
<h3 className="font-semibold text-lg text-foreground">
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) {
|
|||||||
}, [metrics]);
|
}, [metrics]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||||
<h3 className="font-semibold text-lg text-foreground">
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
|||||||
const uniqueIPs = loginStats?.uniqueIPs || 0;
|
const uniqueIPs = loginStats?.uniqueIPs || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
<UserCheck className="h-5 w-5 text-green-400" />
|
<UserCheck className="h-5 w-5 text-green-400" />
|
||||||
<h3 className="font-semibold text-lg text-foreground">
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
@@ -45,7 +45,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
|||||||
|
|
||||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||||
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
|
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
|
||||||
<div className="bg-elevated p-2 rounded border border-edge/30">
|
<div className="bg-canvas/40 p-2 rounded border border-edge/30 hover:bg-canvas/50">
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||||
<Activity className="h-3 w-3" />
|
<Activity className="h-3 w-3" />
|
||||||
<span>{t("serverStats.totalLogins")}</span>
|
<span>{t("serverStats.totalLogins")}</span>
|
||||||
@@ -54,7 +54,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
|||||||
{totalLogins}
|
{totalLogins}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-elevated p-2 rounded border border-edge/30">
|
<div className="bg-canvas/40 p-2 rounded border border-edge/30 hover:bg-canvas/50">
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
<span>{t("serverStats.uniqueIPs")}</span>
|
<span>{t("serverStats.uniqueIPs")}</span>
|
||||||
@@ -80,7 +80,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
|||||||
{recentLogins.slice(0, 5).map((login, idx) => (
|
{recentLogins.slice(0, 5).map((login, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="text-xs bg-elevated p-2 rounded border border-edge/30 flex justify-between items-center"
|
className="text-xs bg-canvas/40 p-2 rounded border border-edge/30 hover:bg-canvas/50 flex justify-between items-center"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-green-400 font-mono truncate">
|
<span className="text-green-400 font-mono truncate">
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
|||||||
}, [metricsHistory]);
|
}, [metricsHistory]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||||
<h3 className="font-semibold text-lg text-foreground">
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
|||||||
const interfaces = network?.interfaces || [];
|
const interfaces = network?.interfaces || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
<Network className="h-5 w-5 text-indigo-400" />
|
<Network className="h-5 w-5 text-indigo-400" />
|
||||||
<h3 className="font-semibold text-lg text-foreground">
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
@@ -43,7 +42,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
|||||||
interfaces.map((iface, index: number) => (
|
interfaces.map((iface, index: number) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="p-3 rounded-lg bg-canvas/50 border border-edge/30 hover:bg-canvas/60 transition-colors"
|
className="p-3 rounded-lg bg-canvas/40 border border-edge/30 hover:bg-canvas/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
|||||||
const topProcesses = processes?.top || [];
|
const topProcesses = processes?.top || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
<List className="h-5 w-5 text-yellow-400" />
|
<List className="h-5 w-5 text-yellow-400" />
|
||||||
<h3 className="font-semibold text-lg text-foreground">
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
@@ -62,7 +62,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
|||||||
{topProcesses.map((proc, index) => (
|
{topProcesses.map((proc, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="p-2.5 rounded-lg bg-canvas/30 hover:bg-canvas/50 transition-colors border border-edge/20"
|
className="p-2.5 rounded-lg bg-canvas/40 hover:bg-canvas/50 border border-edge/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-xs font-mono text-muted-foreground font-medium">
|
<span className="text-xs font-mono text-muted-foreground font-medium">
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
|
|||||||
const system = metricsWithSystem?.system;
|
const system = metricsWithSystem?.system;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
<Server className="h-5 w-5 text-purple-400" />
|
<Server className="h-5 w-5 text-purple-400" />
|
||||||
<h3 className="font-semibold text-lg text-foreground">
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
|
|||||||
const uptime = metricsWithUptime?.uptime;
|
const uptime = metricsWithUptime?.uptime;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
<Clock className="h-5 w-5 text-cyan-400" />
|
<Clock className="h-5 w-5 text-cyan-400" />
|
||||||
<h3 className="font-semibold text-lg text-foreground">
|
<h3 className="font-semibold text-lg text-foreground">
|
||||||
|
|||||||
@@ -247,7 +247,8 @@ export function SSHToolsSidebar({
|
|||||||
tab.type === "terminal" ||
|
tab.type === "terminal" ||
|
||||||
tab.type === "server" ||
|
tab.type === "server" ||
|
||||||
tab.type === "file_manager" ||
|
tab.type === "file_manager" ||
|
||||||
tab.type === "user_profile",
|
tab.type === "tunnel" ||
|
||||||
|
tab.type === "docker",
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1246,7 +1247,7 @@ export function SSHToolsSidebar({
|
|||||||
{terminalTabs.length > 0 && (
|
{terminalTabs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white">
|
<label className="text-sm font-medium text-foreground">
|
||||||
{t("snippets.selectTerminals")}
|
{t("snippets.selectTerminals")}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -1702,7 +1703,7 @@ export function SSHToolsSidebar({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white">
|
<label className="text-sm font-medium text-foreground">
|
||||||
{t("splitScreen.availableTabs")}
|
{t("splitScreen.availableTabs")}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
@@ -1745,7 +1746,7 @@ export function SSHToolsSidebar({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white">
|
<label className="text-sm font-medium text-foreground">
|
||||||
{t("splitScreen.layout")}
|
{t("splitScreen.layout")}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
@@ -1763,7 +1764,9 @@ export function SSHToolsSidebar({
|
|||||||
const assignedTabId =
|
const assignedTabId =
|
||||||
splitAssignments.get(idx);
|
splitAssignments.get(idx);
|
||||||
const assignedTab = assignedTabId
|
const assignedTab = assignedTabId
|
||||||
? tabs.find((t) => t.id === assignedTabId)
|
? splittableTabs.find(
|
||||||
|
(t) => t.id === assignedTabId,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
const isHovered = dragOverCellIndex === idx;
|
const isHovered = dragOverCellIndex === idx;
|
||||||
const isEmpty = !assignedTabId;
|
const isEmpty = !assignedTabId;
|
||||||
@@ -2050,7 +2053,7 @@ export function SSHToolsSidebar({
|
|||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white flex items-center gap-1">
|
<label className="text-sm font-medium text-foreground flex items-center gap-1">
|
||||||
{t("snippets.folderName")}
|
{t("snippets.folderName")}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -2074,7 +2077,7 @@ export function SSHToolsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-semibold text-white">
|
<Label className="text-base font-semibold text-foreground">
|
||||||
{t("snippets.folderColor")}
|
{t("snippets.folderColor")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
@@ -2101,7 +2104,7 @@ export function SSHToolsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-semibold text-white">
|
<Label className="text-base font-semibold text-foreground">
|
||||||
{t("snippets.folderIcon")}
|
{t("snippets.folderIcon")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
@@ -2126,7 +2129,7 @@ export function SSHToolsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-semibold text-white">
|
<Label className="text-base font-semibold text-foreground">
|
||||||
{t("snippets.preview")}
|
{t("snippets.preview")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-center gap-3 p-4 rounded-md bg-elevated border border-edge">
|
<div className="flex items-center gap-3 p-4 rounded-md bg-elevated border border-edge">
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ export function TunnelManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const containerClass = embedded
|
const containerClass = embedded
|
||||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={wrapperStyle} className={containerClass}>
|
<div style={wrapperStyle} className={containerClass}>
|
||||||
|
|||||||
@@ -322,7 +322,9 @@ export function AppView({
|
|||||||
? TERMINAL_THEMES.termixDark.colors
|
? TERMINAL_THEMES.termixDark.colors
|
||||||
: TERMINAL_THEMES.termixLight.colors;
|
: TERMINAL_THEMES.termixLight.colors;
|
||||||
} else {
|
} else {
|
||||||
themeColors = TERMINAL_THEMES[terminalConfig.theme]?.colors || TERMINAL_THEMES.termixDark.colors;
|
themeColors =
|
||||||
|
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
|
||||||
|
TERMINAL_THEMES.termixDark.colors;
|
||||||
}
|
}
|
||||||
const backgroundColor = themeColors.background;
|
const backgroundColor = themeColors.background;
|
||||||
|
|
||||||
@@ -331,7 +333,9 @@ export function AppView({
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-0 rounded-md overflow-hidden"
|
className="absolute inset-0 rounded-md overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isTerminal ? backgroundColor : "var(--bg-base)",
|
backgroundColor: isTerminal
|
||||||
|
? backgroundColor
|
||||||
|
: "var(--bg-base)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.type === "terminal" ? (
|
{t.type === "terminal" ? (
|
||||||
@@ -409,7 +413,7 @@ export function AppView({
|
|||||||
const handleStyle = {
|
const handleStyle = {
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
zIndex: 12,
|
zIndex: 12,
|
||||||
background: "var(--color-border-base)",
|
background: isDarkMode ? "#303032" : "#e5e7eb",
|
||||||
} as React.CSSProperties;
|
} as React.CSSProperties;
|
||||||
const commonGroupProps: {
|
const commonGroupProps: {
|
||||||
onLayout: () => void;
|
onLayout: () => void;
|
||||||
@@ -703,7 +707,9 @@ export function AppView({
|
|||||||
? TERMINAL_THEMES.termixDark.colors
|
? TERMINAL_THEMES.termixDark.colors
|
||||||
: TERMINAL_THEMES.termixLight.colors;
|
: TERMINAL_THEMES.termixLight.colors;
|
||||||
} else {
|
} else {
|
||||||
containerThemeColors = TERMINAL_THEMES[terminalConfig.theme]?.colors || TERMINAL_THEMES.termixDark.colors;
|
containerThemeColors =
|
||||||
|
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
|
||||||
|
TERMINAL_THEMES.termixDark.colors;
|
||||||
}
|
}
|
||||||
const terminalBackgroundColor = containerThemeColors.background;
|
const terminalBackgroundColor = containerThemeColors.background;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user