v1.10.0 #471
@@ -1600,6 +1600,15 @@
|
||||
"commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history",
|
||||
"defaultSnippetFoldersCollapsed": "Collapse Snippet Folders by Default",
|
||||
"defaultSnippetFoldersCollapsedDesc": "When enabled, all snippet folders will be collapsed when you open the snippets tab",
|
||||
"showHostTags": "Show Host Tags",
|
||||
"showHostTagsDesc": "Display tags under each host in the sidebar. Disable to hide all tags.",
|
||||
"account": "Account",
|
||||
"appearance": "Appearance",
|
||||
"languageLocalization": "Language & Localization",
|
||||
"fileManagerSettings": "File Manager",
|
||||
"terminalSettings": "Terminal",
|
||||
"hostSidebarSettings": "Host & Sidebar",
|
||||
"snippetsSettings": "Snippets",
|
||||
"currentPassword": "Current Password",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
||||
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
|
||||
@@ -1642,7 +1651,7 @@
|
||||
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
|
||||
"enterPassword": "Enter your password",
|
||||
"totpCode": "6-digit TOTP code",
|
||||
"searchHostsAny": "Search hosts by any info...",
|
||||
"searchHostsAny": "Search hosts (try: tag:prod, user:root, ip:192.168)...",
|
||||
"confirmPassword": "Enter your password to confirm",
|
||||
"typeHere": "Type here",
|
||||
"fileName": "Enter file name (e.g., example.txt)",
|
||||
|
||||
@@ -616,23 +616,35 @@ export function Dashboard({
|
||||
{t("dashboard.noRecentActivity")}
|
||||
</p>
|
||||
) : (
|
||||
recentActivity.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg min-w-0"
|
||||
onClick={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
) : (
|
||||
<FolderOpen size={20} className="shrink-0" />
|
||||
)}
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{item.hostName}
|
||||
</p>
|
||||
</Button>
|
||||
))
|
||||
recentActivity
|
||||
.filter((item, index, array) => {
|
||||
// Always show the first item
|
||||
if (index === 0) return true;
|
||||
|
||||
// Show if different from previous item (by hostId and type)
|
||||
const prevItem = array[index - 1];
|
||||
return !(
|
||||
item.hostId === prevItem.hostId &&
|
||||
item.type === prevItem.type
|
||||
);
|
||||
})
|
||||
.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg min-w-0"
|
||||
onClick={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
) : (
|
||||
<FolderOpen size={20} className="shrink-0" />
|
||||
)}
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{item.hostName}
|
||||
</p>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -336,6 +336,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
|
||||
jumpHosts: currentHost.jumpHosts,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
@@ -774,6 +775,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword,
|
||||
credentialId: currentHost.credentialId,
|
||||
jumpHosts: currentHost.jumpHosts,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
@@ -1325,6 +1327,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
authType: currentHost.authType,
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
jumpHosts: currentHost.jumpHosts,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
@@ -1482,6 +1485,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
authType: credentials.password ? "password" : "key",
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
jumpHosts: currentHost.jumpHosts,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
|
||||
@@ -60,6 +60,15 @@ export function TerminalWindow({
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
// Trigger resize after maximize/restore
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (terminalRef.current?.fit) {
|
||||
terminalRef.current.fit();
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
|
||||
@@ -219,6 +219,7 @@ function QuickActionItem({
|
||||
placeholder={t("hosts.quickActionName")}
|
||||
value={quickAction.name}
|
||||
onChange={(e) => onUpdate(e.target.value, quickAction.snippetId)}
|
||||
onBlur={(e) => onUpdate(e.target.value.trim(), quickAction.snippetId)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
@@ -1196,6 +1197,10 @@ export function HostManagerEditor({
|
||||
field.ref(e);
|
||||
ipInputRef.current = e;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -1238,6 +1243,10 @@ export function HostManagerEditor({
|
||||
placeholder={t("placeholders.username")}
|
||||
disabled={shouldDisable}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -1259,6 +1268,10 @@ export function HostManagerEditor({
|
||||
<Input
|
||||
placeholder={t("placeholders.hostname")}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -1283,6 +1296,10 @@ export function HostManagerEditor({
|
||||
field.onChange(e);
|
||||
setFolderDropdownOpen(true);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{folderDropdownOpen && filteredFolders.length > 0 && (
|
||||
@@ -1878,6 +1895,10 @@ export function HostManagerEditor({
|
||||
<Input
|
||||
placeholder="proxy.example.com"
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -1927,6 +1948,10 @@ export function HostManagerEditor({
|
||||
<Input
|
||||
placeholder={t("hosts.username")}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -2047,6 +2072,23 @@ export function HostManagerEditor({
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const currentChain =
|
||||
form.watch(
|
||||
"socks5ProxyChain",
|
||||
) || [];
|
||||
const newChain = [
|
||||
...currentChain,
|
||||
];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
host: e.target.value.trim(),
|
||||
};
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2142,6 +2184,23 @@ export function HostManagerEditor({
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const currentChain =
|
||||
form.watch(
|
||||
"socks5ProxyChain",
|
||||
) || [];
|
||||
const newChain = [
|
||||
...currentChain,
|
||||
];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
username: e.target.value.trim(),
|
||||
};
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2694,6 +2753,10 @@ export function HostManagerEditor({
|
||||
<Input
|
||||
placeholder="mosh user@server"
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -2769,6 +2832,10 @@ export function HostManagerEditor({
|
||||
<Input
|
||||
placeholder="Variable name"
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -2780,7 +2847,14 @@ export function HostManagerEditor({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...field} />
|
||||
<Input
|
||||
placeholder="Value"
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -3057,6 +3131,10 @@ export function HostManagerEditor({
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
endpointHostField.onChange(e.target.value.trim());
|
||||
endpointHostField.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{sshConfigDropdownOpen[index] &&
|
||||
@@ -3244,6 +3322,10 @@ export function HostManagerEditor({
|
||||
<Input
|
||||
placeholder={t("placeholders.homePath")}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
@@ -48,8 +48,6 @@ import {
|
||||
Pencil,
|
||||
FolderMinus,
|
||||
Copy,
|
||||
Activity,
|
||||
Clock,
|
||||
Palette,
|
||||
Trash,
|
||||
Cloud,
|
||||
@@ -63,6 +61,8 @@ import {
|
||||
FolderOpen,
|
||||
Share2,
|
||||
Users,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
@@ -583,46 +583,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const getMonitoringStatus = (host: SSHHost) => {
|
||||
try {
|
||||
const statsConfig = host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
|
||||
const formatInterval = (seconds: number): string => {
|
||||
if (seconds >= 60) {
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const statusEnabled = statsConfig.statusCheckEnabled !== false;
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
const statusInterval = statusEnabled
|
||||
? formatInterval(statsConfig.statusCheckInterval || 30)
|
||||
: null;
|
||||
const metricsInterval = metricsEnabled
|
||||
? formatInterval(statsConfig.metricsInterval || 30)
|
||||
: null;
|
||||
|
||||
return {
|
||||
statusEnabled,
|
||||
metricsEnabled,
|
||||
statusInterval,
|
||||
metricsInterval,
|
||||
bothDisabled: !statusEnabled && !metricsEnabled,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
statusEnabled: true,
|
||||
metricsEnabled: true,
|
||||
statusInterval: "30s",
|
||||
metricsInterval: "30s",
|
||||
bothDisabled: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAndSortedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
@@ -1419,48 +1379,15 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{t("hosts.fileManagerBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const monitoringStatus =
|
||||
getMonitoringStatus(host);
|
||||
|
||||
if (monitoringStatus.bothDisabled) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0 text-muted-foreground"
|
||||
>
|
||||
<Activity className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.monitoringDisabledBadge")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{monitoringStatus.statusEnabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Activity className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.statusMonitoring")}:{" "}
|
||||
{monitoringStatus.statusInterval}
|
||||
</Badge>
|
||||
)}
|
||||
{monitoringStatus.metricsEnabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Clock className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.metricsMonitoring")}:{" "}
|
||||
{monitoringStatus.metricsInterval}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{host.enableDocker && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Container className="h-2 w-2 mr-0.5" />
|
||||
Docker
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1519,6 +1446,60 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "tunnel",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-orange-500/10 hover:border-orange-500/50 flex-1"
|
||||
>
|
||||
<ArrowDownUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Tunnels</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableDocker && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "docker",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-cyan-500/10 hover:border-cyan-500/50 flex-1"
|
||||
>
|
||||
<Container className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Docker</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -363,20 +363,78 @@ export function LeftSidebar({
|
||||
|
||||
const filteredHosts = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
const searchQuery = debouncedSearch.trim().toLowerCase();
|
||||
|
||||
return hosts.filter((h) => {
|
||||
const searchableText = [
|
||||
h.name || "",
|
||||
h.username,
|
||||
h.ip,
|
||||
h.folder || "",
|
||||
...(h.tags || []),
|
||||
h.authType,
|
||||
h.defaultPath || "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchableText.includes(q);
|
||||
// Check for field-specific search patterns
|
||||
const fieldMatches: Record<string, string> = {};
|
||||
let remainingQuery = searchQuery;
|
||||
|
||||
// Extract field-specific queries (e.g., "tag:production", "user:root", "ip:192.168")
|
||||
const fieldPattern = /(\w+):([^\s]+)/g;
|
||||
let match;
|
||||
while ((match = fieldPattern.exec(searchQuery)) !== null) {
|
||||
const [fullMatch, field, value] = match;
|
||||
fieldMatches[field] = value;
|
||||
remainingQuery = remainingQuery.replace(fullMatch, "").trim();
|
||||
}
|
||||
|
||||
// Handle field-specific searches
|
||||
for (const [field, value] of Object.entries(fieldMatches)) {
|
||||
switch (field) {
|
||||
case "tag":
|
||||
case "tags":
|
||||
const tags = Array.isArray(h.tags) ? h.tags : [];
|
||||
const hasMatchingTag = tags.some((tag) =>
|
||||
tag.toLowerCase().includes(value)
|
||||
);
|
||||
if (!hasMatchingTag) return false;
|
||||
break;
|
||||
case "name":
|
||||
if (!(h.name || "").toLowerCase().includes(value)) return false;
|
||||
break;
|
||||
case "user":
|
||||
case "username":
|
||||
if (!h.username.toLowerCase().includes(value)) return false;
|
||||
break;
|
||||
case "ip":
|
||||
case "host":
|
||||
if (!h.ip.toLowerCase().includes(value)) return false;
|
||||
break;
|
||||
case "port":
|
||||
if (!String(h.port).includes(value)) return false;
|
||||
break;
|
||||
case "folder":
|
||||
if (!(h.folder || "").toLowerCase().includes(value)) return false;
|
||||
break;
|
||||
case "auth":
|
||||
case "authtype":
|
||||
if (!h.authType.toLowerCase().includes(value)) return false;
|
||||
break;
|
||||
case "path":
|
||||
if (!(h.defaultPath || "").toLowerCase().includes(value))
|
||||
return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If there's remaining query text (not field-specific), search across all fields
|
||||
if (remainingQuery) {
|
||||
const searchableText = [
|
||||
h.name || "",
|
||||
h.username,
|
||||
h.ip,
|
||||
h.folder || "",
|
||||
...(h.tags || []),
|
||||
h.authType,
|
||||
h.defaultPath || "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!searchableText.includes(remainingQuery)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [hosts, debouncedSearch]);
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
const [serverStatus, setServerStatus] = useState<
|
||||
"online" | "offline" | "degraded"
|
||||
>("degraded");
|
||||
const [showTags, setShowTags] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem("showHostTags");
|
||||
return saved !== null ? saved === "true" : true;
|
||||
});
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
|
||||
@@ -54,6 +58,17 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [host.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShowTagsChanged = () => {
|
||||
const saved = localStorage.getItem("showHostTags");
|
||||
setShowTags(saved !== null ? saved === "true" : true);
|
||||
};
|
||||
|
||||
window.addEventListener("showHostTagsChanged", handleShowTagsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("showHostTagsChanged", handleShowTagsChanged);
|
||||
}, []);
|
||||
|
||||
const statsConfig = useMemo(() => {
|
||||
try {
|
||||
return host.statsConfig
|
||||
@@ -216,8 +231,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
{hasTags && (
|
||||
{showTags && hasTags && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{tags.map((tag: string) => (
|
||||
<div
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { User, Shield, AlertCircle } from "lucide-react";
|
||||
import { User, Shield, AlertCircle, Palette } from "lucide-react";
|
||||
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
|
||||
import {
|
||||
getUserInfo,
|
||||
@@ -107,6 +107,10 @@ export function UserProfile({
|
||||
useState<boolean>(
|
||||
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
||||
);
|
||||
const [showHostTags, setShowHostTags] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem("showHostTags");
|
||||
return saved !== null ? saved === "true" : true;
|
||||
});
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -176,6 +180,12 @@ export function UserProfile({
|
||||
window.dispatchEvent(new Event("defaultSnippetFoldersCollapsedChanged"));
|
||||
};
|
||||
|
||||
const handleShowHostTagsToggle = (enabled: boolean) => {
|
||||
setShowHostTags(enabled);
|
||||
localStorage.setItem("showHostTags", enabled.toString());
|
||||
window.dispatchEvent(new Event("showHostTagsChanged"));
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
@@ -285,7 +295,14 @@ export function UserProfile({
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
{t("nav.userProfile")}
|
||||
{t("profile.account")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="appearance"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
<Palette className="w-4 h-4" />
|
||||
{t("profile.appearance")}
|
||||
</TabsTrigger>
|
||||
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
|
||||
<TabsTrigger
|
||||
@@ -380,73 +397,6 @@ export function UserProfile({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.selectPreferredLanguage")}
|
||||
</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.fileColorCoding")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.fileColorCodingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={fileColorCoding}
|
||||
onCheckedChange={handleFileColorCodingToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.commandAutocomplete")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.commandAutocompleteDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandAutocomplete}
|
||||
onCheckedChange={handleCommandAutocompleteToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.defaultSnippetFoldersCollapsed")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.defaultSnippetFoldersCollapsedDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={defaultSnippetFoldersCollapsed}
|
||||
onCheckedChange={
|
||||
handleDefaultSnippetFoldersCollapsedToggle
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -471,6 +421,122 @@ export function UserProfile({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="appearance" className="space-y-4">
|
||||
{/* Language & Localization Section */}
|
||||
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.languageLocalization")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.selectPreferredLanguage")}
|
||||
</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Manager Section */}
|
||||
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.fileManagerSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.fileColorCoding")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.fileColorCodingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={fileColorCoding}
|
||||
onCheckedChange={handleFileColorCodingToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Section */}
|
||||
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.terminalSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.commandAutocomplete")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.commandAutocompleteDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandAutocomplete}
|
||||
onCheckedChange={handleCommandAutocompleteToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host & Sidebar Section */}
|
||||
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.hostSidebarSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.showHostTags")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.showHostTagsDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={showHostTags}
|
||||
onCheckedChange={handleShowHostTagsToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snippets Section */}
|
||||
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.snippetsSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.defaultSnippetFoldersCollapsed")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.defaultSnippetFoldersCollapsedDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={defaultSnippetFoldersCollapsed}
|
||||
onCheckedChange={
|
||||
handleDefaultSnippetFoldersCollapsedToggle
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<TOTPSetup
|
||||
isEnabled={userInfo.totp_enabled}
|
||||
|
||||
Reference in New Issue
Block a user