diff --git a/src/backend/ssh/docker-console.ts b/src/backend/ssh/docker-console.ts index 3cc28394..45b3eab1 100644 --- a/src/backend/ssh/docker-console.ts +++ b/src/backend/ssh/docker-console.ts @@ -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((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, }); }, ); diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 113d66c7..5b8522b8 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
(null); const [users, setUsers] = React.useState< Array<{ @@ -137,7 +83,6 @@ export function AdminSettings({ password_hash?: string; } | null>(null); - const [securityInitialized, setSecurityInitialized] = React.useState(true); const [currentUser, setCurrentUser] = React.useState<{ id: string; username: string; @@ -145,13 +90,6 @@ export function AdminSettings({ is_oidc: boolean; } | null>(null); - const [exportLoading, setExportLoading] = React.useState(false); - const [importLoading, setImportLoading] = React.useState(false); - const [importFile, setImportFile] = React.useState(null); - const [exportPassword, setExportPassword] = React.useState(""); - const [showPasswordInput, setShowPasswordInput] = React.useState(false); - const [importPassword, setImportPassword] = React.useState(""); - const [sessions, setSessions] = React.useState< Array<{ id: string; @@ -173,13 +111,6 @@ export function AdminSettings({ id: string; username: string; } | null>(null); - const [linkTargetUsername, setLinkTargetUsername] = React.useState(""); - const [linkLoading, setLinkLoading] = React.useState(false); - - const requiresImportPassword = React.useMemo( - () => !currentUser?.is_oidc, - [currentUser?.is_oidc], - ); React.useEffect(() => { if (isElectron()) { @@ -302,299 +233,6 @@ export function AdminSettings({ setSelectedUserForEdit(null); }; - const getAuthTypeDisplay = (user: (typeof users)[0]): 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 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); - } - }; - - 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 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", - ); - }; - - 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); - } - }; - const fetchSessions = async () => { if (isElectron()) { const serverUrl = (window as { configuredServerUrl?: string }) @@ -617,94 +255,14 @@ export function AdminSettings({ } }; - 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) => { - const isCurrentUser = currentUser?.id === userId; - - confirmWithToast( - t("admin.confirmRevokeAllSessions"), - async () => { - try { - const data = await revokeAllUserSessions(userId); - toast.success(data.message || t("admin.sessionsRevokedSuccessfully")); - - if (isCurrentUser) { - setTimeout(() => { - window.location.reload(); - }, 1000); - } else { - fetchSessions(); - } - } catch { - toast.error(t("admin.failedToRevokeSessions")); - } - }, - "destructive", - ); - }; - const handleLinkOIDCUser = (user: { id: string; username: string }) => { setLinkOidcUser(user); - setLinkTargetUsername(""); setLinkAccountAlertOpen(true); }; - const handleLinkSubmit = async () => { - if (!linkOidcUser || !linkTargetUsername.trim()) { - toast.error("Target username is required"); - return; - } - - setLinkLoading(true); - try { - const result = await linkOIDCToPasswordAccount( - linkOidcUser.id, - linkTargetUsername.trim(), - ); - - toast.success( - result.message || - `OIDC user ${linkOidcUser.username} linked to ${linkTargetUsername}`, - ); - setLinkAccountAlertOpen(false); - setLinkTargetUsername(""); - setLinkOidcUser(null); - fetchUsers(); - fetchSessions(); - } 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); - } + const handleLinkSuccess = () => { + fetchUsers(); + fetchSessions(); }; const handleUnlinkOIDC = async (userId: string, username: string) => { @@ -806,747 +364,58 @@ export function AdminSettings({ -
-

- {t("admin.userRegistration")} -

- - -
+
-
-

- {t("admin.externalAuthentication")} -

-
-

- {t("admin.configureExternalProvider")} -

- -
- - {!allowPasswordLogin && ( - - {t("admin.criticalWarning")} - - {t("admin.oidcRequiredWarning")} - - - )} - - {oidcError && ( - - {t("common.error")} - {oidcError} - - )} - -
-
- - - handleOIDCConfigChange("client_id", e.target.value) - } - placeholder={t("placeholders.clientId")} - required - /> -
-
- - - handleOIDCConfigChange("client_secret", e.target.value) - } - placeholder={t("placeholders.clientSecret")} - required - /> -
-
- - - handleOIDCConfigChange( - "authorization_url", - e.target.value, - ) - } - placeholder={t("placeholders.authUrl")} - required - /> -
-
- - - handleOIDCConfigChange("issuer_url", e.target.value) - } - placeholder={t("placeholders.redirectUrl")} - required - /> -
-
- - - handleOIDCConfigChange("token_url", e.target.value) - } - placeholder={t("placeholders.tokenUrl")} - required - /> -
-
- - - handleOIDCConfigChange( - "identifier_path", - e.target.value, - ) - } - placeholder={t("placeholders.userIdField")} - required - /> -
-
- - - handleOIDCConfigChange("name_path", e.target.value) - } - placeholder={t("placeholders.usernameField")} - required - /> -
-
- - - handleOIDCConfigChange("scopes", e.target.value) - } - placeholder={t("placeholders.scopes")} - required - /> -
-
- - - handleOIDCConfigChange("userinfo_url", e.target.value) - } - placeholder="https://your-provider.com/application/o/userinfo/" - /> -
-
- - -
-
-
+
-
-
-

- {t("admin.userManagement")} -

-
- {allowPasswordLogin && ( - - )} - -
-
- {usersLoading ? ( -
- {t("admin.loadingUsers")} -
- ) : ( - - - - {t("admin.username")} - {t("admin.authType")} - {t("admin.actions")} - - - - {users.map((user) => ( - - - {user.username} - {user.is_admin && ( - - {t("admin.adminBadge")} - - )} - - {getAuthTypeDisplay(user)} - -
- - {user.is_oidc && !user.password_hash && ( - - )} - {user.is_oidc && user.password_hash && ( - - )} - -
-
-
- ))} -
-
- )} -
+ setCreateUserDialogOpen(true)} + onEditUser={handleEditUser} + onLinkOIDCUser={handleLinkOIDCUser} + onUnlinkOIDC={handleUnlinkOIDC} + />
-
-
-

- {t("admin.sessionManagement")} -

- -
- {sessionsLoading ? ( -
- {t("admin.loadingSessions")} -
- ) : sessions.length === 0 ? ( -
- {t("admin.noActiveSessions")} -
- ) : ( - - - - {t("admin.device")} - {t("admin.user")} - {t("admin.created")} - {t("admin.lastActive")} - {t("admin.expires")} - {t("admin.actions")} - - - - {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); - - const formatDate = (date: Date) => - date.toLocaleDateString() + - " " + - date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - - return ( - - -
- -
- - {session.deviceInfo} - - {session.isRevoked && ( - - {t("admin.revoked")} - - )} -
-
-
- - {session.username || session.userId} - - - {formatDate(createdDate)} - - - {formatDate(lastActiveDate)} - - - {formatDate(expiresDate)} - - -
- - {session.username && ( - - )} -
-
-
- ); - })} -
-
- )} -
+
- +
-
-

- {t("admin.databaseSecurity")} -

- -
-
-
-
- -

{t("admin.export")}

-
-

- {t("admin.exportDescription")} -

- {showPasswordInput && ( -
- - setExportPassword(e.target.value)} - placeholder="Enter your password" - onKeyDown={(e) => { - if (e.key === "Enter") { - handleExportDatabase(); - } - }} - /> -
- )} - - {showPasswordInput && ( - - )} -
-
- -
-
-
- -

{t("admin.import")}

-
-

- {t("admin.importDescription")} -

-
- - setImportFile(e.target.files?.[0] || null) - } - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
- {importFile && requiresImportPassword && ( -
- - setImportPassword(e.target.value)} - placeholder="Enter your password" - onKeyDown={(e) => { - if (e.key === "Enter") { - handleImportDatabase(); - } - }} - /> -
- )} - -
-
-
-
+
- {linkAccountAlertOpen && ( - - - - - - {t("admin.linkOidcToPasswordAccount")} - {" "} - - {t("admin.linkOidcToPasswordAccountDescription", { - username: linkOidcUser?.username, - })} - - - -
- - {t("admin.linkOidcWarningTitle")} - - {t("admin.linkOidcWarningDescription")} -
    -
  • {t("admin.linkOidcActionDeleteUser")}
  • -
  • {t("admin.linkOidcActionAddCapability")}
  • -
  • {t("admin.linkOidcActionDualAuth")}
  • -
-
-
- -
- - setLinkTargetUsername(e.target.value)} - placeholder={t("admin.linkTargetUsernamePlaceholder")} - disabled={linkLoading} - onKeyDown={(e) => { - if (e.key === "Enter" && linkTargetUsername.trim()) { - handleLinkSubmit(); - } - }} - /> -
-
- - - - - -
-
- )} - - {/* New User Management Dialogs */} + {/* Dialogs */} + + ); } diff --git a/src/ui/desktop/admin/CreateUserDialog.tsx b/src/ui/desktop/admin/dialogs/CreateUserDialog.tsx similarity index 91% rename from src/ui/desktop/admin/CreateUserDialog.tsx rename to src/ui/desktop/admin/dialogs/CreateUserDialog.tsx index 85afb890..6f3e9cb7 100644 --- a/src/ui/desktop/admin/CreateUserDialog.tsx +++ b/src/ui/desktop/admin/dialogs/CreateUserDialog.tsx @@ -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({ } }} > - + diff --git a/src/ui/desktop/admin/dialogs/LinkAccountDialog.tsx b/src/ui/desktop/admin/dialogs/LinkAccountDialog.tsx new file mode 100644 index 00000000..6e285748 --- /dev/null +++ b/src/ui/desktop/admin/dialogs/LinkAccountDialog.tsx @@ -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 ( + + + + + + {t("admin.linkOidcToPasswordAccount")} + + + {t("admin.linkOidcToPasswordAccountDescription", { + username: oidcUser?.username, + })} + + + +
+ + {t("admin.linkOidcWarningTitle")} + + {t("admin.linkOidcWarningDescription")} +
    +
  • {t("admin.linkOidcActionDeleteUser")}
  • +
  • {t("admin.linkOidcActionAddCapability")}
  • +
  • {t("admin.linkOidcActionDualAuth")}
  • +
+
+
+ +
+ + setLinkTargetUsername(e.target.value)} + placeholder={t("admin.linkTargetUsernamePlaceholder")} + disabled={linkLoading} + onKeyDown={(e) => { + if (e.key === "Enter" && linkTargetUsername.trim()) { + handleLinkSubmit(); + } + }} + /> +
+
+ + + + + +
+
+ ); +} diff --git a/src/ui/desktop/admin/UserEditDialog.tsx b/src/ui/desktop/admin/dialogs/UserEditDialog.tsx similarity index 95% rename from src/ui/desktop/admin/UserEditDialog.tsx rename to src/ui/desktop/admin/dialogs/UserEditDialog.tsx index eb1284d7..c01d4edf 100644 --- a/src/ui/desktop/admin/UserEditDialog.tsx +++ b/src/ui/desktop/admin/dialogs/UserEditDialog.tsx @@ -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 ( - + @@ -367,7 +367,7 @@ export function UserEditDialog({
{/* READ-ONLY INFO SECTION */} -
+
-
+

{t("admin.administratorRole")}

@@ -487,7 +487,7 @@ export function UserEditDialog({ {userRoles.map((role) => (

@@ -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" > @@ -566,7 +566,7 @@ export function UserEditDialog({ {t("admin.sessionManagement")} -

+

{t("admin.revokeAllSessions")} diff --git a/src/ui/desktop/admin/widgets/DatabaseSecurityTab.tsx b/src/ui/desktop/admin/widgets/DatabaseSecurityTab.tsx new file mode 100644 index 00000000..e9b1a60f --- /dev/null +++ b/src/ui/desktop/admin/widgets/DatabaseSecurityTab.tsx @@ -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(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 ( +

+

{t("admin.databaseSecurity")}

+ +
+
+
+
+ +

{t("admin.export")}

+
+

+ {t("admin.exportDescription")} +

+ {showPasswordInput && ( +
+ + setExportPassword(e.target.value)} + placeholder="Enter your password" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleExportDatabase(); + } + }} + /> +
+ )} + + {showPasswordInput && ( + + )} +
+
+ +
+
+
+ +

{t("admin.import")}

+
+

+ {t("admin.importDescription")} +

+
+ setImportFile(e.target.files?.[0] || null)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+ {importFile && requiresImportPassword && ( +
+ + setImportPassword(e.target.value)} + placeholder="Enter your password" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleImportDatabase(); + } + }} + /> +
+ )} + +
+
+
+
+ ); +} diff --git a/src/ui/desktop/admin/widgets/GeneralSettingsTab.tsx b/src/ui/desktop/admin/widgets/GeneralSettingsTab.tsx new file mode 100644 index 00000000..51e9fdc2 --- /dev/null +++ b/src/ui/desktop/admin/widgets/GeneralSettingsTab.tsx @@ -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 ( +
+

{t("admin.userRegistration")}

+ + +
+ ); +} diff --git a/src/ui/desktop/admin/widgets/OIDCSettingsTab.tsx b/src/ui/desktop/admin/widgets/OIDCSettingsTab.tsx new file mode 100644 index 00000000..33506f97 --- /dev/null +++ b/src/ui/desktop/admin/widgets/OIDCSettingsTab.tsx @@ -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(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 ( +
+

+ {t("admin.externalAuthentication")} +

+
+

+ {t("admin.configureExternalProvider")} +

+ +
+ + {!allowPasswordLogin && ( + + {t("admin.criticalWarning")} + {t("admin.oidcRequiredWarning")} + + )} + + {oidcError && ( + + {t("common.error")} + {oidcError} + + )} + +
+
+ + + handleOIDCConfigChange("client_id", e.target.value) + } + placeholder={t("placeholders.clientId")} + required + /> +
+
+ + + handleOIDCConfigChange("client_secret", e.target.value) + } + placeholder={t("placeholders.clientSecret")} + required + /> +
+
+ + + handleOIDCConfigChange("authorization_url", e.target.value) + } + placeholder={t("placeholders.authUrl")} + required + /> +
+
+ + + handleOIDCConfigChange("issuer_url", e.target.value) + } + placeholder={t("placeholders.redirectUrl")} + required + /> +
+
+ + + handleOIDCConfigChange("token_url", e.target.value) + } + placeholder={t("placeholders.tokenUrl")} + required + /> +
+
+ + + handleOIDCConfigChange("identifier_path", e.target.value) + } + placeholder={t("placeholders.userIdField")} + required + /> +
+
+ + + handleOIDCConfigChange("name_path", e.target.value) + } + placeholder={t("placeholders.usernameField")} + required + /> +
+
+ + handleOIDCConfigChange("scopes", e.target.value)} + placeholder={t("placeholders.scopes")} + required + /> +
+
+ + + handleOIDCConfigChange("userinfo_url", e.target.value) + } + placeholder="https://your-provider.com/application/o/userinfo/" + /> +
+
+ + +
+
+
+ ); +} diff --git a/src/ui/desktop/admin/RoleManagement.tsx b/src/ui/desktop/admin/widgets/RolesTab.tsx similarity index 98% rename from src/ui/desktop/admin/RoleManagement.tsx rename to src/ui/desktop/admin/widgets/RolesTab.tsx index 681b16f1..ffa9c324 100644 --- a/src/ui/desktop/admin/RoleManagement.tsx +++ b/src/ui/desktop/admin/widgets/RolesTab.tsx @@ -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 */} - + {editingRole ? t("rbac.editRole") : t("rbac.createRole")} diff --git a/src/ui/desktop/admin/widgets/SessionManagementTab.tsx b/src/ui/desktop/admin/widgets/SessionManagementTab.tsx new file mode 100644 index 00000000..95bd0953 --- /dev/null +++ b/src/ui/desktop/admin/widgets/SessionManagementTab.tsx @@ -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 ( +
+
+

+ {t("admin.sessionManagement")} +

+ +
+ {sessionsLoading ? ( +
+ {t("admin.loadingSessions")} +
+ ) : sessions.length === 0 ? ( +
+ {t("admin.noActiveSessions")} +
+ ) : ( + + + + {t("admin.device")} + {t("admin.user")} + {t("admin.created")} + {t("admin.lastActive")} + {t("admin.expires")} + {t("admin.actions")} + + + + {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 ( + + +
+ +
+ + {session.deviceInfo} + + {session.isRevoked && ( + + {t("admin.revoked")} + + )} +
+
+
+ + {session.username || session.userId} + + + {formatDate(createdDate)} + + + {formatDate(lastActiveDate)} + + + {formatDate(expiresDate)} + + +
+ + {session.username && ( + + )} +
+
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/ui/desktop/admin/widgets/UserManagementTab.tsx b/src/ui/desktop/admin/widgets/UserManagementTab.tsx new file mode 100644 index 00000000..18c266a8 --- /dev/null +++ b/src/ui/desktop/admin/widgets/UserManagementTab.tsx @@ -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 ( +
+
+

{t("admin.userManagement")}

+
+ {allowPasswordLogin && ( + + )} + +
+
+ {usersLoading ? ( +
+ {t("admin.loadingUsers")} +
+ ) : ( + + + + {t("admin.username")} + {t("admin.authType")} + {t("admin.actions")} + + + + {users.map((user) => ( + + + {user.username} + {user.is_admin && ( + + {t("admin.adminBadge")} + + )} + + {getAuthTypeDisplay(user)} + +
+ + {user.is_oidc && !user.password_hash && ( + + )} + {user.is_oidc && user.password_hash && ( + + )} + +
+
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index de9c7dc4..1dd53977 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -239,7 +239,7 @@ export function CommandPalette({ > - + {t("credentials.general")} - + {t("credentials.authentication")} @@ -693,10 +699,16 @@ export function CredentialEditor({ className="flex-1 flex flex-col h-full min-h-0" > - + {t("credentials.password")} - + {t("credentials.key")} @@ -719,7 +731,7 @@ export function CredentialEditor({
-
+
{t("credentials.generateKeyPair")} @@ -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)", }, }), ]} diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 441f1a8d..215d9cc9 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -407,7 +407,7 @@ export function Dashboard({

@@ -281,7 +279,7 @@ export function DockerManager({
-

+

{isValidating ? t("docker.validating") : t("docker.connectingToHost")} @@ -338,7 +336,7 @@ export function DockerManager({ {currentHostConfig?.folder} / {title} {dockerValidation?.version && ( -

+

{t("docker.version", { version: dockerValidation.version })}

)} @@ -363,7 +361,7 @@ export function DockerManager({ /> ) : (
-

No session available

+

No session available

)}
@@ -377,7 +375,7 @@ export function DockerManager({ /> ) : (
-

+

Select a container to view details

diff --git a/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx b/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx index 9e72206d..7fa4ce83 100644 --- a/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx +++ b/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx @@ -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 (
- -

{t("docker.containerNotRunning")}

-

+ +

+ {t("docker.containerNotRunning")} +

+

{t("docker.startContainerToAccess")}

@@ -359,7 +379,9 @@ export function ConsoleTerminal({
- {t("docker.console")} + + {t("docker.console")} +
- + 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" />
@@ -231,9 +231,11 @@ export function LogViewer({
) : (
-
+              
                 {filteredLogs || (
-                  No logs available
+                  
+                    No logs available
+                  
                 )}
                 
diff --git a/src/ui/desktop/apps/server-stats/ServerStats.tsx b/src/ui/desktop/apps/server-stats/ServerStats.tsx index fe6f37e4..ab0bc813 100644 --- a/src/ui/desktop/apps/server-stats/ServerStats.tsx +++ b/src/ui/desktop/apps/server-stats/ServerStats.tsx @@ -477,7 +477,7 @@ export function ServerStats({ {(metricsEnabled && showStatsUI) || (currentHostConfig?.quickActions && currentHostConfig.quickActions.length > 0) ? ( -
+
{currentHostConfig?.quickActions && currentHostConfig.quickActions.length > 0 && (
diff --git a/src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx index 788e2343..acf27a7e 100644 --- a/src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx @@ -30,8 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) { }, [metricsHistory]); return ( -
- +

diff --git a/src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx index e188e9fe..a67efa72 100644 --- a/src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx @@ -27,8 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) { }, [metrics]); return ( -
- +

diff --git a/src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx index 65c747e7..fe2ea698 100644 --- a/src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx @@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) { const uniqueIPs = loginStats?.uniqueIPs || 0; return ( -
+

@@ -45,7 +45,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
-
+
{t("serverStats.totalLogins")} @@ -54,7 +54,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) { {totalLogins}
-
+
{t("serverStats.uniqueIPs")} @@ -80,7 +80,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) { {recentLogins.slice(0, 5).map((login, idx) => (
diff --git a/src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx index 05b241ca..e022555b 100644 --- a/src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx @@ -30,8 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) { }, [metricsHistory]); return ( -
- +

diff --git a/src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx index 89ad4777..00299ee6 100644 --- a/src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx @@ -24,8 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) { const interfaces = network?.interfaces || []; return ( -
- +

@@ -43,7 +42,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) { interfaces.map((iface, index: number) => (
diff --git a/src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx index 3e95decc..7b00d2a6 100644 --- a/src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx @@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) { const topProcesses = processes?.top || []; return ( -
+

@@ -62,7 +62,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) { {topProcesses.map((proc, index) => (
diff --git a/src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx index e0a65d6a..1f598484 100644 --- a/src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx @@ -21,8 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) { const system = metricsWithSystem?.system; return ( -
- +

diff --git a/src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx index a0c669bc..bb1bfdbf 100644 --- a/src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx @@ -20,8 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) { const uptime = metricsWithUptime?.uptime; return ( -
- +

diff --git a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx index 96ef9cec..35986bfc 100644 --- a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx +++ b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx @@ -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 && ( <>
-