v1.10.0 #471

Merged
LukeGus merged 106 commits from dev-1.10.0 into main 2026-01-01 04:20:12 +00:00
36 changed files with 1780 additions and 1386 deletions
Showing only changes of commit 053017a502 - Show all commits

View File

@@ -429,13 +429,59 @@ wss.on("connection", async (ws: WebSocket, req) => {
activeSessions.set(sessionId, sshSession);
// Detect or use provided shell
const detectedShell =
shell || (await detectShell(sshSession, containerId));
sshSession.shell = detectedShell;
// Validate or detect shell
let shellToUse = shell || "bash";
// 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
const execCommand = `docker exec -it ${containerId} /bin/${detectedShell}`;
const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`;
client.exec(
execCommand,
@@ -482,14 +528,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
stream.stderr.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "output",
data: data.toString("utf8"),
}),
);
}
// Log stderr but don't send to terminal to avoid duplicate error messages
dockerConsoleLogger.debug("Docker exec stderr", {
operation: "docker_exec_stderr",
sessionId,
containerId,
data: data.toString("utf8"),
});
});
stream.on("close", () => {
@@ -512,7 +557,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(
JSON.stringify({
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",
sessionId,
containerId,
shell: detectedShell,
shell: shellToUse,
requestedShell: shell,
});
},
);

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
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,
)}
{...props}

View File

@@ -52,15 +52,15 @@
--bg-base: #fcfcfc;
--bg-elevated: #ffffff;
--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-deepest: #e5e7eb;
--bg-header: #eeeeef;
--bg-button: #f3f4f6;
--bg-active: #e5e7eb;
--bg-light: #fafafa; /* Light background - replaces dark-bg-light */
--bg-subtle: #f5f5f5; /* Very light background - replaces dark-bg-very-light */
--bg-interact: #d1d5db; /* Interactive/active state - replaces dark-active */
--bg-light: #fafafa; /* Light background - replaces dark-bg-light */
--bg-subtle: #f5f5f5; /* Very light background - replaces dark-bg-very-light */
--bg-interact: #d1d5db; /* Interactive/active state - replaces dark-active */
--border-base: #e5e7eb;
--border-panel: #d1d5db;
--border-subtle: #f3f4f6;
@@ -203,19 +203,18 @@
--sidebar-border: #ffffff1a;
--sidebar-ring: #71717a;
/* NEW SEMANTIC VARIABLES - Dark Mode Background Overrides */
--bg-base: #18181b;
--bg-elevated: #0e0e10;
--bg-surface: #1b1b1e;
--bg-surface-hover: #232327; /* Panel hover */
--bg-surface-hover: #232327;
--bg-input: #222225;
--bg-deepest: #09090b;
--bg-header: #131316;
--bg-button: #23232a;
--bg-active: #1d1d1f;
--bg-light: #141416; /* Light background */
--bg-subtle: #101014; /* Very light background */
--bg-interact: #2a2a2c; /* Interactive/active state */
--bg-light: #141416;
--bg-subtle: #101014;
--bg-interact: #2a2a2c;
--border-base: #303032;
--border-panel: #222224;
--border-subtle: #5a5a5d;
@@ -227,8 +226,8 @@
--border-active: #2d2d30;
/* NEW SEMANTIC VARIABLES - Dark Mode Text Color Overrides */
--foreground-secondary: #d1d5db; /* Matches text-gray-300 */
--foreground-subtle: #6b7280; /* Matches text-gray-500 */
--foreground-secondary: #d1d5db; /* Matches text-gray-300 */
--foreground-subtle: #6b7280; /* Matches text-gray-500 */
/* Scrollbar Colors - Dark Mode */
--scrollbar-thumb: #434345;

View File

@@ -1591,6 +1591,10 @@
"permissionsChangedSuccessfully": "Permissions changed successfully",
"failedToChangePermissions": "Failed to change permissions"
},
"tunnel": {
"noTunnelsConfigured": "No Tunnels Configured",
"configureTunnelsInHostSettings": "Configure tunnel connections in the Host Manager to get started"
},
"tunnels": {
"title": "SSH Tunnels",
"noSshTunnels": "No SSH Tunnels",
@@ -1601,6 +1605,7 @@
"disconnecting": "Disconnecting...",
"unknownTunnelStatus": "Unknown",
"statusUnknown": "Unknown",
"unknown": "Unknown",
"error": "Error",
"failed": "Failed",
"retrying": "Retrying",
@@ -2333,5 +2338,95 @@
"close": "Close",
"hostManager": "Host Manager",
"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

View File

@@ -6,16 +6,16 @@ import {
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
} 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 { PasswordInput } from "@/components/ui/password-input.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import { UserPlus, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { registerUser } from "@/ui/main-axios";
import { registerUser } from "@/ui/main-axios.ts";
interface CreateUserDialogProps {
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>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="w-5 h-5" />

View 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>
);
}

View File

@@ -6,13 +6,13 @@ import {
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import {
UserCog,
@@ -24,7 +24,7 @@ import {
Clock,
} from "lucide-react";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getUserRoles,
getRoles,
@@ -37,7 +37,7 @@ import {
deleteUser,
type UserRole,
type Role,
} from "@/ui/main-axios";
} from "@/ui/main-axios.ts";
interface User {
id: string;
@@ -354,7 +354,7 @@ export function UserEditDialog({
return (
<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>
<DialogTitle className="flex items-center gap-2">
<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">
{/* 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>
<Label className="text-muted-foreground text-xs">
{t("admin.username")}
@@ -408,7 +408,7 @@ export function UserEditDialog({
<Shield className="h-4 w-4" />
{t("admin.adminPrivileges")}
</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">
<p className="font-medium">{t("admin.administratorRole")}</p>
<p className="text-sm text-muted-foreground">
@@ -487,7 +487,7 @@ export function UserEditDialog({
{userRoles.map((role) => (
<div
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>
<p className="font-medium text-sm">
@@ -508,7 +508,7 @@ export function UserEditDialog({
variant="ghost"
size="sm"
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" />
</Button>
@@ -566,7 +566,7 @@ export function UserEditDialog({
<Clock className="h-4 w-4" />
{t("admin.sessionManagement")}
</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">
<p className="font-medium text-sm">
{t("admin.revokeAllSessions")}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -32,7 +32,7 @@ import {
type Role,
} from "@/ui/main-axios.ts";
export function RoleManagement(): React.ReactElement {
export function RolesTab(): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
@@ -234,7 +234,7 @@ export function RoleManagement(): React.ReactElement {
{/* Create/Edit Role Dialog */}
<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>
<DialogTitle>
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}

View 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>
);
}

View 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>
);
}

View File

@@ -239,7 +239,7 @@ export function CommandPalette({
>
<Command
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",
!isOpen && "scale-95 opacity-0",
)}

View File

@@ -484,10 +484,16 @@ export function CredentialEditor({
className="w-full"
>
<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")}
</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")}
</TabsTrigger>
</TabsList>
@@ -693,10 +699,16 @@ export function CredentialEditor({
className="flex-1 flex flex-col h-full min-h-0"
>
<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")}
</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")}
</TabsTrigger>
</TabsList>
@@ -719,7 +731,7 @@ export function CredentialEditor({
</TabsContent>
<TabsContent value="key">
<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">
{t("credentials.generateKeyPair")}
</FormLabel>
@@ -954,7 +966,8 @@ export function CredentialEditor({
".cm-scroller": {
overflow: "auto",
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": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
},
}),
]}

View File

@@ -407,7 +407,7 @@ export function Dashboard({
</p>
</div>
<Button
className="font-semibold shrink-0"
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open(
@@ -419,7 +419,7 @@ export function Dashboard({
{t("dashboard.github")}
</Button>
<Button
className="font-semibold shrink-0"
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open(
@@ -431,7 +431,7 @@ export function Dashboard({
{t("dashboard.support")}
</Button>
<Button
className="font-semibold shrink-0"
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open(
@@ -443,7 +443,7 @@ export function Dashboard({
{t("dashboard.discord")}
</Button>
<Button
className="font-semibold shrink-0"
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
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="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<History
size={20}
className="shrink-0"
/>
<History size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.version")}
</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 min-w-0">
<Clock
size={20}
className="shrink-0"
/>
<Clock size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.uptime")}
</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 min-w-0">
<Database
size={20}
className="shrink-0"
/>
<Database size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.database")}
</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-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">
<Server
size={16}
className="mr-3 shrink-0"
/>
<Server size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalServers")}
</p>
@@ -550,10 +538,7 @@ export function Dashboard({
</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 min-w-0">
<Network
size={16}
className="mr-3 shrink-0"
/>
<Network size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalTunnels")}
</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-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">
<Key
size={16}
className="mr-3 shrink-0"
/>
<Key size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalCredentials")}
</p>
@@ -591,7 +573,7 @@ export function Dashboard({
<Button
variant="outline"
size="sm"
className="border-2 !border-edge h-7"
className="border-2 !border-edge h-7 !bg-canvas"
onClick={handleResetActivity}
>
{t("dashboard.reset")}
@@ -626,7 +608,7 @@ export function Dashboard({
<Button
key={item.id}
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)}
>
{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">
<Button
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}
>
<Server
@@ -667,7 +649,7 @@ export function Dashboard({
</Button>
<Button
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}
>
<Key
@@ -681,7 +663,7 @@ export function Dashboard({
{isAdmin && (
<Button
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}
>
<Settings
@@ -695,7 +677,7 @@ export function Dashboard({
)}
<Button
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}
>
<User
@@ -732,7 +714,7 @@ export function Dashboard({
<Button
key={server.id}
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={() =>
handleServerStatClick(server.id, server.name)
}

View File

@@ -230,8 +230,8 @@ export function DockerManager({
};
const containerClass = embedded
? "h-full w-full text-white overflow-hidden bg-transparent"
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
// Check if Docker is enabled
if (!currentHostConfig?.enableDocker) {
@@ -252,9 +252,7 @@ export function DockerManager({
<div className="flex-1 overflow-hidden min-h-0 p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("docker.notEnabled")}
</AlertDescription>
<AlertDescription>{t("docker.notEnabled")}</AlertDescription>
</Alert>
</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="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">
<p className="text-muted-foreground mt-4">
{isValidating
? t("docker.validating")
: t("docker.connectingToHost")}
@@ -338,7 +336,7 @@ export function DockerManager({
{currentHostConfig?.folder} / {title}
</h1>
{dockerValidation?.version && (
<p className="text-xs text-gray-400">
<p className="text-xs text-muted-foreground">
{t("docker.version", { version: dockerValidation.version })}
</p>
)}
@@ -363,7 +361,7 @@ export function DockerManager({
/>
) : (
<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>
@@ -377,7 +375,7 @@ export function DockerManager({
/>
) : (
<div className="text-center py-8">
<p className="text-gray-400">
<p className="text-muted-foreground">
Select a container to view details
</p>
</div>

View File

@@ -93,9 +93,18 @@ export function ConsoleTerminal({
terminal.options.cursorBlink = true;
terminal.options.fontSize = 14;
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 = {
background: "#18181b",
foreground: "#c9d1d9",
background: backgroundColor || "#ffffff",
foreground: foregroundColor || "#09090b",
};
setTimeout(() => {
@@ -152,12 +161,11 @@ export function ConsoleTerminal({
if (terminal) {
try {
terminal.clear();
terminal.write(`${t("docker.disconnectedFromContainer")}\r\n`);
} catch (error) {
// Terminal might be disposed
}
}
}, [terminal]);
}, [terminal, t]);
const connect = React.useCallback(() => {
if (!terminal || containerState !== "running") {
@@ -216,7 +224,15 @@ export function ConsoleTerminal({
case "connected":
setIsConnected(true);
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
setTimeout(() => {
@@ -251,7 +267,9 @@ export function ConsoleTerminal({
case "error":
setIsConnecting(false);
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;
}
} catch (error) {
@@ -341,9 +359,11 @@ export function ConsoleTerminal({
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400 text-lg">{t("docker.containerNotRunning")}</p>
<p className="text-gray-500 text-sm">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground text-lg">
{t("docker.containerNotRunning")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.startContainerToAccess")}
</p>
</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 items-center gap-2 flex-1">
<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>
<Select
value={selectedShell}
@@ -423,9 +445,11 @@ export function ConsoleTerminal({
{!isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400">{t("docker.notConnected")}</p>
<p className="text-gray-500 text-sm">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground">
{t("docker.notConnected")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.clickToConnect")}
</p>
</div>
@@ -437,7 +461,7 @@ export function ConsoleTerminal({
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">
<p className="text-muted-foreground mt-4">
{t("docker.connectingTo", { containerName })}
</p>
</div>

View File

@@ -16,6 +16,7 @@ import {
PlayCircle,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types/index.js";
import {
startDockerContainer,
@@ -57,6 +58,7 @@ export function ContainerCard({
isSelected = false,
onRefresh,
}: ContainerCardProps): React.ReactElement {
const { t } = useTranslation();
const [isStarting, setIsStarting] = React.useState(false);
const [isStopping, setIsStopping] = 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",
},
dead: {
bg: "bg-gray-500/10",
border: "border-gray-500/20",
text: "text-gray-400",
badge: "bg-gray-500/20 text-gray-300 border-gray-500/30",
bg: "bg-muted/10",
border: "border-muted/20",
text: "text-muted-foreground",
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">
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">{t("docker.image")}</span>
<span className="truncate text-gray-200 text-xs">
<span className="text-muted-foreground min-w-[50px] text-xs">
{t("docker.image")}
</span>
<span className="truncate text-foreground text-xs">
{container.image}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">{t("docker.idLabel")}</span>
<span className="font-mono text-xs text-gray-200">
<span className="text-muted-foreground min-w-[50px] text-xs">
{t("docker.idLabel")}
</span>
<span className="font-mono text-xs text-foreground">
{container.id.substring(0, 12)}
</span>
</div>
<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">
{portsList.length > 0 ? (
portsList.map((port, idx) => (
<Badge
key={idx}
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}
</Badge>
@@ -287,7 +295,7 @@ export function ContainerCard({
) : (
<Badge
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")}
</Badge>
@@ -295,8 +303,10 @@ export function ContainerCard({
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">{t("docker.created")}</span>
<span className="text-gray-200 text-xs">
<span className="text-muted-foreground min-w-[50px] text-xs">
{t("docker.created")}
</span>
<span className="text-foreground text-xs">
{formatCreatedDate(container.created)}
</span>
</div>
@@ -390,7 +400,8 @@ export function ContainerCard({
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t("docker.restart")}</TooltipContent> </Tooltip>
<TooltipContent>{t("docker.restart")}</TooltipContent>{" "}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -407,7 +418,8 @@ export function ContainerCard({
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("docker.remove")}</TooltipContent> </Tooltip>
<TooltipContent>{t("docker.remove")}</TooltipContent>{" "}
</Tooltip>
</TooltipProvider>
</div>
</CardContent>
@@ -431,7 +443,9 @@ export function ContainerCard({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogCancel disabled={isRemoving}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();

View File

@@ -8,6 +8,7 @@ import {
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { ArrowLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { DockerContainer, SSHHost } from "@/types/index.js";
import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx";
@@ -28,6 +29,7 @@ export function ContainerDetail({
hostConfig,
onBack,
}: ContainerDetailProps): React.ReactElement {
const { t } = useTranslation();
const [activeTab, setActiveTab] = React.useState("logs");
const container = containers.find((c) => c.id === containerId);
@@ -36,7 +38,9 @@ export function ContainerDetail({
return (
<div className="flex items-center justify-center h-full">
<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">
<ArrowLeft className="h-4 w-4 mr-2" />
{t("docker.backToList")}
@@ -56,7 +60,9 @@ export function ContainerDetail({
</Button>
<div className="min-w-0 flex-1">
<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>
<Separator className="p-0.25 w-full" />
@@ -72,7 +78,9 @@ export function ContainerDetail({
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="logs">{t("docker.logs")}</TabsTrigger>
<TabsTrigger value="stats">{t("docker.stats")}</TabsTrigger>
<TabsTrigger value="console">{t("docker.consoleTab")}</TabsTrigger>
<TabsTrigger value="console">
{t("docker.consoleTab")}
</TabsTrigger>
</TabsList>
</div>

View File

@@ -8,6 +8,7 @@ import {
SelectValue,
} from "@/components/ui/select.tsx";
import { Search, Filter } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types/index.js";
import { ContainerCard } from "./ContainerCard.tsx";
@@ -26,6 +27,7 @@ export function ContainerList({
selectedContainerId = null,
onRefresh,
}: ContainerListProps): React.ReactElement {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<string>("all");
@@ -55,8 +57,12 @@ export function ContainerList({
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-gray-400 text-lg">{t("docker.noContainersFound")}</p>
<p className="text-gray-500 text-sm">{t("docker.noContainersFoundHint")}</p>
<p className="text-muted-foreground text-lg">
{t("docker.noContainersFound")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.noContainersFoundHint")}
</p>
</div>
</div>
);
@@ -67,7 +73,7 @@ export function ContainerList({
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-2">
<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
placeholder={t("docker.searchPlaceholder")}
value={searchQuery}
@@ -76,16 +82,23 @@ export function ContainerList({
/>
</div>
<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}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("docker.filterByStatusPlaceholder")} />
<SelectValue
placeholder={t("docker.filterByStatusPlaceholder")}
/>
</SelectTrigger>
<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]) => (
<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>
))}
</SelectContent>
@@ -97,8 +110,12 @@ export function ContainerList({
{filteredContainers.length === 0 ? (
<div className="flex items-center justify-center flex-1">
<div className="text-center space-y-2">
<p className="text-gray-400">{t("docker.noContainersMatchFilters")}</p>
<p className="text-gray-500 text-sm">{t("docker.noContainersMatchFiltersHint")}</p>
<p className="text-muted-foreground">
{t("docker.noContainersMatchFilters")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.noContainersMatchFiltersHint")}
</p>
</div>
</div>
) : (

View File

@@ -42,7 +42,9 @@ export function ContainerStats({
const data = await getContainerStats(sessionId, containerId);
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : t("docker.failedToFetchStats"));
setError(
err instanceof Error ? err.message : t("docker.failedToFetchStats"),
);
} finally {
setIsLoading(false);
}
@@ -61,11 +63,11 @@ export function ContainerStats({
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<Activity className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400 text-lg">
<Activity className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground text-lg">
{t("docker.containerNotRunning")}
</p>
<p className="text-gray-500 text-sm">
<p className="text-muted-foreground text-sm">
{t("docker.startContainerToViewStats")}
</p>
</div>
@@ -78,7 +80,9 @@ export function ContainerStats({
<div className="flex items-center justify-center h-full">
<div className="text-center">
<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>
);
@@ -91,7 +95,7 @@ export function ContainerStats({
<p className="text-red-400 text-lg">
{t("docker.errorLoadingStats")}
</p>
<p className="text-gray-500 text-sm">{error}</p>
<p className="text-muted-foreground text-sm">{error}</p>
</div>
</div>
);
@@ -100,7 +104,7 @@ export function ContainerStats({
if (!stats) {
return (
<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>
);
}
@@ -121,7 +125,10 @@ export function ContainerStats({
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<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}
</span>
</div>
@@ -141,14 +148,18 @@ export function ContainerStats({
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">{t("docker.usedLimit")}</span>
<span className="font-mono font-semibold text-purple-300">
<span className="text-muted-foreground">
{t("docker.usedLimit")}
</span>
<span className="font-mono font-semibold text-purple-400">
{stats.memoryUsed} / {stats.memoryLimit}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">{t("docker.percentage")}</span>
<span className="font-mono text-purple-300">
<span className="text-muted-foreground">
{t("docker.percentage")}
</span>
<span className="font-mono text-purple-400">
{stats.memoryPercent}
</span>
</div>
@@ -168,12 +179,14 @@ export function ContainerStats({
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">{t("docker.input")}</span>
<span className="font-mono text-green-300">{stats.netInput}</span>
<span className="text-muted-foreground">{t("docker.input")}</span>
<span className="font-mono text-green-400">{stats.netInput}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">{t("docker.output")}</span>
<span className="font-mono text-green-300">
<span className="text-muted-foreground">
{t("docker.output")}
</span>
<span className="font-mono text-green-400">
{stats.netOutput}
</span>
</div>
@@ -192,21 +205,23 @@ export function ContainerStats({
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">{t("docker.read")}</span>
<span className="font-mono text-orange-300">
<span className="text-muted-foreground">{t("docker.read")}</span>
<span className="font-mono text-orange-400">
{stats.blockRead}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">{t("docker.write")}</span>
<span className="font-mono text-orange-300">
<span className="text-muted-foreground">{t("docker.write")}</span>
<span className="font-mono text-orange-400">
{stats.blockWrite}
</span>
</div>
{stats.pids && (
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">{t("docker.pids")}</span>
<span className="font-mono text-orange-300">{stats.pids}</span>
<span className="text-muted-foreground">
{t("docker.pids")}
</span>
<span className="font-mono text-orange-400">{stats.pids}</span>
</div>
)}
</div>
@@ -224,17 +239,17 @@ export function ContainerStats({
<CardContent className="px-4 pb-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="flex justify-between items-center">
<span className="text-gray-400">{t("docker.name")}</span>
<span className="font-mono text-gray-200">{containerName}</span>
<span className="text-muted-foreground">{t("docker.name")}</span>
<span className="font-mono text-foreground">{containerName}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">{t("docker.id")}</span>
<span className="font-mono text-sm text-gray-300">
<span className="text-muted-foreground">{t("docker.id")}</span>
<span className="font-mono text-sm text-foreground">
{containerId.substring(0, 12)}
</span>
</div>
<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">
{containerState}
</span>

View File

@@ -209,13 +209,13 @@ export function LogViewer({
{/* Search Filter */}
<div className="mt-2">
<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
type="text"
placeholder="Filter logs..."
value={searchFilter}
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>
@@ -231,9 +231,11 @@ export function LogViewer({
</div>
) : (
<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 || (
<span className="text-gray-500">No logs available</span>
<span className="text-muted-foreground">
No logs available
</span>
)}
<div ref={logsEndRef} />
</pre>

View File

@@ -477,7 +477,7 @@ export function ServerStats({
{(metricsEnabled && showStatsUI) ||
(currentHostConfig?.quickActions &&
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.length > 0 && (
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>

View File

@@ -30,8 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
}, [metricsHistory]);
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">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-foreground">

View File

@@ -27,8 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) {
}, [metrics]);
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">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-foreground">

View File

@@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
const uniqueIPs = loginStats?.uniqueIPs || 0;
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">
<UserCheck className="h-5 w-5 text-green-400" />
<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="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">
<Activity className="h-3 w-3" />
<span>{t("serverStats.totalLogins")}</span>
@@ -54,7 +54,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
{totalLogins}
</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">
<MapPin className="h-3 w-3" />
<span>{t("serverStats.uniqueIPs")}</span>
@@ -80,7 +80,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
{recentLogins.slice(0, 5).map((login, idx) => (
<div
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">
<span className="text-green-400 font-mono truncate">

View File

@@ -30,8 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
}, [metricsHistory]);
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">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-foreground">

View File

@@ -24,8 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
const interfaces = network?.interfaces || [];
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">
<Network className="h-5 w-5 text-indigo-400" />
<h3 className="font-semibold text-lg text-foreground">
@@ -43,7 +42,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
interfaces.map((iface, index: number) => (
<div
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 gap-2">

View File

@@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
const topProcesses = processes?.top || [];
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">
<List className="h-5 w-5 text-yellow-400" />
<h3 className="font-semibold text-lg text-foreground">
@@ -62,7 +62,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
{topProcesses.map((proc, index) => (
<div
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">
<span className="text-xs font-mono text-muted-foreground font-medium">

View File

@@ -21,8 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
const system = metricsWithSystem?.system;
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">
<Server className="h-5 w-5 text-purple-400" />
<h3 className="font-semibold text-lg text-foreground">

View File

@@ -20,8 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
const uptime = metricsWithUptime?.uptime;
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">
<Clock className="h-5 w-5 text-cyan-400" />
<h3 className="font-semibold text-lg text-foreground">

View File

@@ -247,7 +247,8 @@ export function SSHToolsSidebar({
tab.type === "terminal" ||
tab.type === "server" ||
tab.type === "file_manager" ||
tab.type === "user_profile",
tab.type === "tunnel" ||
tab.type === "docker",
);
useEffect(() => {
@@ -1246,7 +1247,7 @@ export function SSHToolsSidebar({
{terminalTabs.length > 0 && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
<label className="text-sm font-medium text-foreground">
{t("snippets.selectTerminals")}
</label>
<p className="text-xs text-muted-foreground">
@@ -1702,7 +1703,7 @@ export function SSHToolsSidebar({
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium text-white">
<label className="text-sm font-medium text-foreground">
{t("splitScreen.availableTabs")}
</label>
<p className="text-xs text-muted-foreground mb-2">
@@ -1745,7 +1746,7 @@ export function SSHToolsSidebar({
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium text-white">
<label className="text-sm font-medium text-foreground">
{t("splitScreen.layout")}
</label>
<div
@@ -1763,7 +1764,9 @@ export function SSHToolsSidebar({
const assignedTabId =
splitAssignments.get(idx);
const assignedTab = assignedTabId
? tabs.find((t) => t.id === assignedTabId)
? splittableTabs.find(
(t) => t.id === assignedTabId,
)
: null;
const isHovered = dragOverCellIndex === idx;
const isEmpty = !assignedTabId;
@@ -2050,7 +2053,7 @@ export function SSHToolsSidebar({
<div className="space-y-5">
<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")}
<span className="text-destructive">*</span>
</label>
@@ -2074,7 +2077,7 @@ export function SSHToolsSidebar({
</div>
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
<Label className="text-base font-semibold text-foreground">
{t("snippets.folderColor")}
</Label>
<div className="grid grid-cols-4 gap-3">
@@ -2101,7 +2104,7 @@ export function SSHToolsSidebar({
</div>
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
<Label className="text-base font-semibold text-foreground">
{t("snippets.folderIcon")}
</Label>
<div className="grid grid-cols-5 gap-3">
@@ -2126,7 +2129,7 @@ export function SSHToolsSidebar({
</div>
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
<Label className="text-base font-semibold text-foreground">
{t("snippets.preview")}
</Label>
<div className="flex items-center gap-3 p-4 rounded-md bg-elevated border border-edge">

View File

@@ -94,8 +94,8 @@ export function TunnelManager({
};
const containerClass = embedded
? "h-full w-full text-white overflow-hidden bg-transparent"
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
return (
<div style={wrapperStyle} className={containerClass}>

View File

@@ -322,7 +322,9 @@ export function AppView({
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} 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;
@@ -331,7 +333,9 @@ export function AppView({
<div
className="absolute inset-0 rounded-md overflow-hidden"
style={{
backgroundColor: isTerminal ? backgroundColor : "var(--bg-base)",
backgroundColor: isTerminal
? backgroundColor
: "var(--bg-base)",
}}
>
{t.type === "terminal" ? (
@@ -409,7 +413,7 @@ export function AppView({
const handleStyle = {
pointerEvents: "auto",
zIndex: 12,
background: "var(--color-border-base)",
background: isDarkMode ? "#303032" : "#e5e7eb",
} as React.CSSProperties;
const commonGroupProps: {
onLayout: () => void;
@@ -703,7 +707,9 @@ export function AppView({
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} 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;