mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
v2.3.2 (#874)
* fix: patch critical security vulnerabilities (GHSA-5fqh, GHSA-ccm8, GHSA-wqfw, GHSA-xmjh) - Remove passwordHash from /users/list API response - Require both password and TOTP code for MFA-critical operations - Restrict tunnel kill commands to tunnelMarker-only matching - Add session ownership middleware for file manager endpoints * fix: allow navigating away from split-view to non-pane tabs Show the normal view container on top of the split view when the active tab is not assigned to any pane, so users can switch to dashboard or other tabs while split mode is active. Closes #739 * fix: add inline quick-action buttons on host name row Show Terminal, Files, RDP, and VNC shortcut icons on the host name row on hover, so users can launch connections with a single click without expanding the full action tray. Closes #736 * fix: restore SSH keepalive interval to 30s to prevent random disconnects Revert keepalive defaults from 60s/5 to 30s/3 across terminal, tunnel, and server-stats SSH connections. The 60s interval introduced in 2.3.0 causes firewalls and NAT devices to drop idle connections before the next keepalive probe. Closes #733 * fix: apply guacamole-lite protocol patch in Docker builds The Dockerfile uses --ignore-scripts which skips the postinstall hook that patches guacamole-lite for guacd 1.6.0 protocol VERSION_1_5_0. Without this patch, the timezone handshake instruction is not sent for protocol versions above 1.1.0, causing VNC connections to fail immediately on connect. Closes #734 * fix: show correct icons for network interface types Detect interface type from name pattern and show appropriate icons: WiFi for wlan/wl*, Ethernet (Cable) for eth/en*, Container for docker/bridge/virtual, generic Network for others. Closes #720 * fix: resolve sudo password for shared host users The password endpoint required hosts.userId to match the requesting user, which fails for shared hosts. Now falls back to decrypting with the owner's key when the requesting user doesn't own the host. Closes #717 * fix: use jump hosts for online status check and metrics collection Status polling now pings the first jump host instead of the unreachable target when jump hosts are configured. The /metrics/start endpoint now tunnels through the jump host chain to reach the target host. Closes #716 * fix: broaden sudo prompt detection for newer distros Add patterns for 'password for <user>:' and bare 'Password:' prompts in addition to the existing [sudo] and sudo: patterns. Covers Ubuntu 26.04 and other distros that use different sudo prompt formats. Closes #718 * fix: recalculate terminal layout after web fonts load xterm.js measures character widths at open() time. If custom fonts haven't loaded yet, measurements use the fallback font and spacing becomes incorrect. Now refresh and re-fit the terminal once document.fonts.ready resolves. Closes #710 * fix: improve terminal cwd detection and initial directory command Remove '&& pwd' from initial directory command — the shell prompt shows the new directory naturally. Fixes PowerShell 5.1 which doesn't support '&&' as a statement separator. Prepend Ctrl+U to get_cwd command to clear any pending input before injecting the cwd probe, reducing interference with foreground programs. Closes #713, #714 * fix: decode base64 file content as UTF-8 in file manager Replace bare atob() with TextDecoder('utf-8') for base64 content decoding. atob() only handles Latin-1, so multi-byte UTF-8 characters like 'é' were decoded as 'é'. Closes #719 * fix: normalize lazy import default exports for iOS compatibility Wrap all lazy() imports with explicit .then(m => ({ default: m.default })) to ensure consistent module resolution across platforms. iOS Safari/WebView may handle bare lazy(() => import(...)) differently, returning the module object instead of extracting the default export. Closes #721 * fix: prevent RDP display from snapping back after container resize Remove immediate rescaleDisplay() from ResizeObserver callback. The display.onresize event already triggers rescaling when the RDP server responds with the new resolution. Calling rescaleDisplay before the server responds uses stale display dimensions, causing the bottom of the screen to be truncated. Closes #725 * fix: add portal Desktop DBus permission for Flatpak URL opening Flatpak sandbox blocks window.open() without the portal permission, causing terminal link clicks to open about:blank. Add talk-name for org.freedesktop.portal.Desktop to enable xdg-desktop-portal URL handling. Closes #704 * chore: remove unused code and fix PR checks (#851) * chore: remove unused frontend code * chore: prune unused theme exports * ci: fix pr check failures * chore: reduce lint warnings * feat(oidc): expose admin_group via OIDC_ADMIN_GROUP env var (#828) The admin-group OIDC sync added in 2.3.0 (#782) reads `config.admin_group` to sync the user's admin flag from OIDC group membership on each login. That field is only populated when the OIDC config is stored in the in-app DB — `getOIDCConfigFromEnv()` does not expose it, so deployments using the env-var config path (declarative IaC: Helm/Compose/Puppet) cannot enable the feature without abandoning env vars and pasting the client_secret into the admin UI. Add `admin_group: process.env.OIDC_ADMIN_GROUP || ""` to the env-config return type and object. Backward compatible: when unset, the existing `if (config.admin_group)` guard at users.ts:1336 keeps the sync block skipped, matching today's behavior. * chore: reduce explicit-any warnings * chore: reduce more explicit-any warnings * chore: reduce lint warnings * chore: silence intentional hook dependency warnings * chore: clean dependency tooling * chore: narrow frontend tsconfig scope * chore: reduce type assertion debt * refactor: split host manager components * refactor: split host editor sections * refactor: split api client modules * refactor: split more api clients * refactor: split user settings api clients * refactor: split tab and history api clients * refactor: split tunnel api clients * refactor: split server stats api client * refactor: split file manager data api * refactor: split ssh file operations api * refactor: split host editor general tab * refactor: split host editor guacamole tabs * refactor: split ssh host management api * refactor: split admin general settings sections * refactor: split admin database section * refactor: split admin management sections * refactor: split admin keys and dialogs * refactor: split system status api clients * refactor: split user route helpers * refactor: split host route helpers * refactor: split file manager ssh helpers * refactor: split file manager session helpers * refactor: split file manager listing routes * refactor: split host opkssh routes * refactor: split file manager content routes * refactor: split user api key routes * refactor: split host folder routes * refactor: split user settings routes * refactor: split user totp routes * refactor: split host file manager bookmark routes * refactor: split file manager operation routes * refactor: split server stats settings routes * refactor: split user session routes * refactor: split host command history routes * refactor: split server stats viewer routes * refactor: split docker container routes * refactor: split user oidc account routes * refactor: split host autostart routes * refactor: split host internal routes * refactor: split host network routes * refactor: split user password reset routes * refactor: split user admin routes * refactor: split user data access routes * refactor: split credential key routes * refactor: split credential deploy routes * refactor: split host bulk routes * refactor: split server stats connection helpers * refactor: split tunnel helpers * refactor: split file manager action routes * refactor: split terminal auth helpers * refactor: split terminal jump host helpers * refactor: split tunnel relay helpers * refactor: split tunnel socks relay helpers * refactor: split tunnel c2s relay handlers * refactor: split server stats session helpers * refactor: split terminal presentation helpers * refactor: split file manager presentation helpers * refactor: split file manager toolbar * fix(guacamole-lite): send name instruction for protocol >= 1.3.0 The Guacamole protocol added the `name` handshake instruction in 1.3.0 (an optional human-readable identifier for the joining user). guacd 1.6.0 began requiring it during the VNC handshake even when negotiating older protocol versions, causing connections to silently drop right after the "User joined" log line with no client-visible error. This patch extends scripts/patch-guacamole-lite.cjs with a third idempotent string-replacement that injects the `name` instruction send when guacamole-lite has negotiated protocol VERSION_1_3_0 or VERSION_1_5_0. Verified end-to-end: guacd debug logs now show `Processing instruction: name` and `Client is using protocol version "VERSION_1_5_0"` (previously stuck at VERSION_1_1_0). VNC session connects successfully against guacd 1.5.5 / macOS Tahoe target. Related: Termix-SSH/Support#567, #734 * fix: resolve recent support bugs * fix(admin): wire up OIDC-to-password link dialog submit + visibility The admin user-management UI already shipped a link icon and a "Link Account" dialog, but two things blocked the flow: 1. The submit button had no onClick handler and the username input was uncontrolled (no value/onChange). Clicking "Link Accounts" was a no-op — no network request, no console error, no toast. 2. The link icon's visibility condition was `user.isOidc && !user.passwordHash`, which hid the button on OIDC users that had been auto-provisioned with a passwordHash. Termix's OIDC provisioning sets a passwordHash by default, so the button was hidden on virtually every OIDC-provisioned user. This change: - Adds `linkOIDCToPasswordAccount` to the imports from `@/main-axios`. - Adds two pieces of dialog state: `linkAccountTargetUsername` and `linkAccountSubmitting`. - Makes the dialog's Input field a controlled component. - Wires the submit Button's onClick to call `linkOIDCToPasswordAccount`, emit success/error toasts, refresh the local user list, and close the dialog. - Loosens the visibility condition to `user.isOidc` (the backend handler already enforces all integrity checks). - Adds `linkAccountSuccess`, `linkAccountFailed`, and `linkAccountInProgress` translation keys to `en.json`. Verified locally: full Docker build via docker/Dockerfile passes; `tsc --noEmit` is clean; `prettier --check .` is clean; ESLint produces the same warning count as upstream (16 pre-existing `any`-type warnings, 0 errors). * fix: support native oidc callbacks (#856) * docs: add cloudflare tunnel guidance (#857) * fix: sync appearance preferences (#858) * fix: pass through terminal tab completion (#859) * fix: resolve terminal jump hosts server-side (#860) * fix(electron): auto-allow SSL certificates for private network hosts (#861) Add private network IP detection (RFC 1918, link-local, loopback, IPv6 ULA) to the Electron certificate-error handler so that connections to local/private servers like 192.168.x.x bypass SSL validation automatically. Also add an explicit "Allow invalid certificate" toggle in the server config UI for public HTTPS servers with self-signed certs. * fix: restore host password copy actions (#862) * feat: support single-host direct tunnels (ssh -L style) (#863) Add direct tunnel mode that uses a single SSH host for port forwarding, matching the behavior of ssh -L / ssh -R / ssh -D without requiring a second endpoint host in the Termix database. The Termix server creates a local TCP listener and forwards through the SSH channel directly. * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * fix: backend build errors (Type) * fix: mobile auth failing to login with webview * fix: mobile app geting incorrectly sent auth token * feat: commit existing frontend/backend e2e/unit tests (skipped tests containing private info like OIDC and real server testing) * feat: host-to-host file transfer via server relay * feat: removed host management from command palette, fixed command palette opening wrong protocol, export/import failing for ssh key hosts, docker ssh2 native crypto not compiled, persisted terminal tabs attempt SSh on RDP hosts after migration, improved layout for click to expand hosts, show ip/username without having to hover over hosts * fix: credentials not indexing into host manager until refresh * feat: update credentials lists to match hosts list UI/UX * feat: add rename folder UI * feat: improve transfer to host UI/UX * chore: increment ver * feat: improve transfer to host UI * feat: implement initial auto release system --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: nicodarge <43711429+nicodarge@users.noreply.github.com> Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com> Co-authored-by: luc <luc_cook@hotmail.co.uk>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deleteApiKey } from "@/main-axios";
|
||||
import type { ApiKey } from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Copy, Network, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AccordionSection } from "./AdminSettingsShared";
|
||||
import type { AdminUser } from "./AdminManagementSections";
|
||||
|
||||
type AdminApiKeysSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
apiKeys: ApiKey[];
|
||||
setApiKeys: Dispatch<SetStateAction<ApiKey[]>>;
|
||||
loadApiKeys: () => void;
|
||||
showCreateKey: boolean;
|
||||
setShowCreateKey: Dispatch<SetStateAction<boolean>>;
|
||||
createdKeyToken: string | null;
|
||||
setCreatedKeyToken: Dispatch<SetStateAction<string | null>>;
|
||||
newKeyName: string;
|
||||
setNewKeyName: Dispatch<SetStateAction<string>>;
|
||||
newKeyUserId: string;
|
||||
setNewKeyUserId: Dispatch<SetStateAction<string>>;
|
||||
newKeyExpiry: string;
|
||||
setNewKeyExpiry: Dispatch<SetStateAction<string>>;
|
||||
users: AdminUser[];
|
||||
handleCreateApiKey: () => void;
|
||||
newKeyLoading: boolean;
|
||||
};
|
||||
|
||||
export function AdminApiKeysSection({
|
||||
open,
|
||||
onToggle,
|
||||
apiKeys,
|
||||
setApiKeys,
|
||||
loadApiKeys,
|
||||
showCreateKey,
|
||||
setShowCreateKey,
|
||||
createdKeyToken,
|
||||
setCreatedKeyToken,
|
||||
newKeyName,
|
||||
setNewKeyName,
|
||||
newKeyUserId,
|
||||
setNewKeyUserId,
|
||||
newKeyExpiry,
|
||||
setNewKeyExpiry,
|
||||
users,
|
||||
handleCreateApiKey,
|
||||
newKeyLoading,
|
||||
}: AdminApiKeysSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionApiKeys")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.apiKeysCount", { count: apiKeys.length })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={loadApiKeys}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setShowCreateKey((o) => !o);
|
||||
setCreatedKeyToken(null);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("admin.createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showCreateKey && (
|
||||
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.newApiKey")}
|
||||
</span>
|
||||
{createdKeyToken ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] text-accent-brand font-semibold">
|
||||
{t("admin.apiKeyCreatedWarning")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
|
||||
<span className="text-[10px] font-mono flex-1 truncate">
|
||||
{createdKeyToken}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(createdKeyToken);
|
||||
toast.info(t("admin.copiedToClipboard"));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-accent-brand shrink-0"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs self-end"
|
||||
onClick={() => {
|
||||
setShowCreateKey(false);
|
||||
setCreatedKeyToken(null);
|
||||
}}
|
||||
>
|
||||
{t("admin.done")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.apiKeyName")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g., CI Pipeline"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.apiKeyUser")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<select
|
||||
className="px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none"
|
||||
value={newKeyUserId}
|
||||
onChange={(e) => setNewKeyUserId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.apiKeySelectUser")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.apiKeyExpiresAt")}
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={newKeyExpiry}
|
||||
onChange={(e) => setNewKeyExpiry(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => setShowCreateKey(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleCreateApiKey}
|
||||
disabled={newKeyLoading}
|
||||
>
|
||||
{newKeyLoading ? t("admin.creating") : t("admin.createKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{apiKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{key.name}
|
||||
</span>
|
||||
{!key.isActive && (
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-destructive/40 bg-destructive/10 text-destructive">
|
||||
{t("admin.revokedBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.apiKeyUser")}: {key.username}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{key.tokenPrefix}…
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{key.createdAt.split("T")[0]} ·{" "}
|
||||
{key.expiresAt
|
||||
? key.expiresAt.split("T")[0]
|
||||
: t("admin.apiKeyNoExpiry")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteApiKey(key.id);
|
||||
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
|
||||
toast.success(
|
||||
t("admin.revokeKeySuccess", { name: key.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("admin.revokeKeyFailed"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
deleteRole,
|
||||
deleteUser,
|
||||
revokeAllUserSessions,
|
||||
revokeSession,
|
||||
} from "@/main-axios";
|
||||
import type { Role } from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Activity,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Share2,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AccordionSection } from "./AdminSettingsShared";
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
};
|
||||
|
||||
export type AdminSession = {
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
isRevoked?: boolean;
|
||||
isCurrentSession?: boolean;
|
||||
};
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string) {
|
||||
return (error as ApiErrorLike).response?.data?.error || fallback;
|
||||
}
|
||||
|
||||
type UsersSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
users: AdminUser[];
|
||||
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
|
||||
loadUsers: () => void;
|
||||
setCreateUserOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setEditUserTarget: Dispatch<SetStateAction<AdminUser | null>>;
|
||||
setEditUserOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setLinkAccountTarget: Dispatch<
|
||||
SetStateAction<{ id: string; username: string } | null>
|
||||
>;
|
||||
setLinkAccountOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export function AdminUsersSection({
|
||||
open,
|
||||
onToggle,
|
||||
users,
|
||||
setUsers,
|
||||
loadUsers,
|
||||
setCreateUserOpen,
|
||||
setEditUserTarget,
|
||||
setEditUserOpen,
|
||||
setLinkAccountTarget,
|
||||
setLinkAccountOpen,
|
||||
}: UsersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionUsers")}
|
||||
icon={<User className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.usersCount", { count: users.length })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={loadUsers}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => setCreateUserOpen(true)}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("admin.createUser")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{users.map((user) => {
|
||||
const authLabel =
|
||||
user.isOidc && user.passwordHash
|
||||
? t("admin.authTypeDual")
|
||||
: user.isOidc
|
||||
? t("admin.authTypeOidc")
|
||||
: t("admin.authTypeLocal");
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="size-6 bg-muted border border-border flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
{user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-xs font-semibold truncate max-w-[120px]">
|
||||
{user.username}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{user.isAdmin && (
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
|
||||
{authLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditUserTarget(user);
|
||||
setEditUserOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
{user.isOidc && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setLinkAccountTarget({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
setLinkAccountOpen(true);
|
||||
}}
|
||||
>
|
||||
<Share2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
disabled={user.isAdmin}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteUser(user.username);
|
||||
setUsers((prev) => prev.filter((u) => u.id !== user.id));
|
||||
toast.success(
|
||||
t("admin.deleteUserSuccess", {
|
||||
username: user.username,
|
||||
}),
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
toast.error(
|
||||
apiErrorMessage(e, t("admin.deleteUserFailed")),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
type SessionsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
sessions: AdminSession[];
|
||||
setSessions: Dispatch<SetStateAction<AdminSession[]>>;
|
||||
loadSessions: () => void;
|
||||
};
|
||||
|
||||
export function AdminSessionsSection({
|
||||
open,
|
||||
onToggle,
|
||||
sessions,
|
||||
setSessions,
|
||||
loadSessions,
|
||||
}: SessionsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionSessions")}
|
||||
icon={<Activity className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sessionsActive", { count: sessions.length })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={loadSessions}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs font-semibold">
|
||||
{session.username}
|
||||
</span>
|
||||
{session.isCurrentSession && (
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
|
||||
{t("admin.youBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sessionActive", { time: session.lastActiveAt })}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sessionExpires", { time: session.expiresAt })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await revokeAllUserSessions(session.userId);
|
||||
setSessions((prev) =>
|
||||
prev.filter((s) => s.userId !== session.userId),
|
||||
);
|
||||
toast.success(t("admin.revokeAllSessionsSuccess"));
|
||||
} catch {
|
||||
toast.error(t("admin.revokeAllSessionsFailed"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("admin.revokeAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await revokeSession(session.id);
|
||||
setSessions((prev) =>
|
||||
prev.filter((s) => s.id !== session.id),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("admin.revokeSessionFailed"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
type RolesSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
roles: Role[];
|
||||
setRoles: Dispatch<SetStateAction<Role[]>>;
|
||||
showCreateRole: boolean;
|
||||
setShowCreateRole: Dispatch<SetStateAction<boolean>>;
|
||||
newRoleName: string;
|
||||
setNewRoleName: Dispatch<SetStateAction<string>>;
|
||||
newRoleDisplayName: string;
|
||||
setNewRoleDisplayName: Dispatch<SetStateAction<string>>;
|
||||
newRoleDescription: string;
|
||||
setNewRoleDescription: Dispatch<SetStateAction<string>>;
|
||||
handleCreateRole: () => void;
|
||||
createRoleLoading: boolean;
|
||||
};
|
||||
|
||||
export function AdminRolesSection({
|
||||
open,
|
||||
onToggle,
|
||||
roles,
|
||||
setRoles,
|
||||
showCreateRole,
|
||||
setShowCreateRole,
|
||||
newRoleName,
|
||||
setNewRoleName,
|
||||
newRoleDisplayName,
|
||||
setNewRoleDisplayName,
|
||||
newRoleDescription,
|
||||
setNewRoleDescription,
|
||||
handleCreateRole,
|
||||
createRoleLoading,
|
||||
}: RolesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionRoles")}
|
||||
icon={<KeyRound className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.rolesCount", { count: roles.length })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => setShowCreateRole((o) => !o)}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("admin.createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
{showCreateRole && (
|
||||
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.newRole")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.roleName")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g., developer"
|
||||
value={newRoleName}
|
||||
onChange={(e) => setNewRoleName(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.roleDisplayName")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g., Developer"
|
||||
value={newRoleDisplayName}
|
||||
onChange={(e) => setNewRoleDisplayName(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.roleDescription")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder={t("common.optional")}
|
||||
value={newRoleDescription}
|
||||
onChange={(e) => setNewRoleDescription(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
setShowCreateRole(false);
|
||||
setNewRoleName("");
|
||||
setNewRoleDisplayName("");
|
||||
setNewRoleDescription("");
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleCreateRole}
|
||||
disabled={createRoleLoading}
|
||||
>
|
||||
{createRoleLoading
|
||||
? t("admin.creating")
|
||||
: t("admin.createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{roles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{role.displayName}
|
||||
</span>
|
||||
{role.isSystem ? (
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
|
||||
{t("admin.systemBadge")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
|
||||
{t("admin.customBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
{role.name}
|
||||
</span>
|
||||
</div>
|
||||
{!role.isSystem && (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
await deleteRole(role.id);
|
||||
setRoles((prev) => prev.filter((r) => r.id !== role.id));
|
||||
toast.success(
|
||||
t("admin.deleteRoleSuccess", {
|
||||
name: role.displayName,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
+195
-1364
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,547 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
import { Database, RefreshCw, Settings, Shield, Trash2 } from "lucide-react";
|
||||
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||
|
||||
type GeneralSettingsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
allowRegistration: boolean;
|
||||
handleToggleRegistration: () => void;
|
||||
allowPasswordLogin: boolean;
|
||||
handleTogglePasswordLogin: () => void;
|
||||
oidcAutoProvision: boolean;
|
||||
handleToggleOidcAutoProvision: () => void;
|
||||
allowPasswordReset: boolean;
|
||||
handleTogglePasswordReset: () => void;
|
||||
sessionTimeout: string;
|
||||
setSessionTimeout: Dispatch<SetStateAction<string>>;
|
||||
handleSaveSessionTimeout: () => void;
|
||||
statusInterval: string;
|
||||
setStatusInterval: Dispatch<SetStateAction<string>>;
|
||||
metricsInterval: string;
|
||||
setMetricsInterval: Dispatch<SetStateAction<string>>;
|
||||
handleSaveMonitoring: () => void;
|
||||
guacEnabled: boolean;
|
||||
handleToggleGuacamole: () => void;
|
||||
guacUrl: string;
|
||||
setGuacUrl: Dispatch<SetStateAction<string>>;
|
||||
handleSaveGuacamole: () => void;
|
||||
logLevel: string;
|
||||
handleSaveLogLevel: (level: string) => void;
|
||||
};
|
||||
|
||||
export function AdminGeneralSettingsSection({
|
||||
open,
|
||||
onToggle,
|
||||
allowRegistration,
|
||||
handleToggleRegistration,
|
||||
allowPasswordLogin,
|
||||
handleTogglePasswordLogin,
|
||||
oidcAutoProvision,
|
||||
handleToggleOidcAutoProvision,
|
||||
allowPasswordReset,
|
||||
handleTogglePasswordReset,
|
||||
sessionTimeout,
|
||||
setSessionTimeout,
|
||||
handleSaveSessionTimeout,
|
||||
statusInterval,
|
||||
setStatusInterval,
|
||||
metricsInterval,
|
||||
setMetricsInterval,
|
||||
handleSaveMonitoring,
|
||||
guacEnabled,
|
||||
handleToggleGuacamole,
|
||||
guacUrl,
|
||||
setGuacUrl,
|
||||
handleSaveGuacamole,
|
||||
logLevel,
|
||||
handleSaveLogLevel,
|
||||
}: GeneralSettingsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionGeneral")}
|
||||
icon={<Settings className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
<SettingRow
|
||||
label={t("admin.allowRegistration")}
|
||||
description={t("admin.allowRegistrationDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={allowRegistration}
|
||||
onToggle={handleToggleRegistration}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.allowPasswordLogin")}
|
||||
description={t("admin.allowPasswordLoginDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={allowPasswordLogin}
|
||||
onToggle={handleTogglePasswordLogin}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.oidcAutoProvision")}
|
||||
description={t("admin.oidcAutoProvisionDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={oidcAutoProvision}
|
||||
onToggle={handleToggleOidcAutoProvision}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.allowPasswordReset")}
|
||||
description={t("admin.allowPasswordResetDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={allowPasswordReset}
|
||||
onToggle={handleTogglePasswordReset}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.sessionTimeout")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={sessionTimeout}
|
||||
onChange={(e) => setSessionTimeout(e.target.value)}
|
||||
className="w-20 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("admin.hours")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSaveSessionTimeout}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sessionTimeoutRange")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.monitoringDefaults")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.statusCheck")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={statusInterval}
|
||||
onChange={(e) => setStatusInterval(e.target.value)}
|
||||
className="w-20 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("admin.sec")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.metrics")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={metricsInterval}
|
||||
onChange={(e) => setMetricsInterval(e.target.value)}
|
||||
className="w-20 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("admin.sec")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSaveMonitoring}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
<SettingRow
|
||||
label={t("admin.enableGuacamole")}
|
||||
description={t("admin.enableGuacamoleDesc")}
|
||||
>
|
||||
<AdminToggle on={guacEnabled} onToggle={handleToggleGuacamole} />
|
||||
</SettingRow>
|
||||
{guacEnabled && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.guacdUrl")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={guacUrl}
|
||||
onChange={(e) => setGuacUrl(e.target.value)}
|
||||
placeholder="guacd:4822"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7 shrink-0"
|
||||
onClick={handleSaveGuacamole}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.logLevel")}
|
||||
</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{["debug", "info", "warn", "error"].map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => handleSaveLogLevel(l)}
|
||||
className={`px-2 py-1 text-[10px] font-semibold border capitalize transition-colors ${logLevel === l ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
type OidcSettingsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
oidcClientId: string;
|
||||
setOidcClientId: Dispatch<SetStateAction<string>>;
|
||||
oidcClientSecret: string;
|
||||
setOidcClientSecret: Dispatch<SetStateAction<string>>;
|
||||
oidcAuthUrl: string;
|
||||
setOidcAuthUrl: Dispatch<SetStateAction<string>>;
|
||||
oidcIssuerUrl: string;
|
||||
setOidcIssuerUrl: Dispatch<SetStateAction<string>>;
|
||||
oidcTokenUrl: string;
|
||||
setOidcTokenUrl: Dispatch<SetStateAction<string>>;
|
||||
oidcUserIdentifier: string;
|
||||
setOidcUserIdentifier: Dispatch<SetStateAction<string>>;
|
||||
oidcDisplayName: string;
|
||||
setOidcDisplayName: Dispatch<SetStateAction<string>>;
|
||||
oidcScopes: string;
|
||||
setOidcScopes: Dispatch<SetStateAction<string>>;
|
||||
oidcUserinfoUrl: string;
|
||||
setOidcUserinfoUrl: Dispatch<SetStateAction<string>>;
|
||||
oidcAllowedUsers: string;
|
||||
setOidcAllowedUsers: Dispatch<SetStateAction<string>>;
|
||||
oidcSaving: boolean;
|
||||
handleRemoveOidc: () => void;
|
||||
handleSaveOidc: () => void;
|
||||
};
|
||||
|
||||
export function AdminOidcSettingsSection({
|
||||
open,
|
||||
onToggle,
|
||||
oidcClientId,
|
||||
setOidcClientId,
|
||||
oidcClientSecret,
|
||||
setOidcClientSecret,
|
||||
oidcAuthUrl,
|
||||
setOidcAuthUrl,
|
||||
oidcIssuerUrl,
|
||||
setOidcIssuerUrl,
|
||||
oidcTokenUrl,
|
||||
setOidcTokenUrl,
|
||||
oidcUserIdentifier,
|
||||
setOidcUserIdentifier,
|
||||
oidcDisplayName,
|
||||
setOidcDisplayName,
|
||||
oidcScopes,
|
||||
setOidcScopes,
|
||||
oidcUserinfoUrl,
|
||||
setOidcUserinfoUrl,
|
||||
oidcAllowedUsers,
|
||||
setOidcAllowedUsers,
|
||||
oidcSaving,
|
||||
handleRemoveOidc,
|
||||
handleSaveOidc,
|
||||
}: OidcSettingsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionOidc")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcDescription").split("*")[0]}
|
||||
<span className="text-accent-brand">*</span>
|
||||
{t("admin.oidcDescription").split("*")[1]}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcClientId")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcClientId}
|
||||
onChange={(e) => setOidcClientId(e.target.value)}
|
||||
placeholder="your-client-id"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcClientSecret")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={oidcClientSecret}
|
||||
onChange={(e) => setOidcClientSecret(e.target.value)}
|
||||
placeholder="your-client-secret"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcAuthUrl")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcAuthUrl}
|
||||
onChange={(e) => setOidcAuthUrl(e.target.value)}
|
||||
placeholder="https://provider/oauth2/auth"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcIssuerUrl")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcIssuerUrl}
|
||||
onChange={(e) => setOidcIssuerUrl(e.target.value)}
|
||||
placeholder="https://provider"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcTokenUrl")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcTokenUrl}
|
||||
onChange={(e) => setOidcTokenUrl(e.target.value)}
|
||||
placeholder="https://provider/oauth2/token"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcUserIdentifier")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcUserIdentifier}
|
||||
onChange={(e) => setOidcUserIdentifier(e.target.value)}
|
||||
placeholder="sub"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcDisplayName")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcDisplayName}
|
||||
onChange={(e) => setOidcDisplayName(e.target.value)}
|
||||
placeholder="name"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcScopes")} <span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcScopes}
|
||||
onChange={(e) => setOidcScopes(e.target.value)}
|
||||
placeholder="openid email profile"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcUserinfoUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={oidcUserinfoUrl}
|
||||
onChange={(e) => setOidcUserinfoUrl(e.target.value)}
|
||||
placeholder="https://provider/oauth2/userinfo"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcAllowedUsers")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcAllowedUsersDesc")}
|
||||
</span>
|
||||
<textarea
|
||||
value={oidcAllowedUsers}
|
||||
onChange={(e) => setOidcAllowedUsers(e.target.value)}
|
||||
placeholder={"user@example.com\nanother@example.com"}
|
||||
rows={3}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleRemoveOidc}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
{t("admin.removeOidc")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleSaveOidc}
|
||||
disabled={oidcSaving}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
{oidcSaving ? t("admin.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
type DatabaseSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
importFile: File | null;
|
||||
setImportFile: Dispatch<SetStateAction<File | null>>;
|
||||
exportLoading: boolean;
|
||||
importLoading: boolean;
|
||||
handleExportDatabase: () => void;
|
||||
handleImportDatabase: () => void;
|
||||
};
|
||||
|
||||
export function AdminDatabaseSection({
|
||||
open,
|
||||
onToggle,
|
||||
importFile,
|
||||
setImportFile,
|
||||
exportLoading,
|
||||
importLoading,
|
||||
handleExportDatabase,
|
||||
handleImportDatabase,
|
||||
}: DatabaseSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionDatabase")}
|
||||
icon={<Database className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("admin.exportDatabase")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.exportDatabaseDesc")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand mt-1"
|
||||
onClick={handleExportDatabase}
|
||||
disabled={exportLoading}
|
||||
>
|
||||
{exportLoading ? t("admin.exporting") : t("admin.export")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||
<span className="text-xs font-medium">
|
||||
{t("admin.importDatabase")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{importFile
|
||||
? t("admin.importDatabaseSelected", { name: importFile.name })
|
||||
: t("admin.importDatabaseDesc")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="pointer-events-none text-xs"
|
||||
>
|
||||
{importFile ? t("admin.changeFile") : t("admin.selectFile")}
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleImportDatabase}
|
||||
disabled={importLoading}
|
||||
>
|
||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export function AdminToggle({
|
||||
on,
|
||||
onToggle,
|
||||
}: {
|
||||
on: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccordionSection({
|
||||
label,
|
||||
icon,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-border bg-card overflow-hidden">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">{icon}</span>
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-foreground flex-1">
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`size-3.5 text-muted-foreground shrink-0 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border px-3 pb-3">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
assignRoleToUser,
|
||||
linkOIDCToPasswordAccount,
|
||||
removeRoleFromUser,
|
||||
} from "@/main-axios";
|
||||
import type { Role, UserRole } from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AdminToggle } from "./AdminSettingsShared";
|
||||
import type { AdminUser } from "./AdminManagementSections";
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string) {
|
||||
const err = error as ApiErrorLike;
|
||||
return err.response?.data?.error || err.message || fallback;
|
||||
}
|
||||
|
||||
type CreateUserDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: Dispatch<SetStateAction<boolean>>;
|
||||
newUsername: string;
|
||||
setNewUsername: Dispatch<SetStateAction<string>>;
|
||||
newPassword: string;
|
||||
setNewPassword: Dispatch<SetStateAction<string>>;
|
||||
showNewPassword: boolean;
|
||||
setShowNewPassword: Dispatch<SetStateAction<boolean>>;
|
||||
handleCreateUser: () => void;
|
||||
createUserLoading: boolean;
|
||||
};
|
||||
|
||||
export function AdminCreateUserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
newUsername,
|
||||
setNewUsername,
|
||||
newPassword,
|
||||
setNewPassword,
|
||||
showNewPassword,
|
||||
setShowNewPassword,
|
||||
handleCreateUser,
|
||||
createUserLoading,
|
||||
}: CreateUserDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("admin.createUserTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("admin.createUserDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.createUserUsername")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("admin.createUserEnterUsername")}
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.createUserPassword")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
placeholder="Enter password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowNewPassword((o) => !o)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("admin.createUserPasswordHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleCreateUser}
|
||||
disabled={createUserLoading}
|
||||
>
|
||||
{createUserLoading
|
||||
? t("admin.creating")
|
||||
: t("admin.createUserSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type EditUserDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: Dispatch<SetStateAction<boolean>>;
|
||||
editUserTarget: AdminUser | null;
|
||||
editUserLoading: boolean;
|
||||
editUserRoles: UserRole[];
|
||||
editUserRolesLoading: boolean;
|
||||
roles: Role[];
|
||||
setEditUserRoles: Dispatch<SetStateAction<UserRole[]>>;
|
||||
handleToggleAdmin: (user: AdminUser) => void;
|
||||
handleRevokeUserSessions: (userId: string) => void;
|
||||
handleDeleteEditUser: () => void;
|
||||
};
|
||||
|
||||
export function AdminEditUserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editUserTarget,
|
||||
editUserLoading,
|
||||
editUserRoles,
|
||||
editUserRolesLoading,
|
||||
roles,
|
||||
setEditUserRoles,
|
||||
handleToggleAdmin,
|
||||
handleRevokeUserSessions,
|
||||
handleDeleteEditUser,
|
||||
}: EditUserDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("admin.editUserTitle", { username: editUserTarget?.username })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("admin.editUserDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editUserTarget && (
|
||||
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("admin.editUserUsername")}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{editUserTarget.username}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("admin.editUserAuthType")}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{editUserTarget.isOidc && editUserTarget.passwordHash
|
||||
? t("admin.authTypeDual")
|
||||
: editUserTarget.isOidc
|
||||
? t("admin.authTypeOidc")
|
||||
: t("admin.authTypeLocal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("admin.editUserAdminStatus")}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{editUserTarget.isAdmin
|
||||
? t("admin.adminStatusAdministrator")
|
||||
: t("admin.adminStatusRegularUser")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("admin.editUserUserId")}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-muted-foreground truncate">
|
||||
{editUserTarget.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{t("admin.userAdminAccess")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("admin.userAdminAccessDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<AdminToggle
|
||||
on={editUserTarget.isAdmin}
|
||||
onToggle={() => handleToggleAdmin(editUserTarget)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<span className="text-sm font-medium">
|
||||
{t("admin.userRoles")}
|
||||
</span>
|
||||
{editUserRolesLoading ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.loading")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{editUserRoles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{editUserRoles.map((ur) => {
|
||||
const roleInfo = roles.find((r) => r.id === ur.roleId);
|
||||
const isSystem = roleInfo?.isSystem ?? false;
|
||||
return (
|
||||
<span
|
||||
key={ur.roleId}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
>
|
||||
{ur.roleDisplayName}
|
||||
{!isSystem && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeRoleFromUser(
|
||||
editUserTarget.id,
|
||||
ur.roleId,
|
||||
);
|
||||
setEditUserRoles((prev) =>
|
||||
prev.filter(
|
||||
(r) => r.roleId !== ur.roleId,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("admin.removeRoleFailed"));
|
||||
}
|
||||
}}
|
||||
className="hover:text-destructive ml-0.5"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{roles.filter(
|
||||
(r) =>
|
||||
!r.isSystem &&
|
||||
!editUserRoles.some((ur) => ur.roleId === r.id),
|
||||
).length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("admin.addRole")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{roles
|
||||
.filter(
|
||||
(r) =>
|
||||
!r.isSystem &&
|
||||
!editUserRoles.some((ur) => ur.roleId === r.id),
|
||||
)
|
||||
.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await assignRoleToUser(
|
||||
editUserTarget.id,
|
||||
r.id,
|
||||
);
|
||||
setEditUserRoles((prev) => [
|
||||
...prev,
|
||||
{
|
||||
userId: editUserTarget.id,
|
||||
roleId: r.id,
|
||||
roleName: r.name,
|
||||
roleDisplayName: r.displayName,
|
||||
grantedBy: "",
|
||||
grantedByUsername: "",
|
||||
grantedAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
toast.error(t("admin.assignRoleFailed"));
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
|
||||
>
|
||||
+ {r.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editUserRoles.length === 0 &&
|
||||
roles.filter((r) => !r.isSystem).length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("admin.noCustomRoles")}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{t("admin.revokeAllUserSessions")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("admin.revokeAllUserSessionsDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
|
||||
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
|
||||
disabled={editUserLoading}
|
||||
>
|
||||
{t("admin.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-destructive">
|
||||
{t("admin.deleteUserWarning")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={editUserTarget.isAdmin || editUserLoading}
|
||||
onClick={handleDeleteEditUser}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{editUserLoading
|
||||
? t("admin.deleting")
|
||||
: t("admin.deleteUser", {
|
||||
username: editUserTarget.username,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type LinkAccountDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: Dispatch<SetStateAction<boolean>>;
|
||||
linkAccountTarget: { id: string; username: string } | null;
|
||||
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
|
||||
};
|
||||
|
||||
export function AdminLinkAccountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
linkAccountTarget,
|
||||
setUsers,
|
||||
}: LinkAccountDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [targetUsername, setTargetUsername] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTargetUsername("");
|
||||
}, [open, linkAccountTarget]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedUsername = targetUsername.trim();
|
||||
if (!linkAccountTarget || !trimmedUsername) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await linkOIDCToPasswordAccount(linkAccountTarget.id, trimmedUsername);
|
||||
toast.success(
|
||||
t("admin.linkAccountSuccess", { username: trimmedUsername }),
|
||||
);
|
||||
setUsers((prev) => prev.filter((u) => u.id !== linkAccountTarget.id));
|
||||
setTargetUsername("");
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
toast.error(apiErrorMessage(error, t("admin.linkAccountFailed")));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("admin.linkAccountTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("admin.linkAccountDesc", {
|
||||
username: linkAccountTarget?.username,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-1 text-xs text-destructive">
|
||||
<span>{t("admin.linkAccountWarningTitle")}</span>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-1">
|
||||
<li>{t("admin.linkAccountEffect1")}</li>
|
||||
<li>{t("admin.linkAccountEffect2")}</li>
|
||||
<li>{t("admin.linkAccountEffect3")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.linkAccountTargetUsername")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={targetUsername}
|
||||
onChange={(e) => setTargetUsername(e.target.value)}
|
||||
placeholder={t("admin.linkAccountTargetPlaceholder")}
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={
|
||||
submitting || !linkAccountTarget || !targetUsername.trim()
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting
|
||||
? t("admin.linkAccountInProgress")
|
||||
: t("admin.linkAccounts")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,6 @@ type RailItem =
|
||||
function buildRailButtons(
|
||||
splitMode: SplitMode,
|
||||
t: (key: string) => string,
|
||||
connectionCount: number,
|
||||
): RailItem[] {
|
||||
return [
|
||||
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
|
||||
@@ -97,7 +96,6 @@ export function AppRail({
|
||||
railView,
|
||||
sidebarOpen,
|
||||
splitMode,
|
||||
connectionCount,
|
||||
username,
|
||||
isAdmin,
|
||||
profileDropdownOpen,
|
||||
@@ -109,7 +107,6 @@ export function AppRail({
|
||||
railView: RailView;
|
||||
sidebarOpen: boolean;
|
||||
splitMode: SplitMode;
|
||||
connectionCount: number;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
profileDropdownOpen: boolean;
|
||||
@@ -132,7 +129,7 @@ export function AppRail({
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || hovered || profileDropdownOpen;
|
||||
const railButtons = buildRailButtons(splitMode, t, connectionCount);
|
||||
const railButtons = buildRailButtons(splitMode, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Plug, Search, X } from "lucide-react";
|
||||
import { getActiveSessions, deleteOpenTab, type ActiveSessionInfo, type OpenTabRecord } from "@/main-axios";
|
||||
import {
|
||||
getActiveSessions,
|
||||
deleteOpenTab,
|
||||
type ActiveSessionInfo,
|
||||
type OpenTabRecord,
|
||||
} from "@/main-axios";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, TabType } from "@/types/ui-types";
|
||||
import { Badge } from "@/components/badge";
|
||||
import { Input } from "@/components/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip";
|
||||
|
||||
const CONNECTION_TAB_TYPES: TabType[] = [
|
||||
"terminal",
|
||||
@@ -128,7 +138,10 @@ function ConnectionRow({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSwitch(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSwitch();
|
||||
}}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
@@ -138,7 +151,10 @@ function ConnectionRow({
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
@@ -178,7 +194,10 @@ export function ConnectionsPanel({
|
||||
backgroundTabRecords: OpenTabRecord[];
|
||||
onSwitchToTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => void;
|
||||
onReopenTab: (record: OpenTabRecord, restoredSessionId: string | null) => void;
|
||||
onReopenTab: (
|
||||
record: OpenTabRecord,
|
||||
restoredSessionId: string | null,
|
||||
) => void;
|
||||
onForgetBackground: (recordId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -187,15 +206,23 @@ export function ConnectionsPanel({
|
||||
const [search, setSearch] = useState("");
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const openTabs = tabs.filter((tab) => CONNECTION_TAB_TYPES.includes(tab.type));
|
||||
const openTabs = tabs.filter((tab) =>
|
||||
CONNECTION_TAB_TYPES.includes(tab.type),
|
||||
);
|
||||
|
||||
// Filter background records to only those not already open in the tab bar
|
||||
const openInstanceIds = new Set(tabs.map((t) => t.instanceId).filter(Boolean));
|
||||
const backgroundTabs = backgroundTabRecords.filter((r) => !openInstanceIds.has(r.id));
|
||||
const openInstanceIds = new Set(
|
||||
tabs.map((t) => t.instanceId).filter(Boolean),
|
||||
);
|
||||
const backgroundTabs = backgroundTabRecords.filter(
|
||||
(r) => !openInstanceIds.has(r.id),
|
||||
);
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filteredOpenTabs = q
|
||||
? openTabs.filter((tab) => (tab.host?.name ?? tab.label).toLowerCase().includes(q))
|
||||
? openTabs.filter((tab) =>
|
||||
(tab.host?.name ?? tab.label).toLowerCase().includes(q),
|
||||
)
|
||||
: openTabs;
|
||||
const filteredBackgroundTabs = q
|
||||
? backgroundTabs.filter((r) => {
|
||||
@@ -225,13 +252,18 @@ export function ConnectionsPanel({
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
pollTimerRef.current = setInterval(refresh, 5000);
|
||||
return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); };
|
||||
return () => {
|
||||
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const sessionByInstanceId = new Map(activeSessions.map((s) => [s.tabInstanceId, s]));
|
||||
const sessionByInstanceId = new Map(
|
||||
activeSessions.map((s) => [s.tabInstanceId, s]),
|
||||
);
|
||||
|
||||
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
|
||||
const hasResults = filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
|
||||
const hasResults =
|
||||
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
|
||||
|
||||
if (!hasAnything) {
|
||||
return (
|
||||
@@ -265,19 +297,27 @@ export function ConnectionsPanel({
|
||||
|
||||
{!hasResults && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
|
||||
<span className="text-xs text-muted-foreground/50">{t("connections.noSearchResults")}</span>
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
{t("connections.noSearchResults")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredOpenTabs.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader label={t("connections.sectionOpen")} count={filteredOpenTabs.length} />
|
||||
<SectionHeader
|
||||
label={t("connections.sectionOpen")}
|
||||
count={filteredOpenTabs.length}
|
||||
/>
|
||||
{filteredOpenTabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const liveSession = tab.instanceId ? sessionByInstanceId.get(tab.instanceId) : undefined;
|
||||
const isLive = tab.type === "terminal"
|
||||
? (liveSession?.isConnected ?? false)
|
||||
: true;
|
||||
const liveSession = tab.instanceId
|
||||
? sessionByInstanceId.get(tab.instanceId)
|
||||
: undefined;
|
||||
const isLive =
|
||||
tab.type === "terminal"
|
||||
? (liveSession?.isConnected ?? false)
|
||||
: true;
|
||||
const duration = liveSession?.createdAt
|
||||
? formatDuration(now - liveSession.createdAt)
|
||||
: formatDuration(now - tab.openedAt);
|
||||
@@ -306,8 +346,13 @@ export function ConnectionsPanel({
|
||||
)}
|
||||
|
||||
{filteredBackgroundTabs.length > 0 && (
|
||||
<div className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}>
|
||||
<SectionHeader label={t("connections.sectionBackground")} count={filteredBackgroundTabs.length} />
|
||||
<div
|
||||
className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}
|
||||
>
|
||||
<SectionHeader
|
||||
label={t("connections.sectionBackground")}
|
||||
count={filteredBackgroundTabs.length}
|
||||
/>
|
||||
<div className="px-3 py-1.5 border-b border-border/40">
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{t("connections.backgroundDesc")}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Info, Lock, Upload, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import {
|
||||
createCredential,
|
||||
generateKeyPair,
|
||||
generatePublicKeyFromPrivate,
|
||||
updateCredential,
|
||||
} from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
|
||||
type CredentialAuthType = Credential["type"];
|
||||
type CredentialWithCertificate = Credential & { certPublicKey?: string };
|
||||
|
||||
export function CredentialEditorView({
|
||||
credential,
|
||||
activeTab,
|
||||
onBack,
|
||||
onSave,
|
||||
}: {
|
||||
credential: Credential | null;
|
||||
activeTab: string;
|
||||
onBack: () => void;
|
||||
onSave: (saved: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const [credForm, setCredForm] = useState(() => ({
|
||||
name: credential?.name ?? "",
|
||||
username: credential?.username ?? "",
|
||||
folder: credential?.folder ?? "",
|
||||
description: credential?.description ?? "",
|
||||
tags: credential?.tags ?? ([] as string[]),
|
||||
tagInput: "",
|
||||
type: credential?.type ?? "password",
|
||||
value: credential?.value ?? "",
|
||||
publicKey: credential?.publicKey ?? "",
|
||||
passphrase: credential?.passphrase ?? "",
|
||||
certPublicKey:
|
||||
(credential as CredentialWithCertificate | null)?.certPublicKey ?? "",
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const [generatingKey, setGeneratingKey] = useState(false);
|
||||
const [generatingPublicKey, setGeneratingPublicKey] = useState(false);
|
||||
const credFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const certFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const setCredField = <K extends keyof typeof credForm>(
|
||||
k: K,
|
||||
v: (typeof credForm)[K],
|
||||
) => setCredForm((p) => ({ ...p, [k]: v }));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = {
|
||||
name: credForm.name,
|
||||
username: credForm.username,
|
||||
folder: credForm.folder || null,
|
||||
description: credForm.description || null,
|
||||
tags: credForm.tags,
|
||||
authType: credForm.type,
|
||||
password: credForm.type === "password" ? credForm.value : null,
|
||||
key:
|
||||
credForm.type === "key"
|
||||
? credForm.value === "existing_key"
|
||||
? undefined
|
||||
: credForm.value || null
|
||||
: null,
|
||||
publicKey: credForm.type === "key" ? credForm.publicKey : null,
|
||||
certPublicKey:
|
||||
credForm.type === "key" ? credForm.certPublicKey || null : null,
|
||||
keyPassword:
|
||||
credForm.type === "key"
|
||||
? credForm.passphrase === "existing_key_password"
|
||||
? undefined
|
||||
: credForm.passphrase || null
|
||||
: null,
|
||||
};
|
||||
const saved = credential
|
||||
? await updateCredential(Number(credential.id), data)
|
||||
: await createCredential(data);
|
||||
toast.success(
|
||||
credential
|
||||
? t("hosts.credentialUpdated")
|
||||
: t("hosts.credentialCreated"),
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
onSave(saved);
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToSaveCredential"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const type = credForm.type;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "general" && (
|
||||
<SectionCard
|
||||
title={t("hosts.basicInformation")}
|
||||
icon={<Info className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.friendlyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Production SSH Key"
|
||||
value={credForm.name}
|
||||
onChange={(e) => setCredField("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.folder")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Server Keys"
|
||||
value={credForm.folder}
|
||||
onChange={(e) => setCredField("folder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.descriptionLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Optional details..."
|
||||
value={credForm.description}
|
||||
onChange={(e) => setCredField("description", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.tags")}
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
|
||||
{credForm.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCredField(
|
||||
"tags",
|
||||
credForm.tags.filter((tg) => tg !== tag),
|
||||
)
|
||||
}
|
||||
className="text-muted-foreground hover:text-destructive ml-0.5"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder={
|
||||
credForm.tags.length === 0
|
||||
? t("hosts.addTagsPlaceholder")
|
||||
: ""
|
||||
}
|
||||
value={credForm.tagInput}
|
||||
onChange={(e) => setCredField("tagInput", e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.key === " " || e.key === "Enter") &&
|
||||
credForm.tagInput.trim()
|
||||
) {
|
||||
e.preventDefault();
|
||||
const tag = credForm.tagInput.trim();
|
||||
if (!credForm.tags.includes(tag))
|
||||
setCredField("tags", [...credForm.tags, tag]);
|
||||
setCredField("tagInput", "");
|
||||
} else if (
|
||||
e.key === "Backspace" &&
|
||||
!credForm.tagInput &&
|
||||
credForm.tags.length > 0
|
||||
) {
|
||||
setCredField("tags", credForm.tags.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{activeTab === "auth" && (
|
||||
<SectionCard
|
||||
title={t("hosts.authDetailsSection")}
|
||||
icon={<Lock className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.credTypeLabel")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{["password", "key"].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() =>
|
||||
setCredField("type", m as CredentialAuthType)
|
||||
}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{m === "key"
|
||||
? t("hosts.sshPrivateKey")
|
||||
: t("hosts.password")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.username")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. root or deploy"
|
||||
value={credForm.username}
|
||||
onChange={(e) => setCredField("username", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{type === "password" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={credForm.value}
|
||||
onChange={(e) => setCredField("value", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === "key" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="p-3 border border-border bg-muted/20">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
|
||||
{t("hosts.generateKeyPairTitle")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
{t("hosts.generateKeyPairDescription")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "Ed25519", type: "ssh-ed25519" },
|
||||
{
|
||||
label: "ECDSA (nistp256)",
|
||||
type: "ecdsa-sha2-nistp256",
|
||||
},
|
||||
{ label: "RSA (2048)", type: "ssh-rsa", bits: 2048 },
|
||||
].map(({ label, type: keyType, bits }) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2"
|
||||
disabled={generatingKey}
|
||||
onClick={async () => {
|
||||
setGeneratingKey(true);
|
||||
try {
|
||||
const result = await generateKeyPair(
|
||||
keyType as
|
||||
| "ssh-ed25519"
|
||||
| "ssh-rsa"
|
||||
| "ecdsa-sha2-nistp256",
|
||||
bits,
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? undefined
|
||||
: credForm.passphrase || undefined,
|
||||
);
|
||||
if (result.success) {
|
||||
setCredField("value", result.privateKey);
|
||||
setCredField("publicKey", result.publicKey);
|
||||
toast.success(
|
||||
t("hosts.keyPairGenerated", { label }),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
result.error ??
|
||||
t("hosts.failedToGenerateKeyPair"),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToGenerateKeyPair"));
|
||||
} finally {
|
||||
setGeneratingKey(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generatingKey
|
||||
? t("hosts.generatingKey")
|
||||
: t("hosts.generateLabel", { label })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sshPrivateKey")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1"
|
||||
onClick={() => credFileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-3" /> {t("hosts.uploadFileBtn")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={credFileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
setCredField("value", text.trim());
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{credForm.value === "existing_key" && (
|
||||
<div className="px-3 py-2 text-[10px] border border-accent-brand/30 bg-accent-brand/5 text-accent-brand">
|
||||
{t("hosts.keySaved")} — {t("hosts.keyReplaceNotice")}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
rows={8}
|
||||
value={
|
||||
credForm.value === "existing_key" ? "" : credForm.value
|
||||
}
|
||||
onChange={(e) => setCredField("value", e.target.value)}
|
||||
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.keyPassphraseOptional")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder={
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? t("hosts.keyPassphraseSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? ""
|
||||
: credForm.passphrase
|
||||
}
|
||||
onFocus={() => {
|
||||
if (credForm.passphrase === "existing_key_password")
|
||||
setCredField("passphrase", "");
|
||||
}}
|
||||
onChange={(e) => setCredField("passphrase", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sshPublicKeyOptional")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
disabled={
|
||||
!credForm.value ||
|
||||
credForm.value === "existing_key" ||
|
||||
generatingPublicKey
|
||||
}
|
||||
onClick={async () => {
|
||||
setGeneratingPublicKey(true);
|
||||
try {
|
||||
const result = await generatePublicKeyFromPrivate(
|
||||
credForm.value,
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? undefined
|
||||
: credForm.passphrase || undefined,
|
||||
);
|
||||
if (result?.publicKey) {
|
||||
setCredField("publicKey", result.publicKey);
|
||||
toast.success(t("hosts.publicKeyGenerated"));
|
||||
} else {
|
||||
toast.error(t("hosts.failedToGeneratePublicKey"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToGeneratePublicKey"));
|
||||
} finally {
|
||||
setGeneratingPublicKey(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generatingPublicKey
|
||||
? t("hosts.generatingKey")
|
||||
: t("hosts.generateFromPrivateKey")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2"
|
||||
disabled={!credForm.publicKey}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(credForm.publicKey ?? "");
|
||||
toast.success(t("hosts.publicKeyCopied"));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3 mr-1" /> {t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="ssh-rsa AAAAB3Nza..."
|
||||
rows={3}
|
||||
value={credForm.publicKey}
|
||||
onChange={(e) => setCredField("publicKey", e.target.value)}
|
||||
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 p-3 border border-border bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.caCertificate")}
|
||||
</label>
|
||||
{credForm.certPublicKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-destructive hover:text-destructive/80"
|
||||
onClick={() => setCredField("certPublicKey", "")}
|
||||
>
|
||||
{t("credentials.clearCert")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("credentials.caCertificateDescription")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1 self-start"
|
||||
onClick={() => certFileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-3" />{" "}
|
||||
{t("credentials.uploadCertFile")}
|
||||
</button>
|
||||
<input
|
||||
ref={certFileInputRef}
|
||||
type="file"
|
||||
accept=".pub,.txt"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
setCredField("certPublicKey", text.trim());
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
placeholder={t("credentials.pasteOrUploadCert")}
|
||||
rows={2}
|
||||
value={credForm.certPublicKey}
|
||||
onChange={(e) =>
|
||||
setCredField("certPublicKey", e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 mt-3">
|
||||
<Button variant="ghost" onClick={onBack} disabled={saving}>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? t("hosts.savingBtn")
|
||||
: credential
|
||||
? t("hosts.updateCredentialBtn")
|
||||
: t("hosts.addCredentialBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { getCredentialDetails } from "@/main-axios";
|
||||
import type { Host, Credential } from "@/types/ui-types";
|
||||
|
||||
type CredentialWithCertificate = Credential & { certPublicKey?: string };
|
||||
type ConfirmDialog = {
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
function CredentialItem({
|
||||
cred,
|
||||
usedByCount,
|
||||
stripeIndex,
|
||||
onDeploy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
cred: Credential;
|
||||
usedByCount: number;
|
||||
stripeIndex: number;
|
||||
onDeploy: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const isKey = cred.type === "key";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-stretch cursor-default select-none transition-colors hover:bg-muted/40 ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
|
||||
>
|
||||
{/* Type stripe */}
|
||||
<div className="w-[3px] shrink-0 bg-transparent" />
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
|
||||
{/* Name row */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[13px] font-medium truncate text-foreground leading-none">
|
||||
{cred.name}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
|
||||
>
|
||||
{isKey ? "KEY" : "PWD"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Username row */}
|
||||
{(cred.username || usedByCount > 0) && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
|
||||
{cred.username}
|
||||
{usedByCount > 0 && (
|
||||
<span className="text-muted-foreground/30">
|
||||
{cred.username ? " · " : ""}
|
||||
{usedByCount}h
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tag pills */}
|
||||
{cred.tags && cred.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1 min-w-0 overflow-hidden pl-3">
|
||||
{cred.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[9px] px-1 py-px border border-border/50 bg-muted/30 text-muted-foreground/60 lowercase shrink-0 leading-none"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{cred.tags.length > 4 && (
|
||||
<span className="text-[9px] text-muted-foreground/40 shrink-0">
|
||||
+{cred.tags.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action tray — slides open on hover */}
|
||||
<div className="overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[60px] group-hover:opacity-100">
|
||||
<div className="flex items-center gap-1 pt-1.5 pl-2 pb-1 border-t border-border/40 mt-0.5">
|
||||
{isKey && (
|
||||
<>
|
||||
<button
|
||||
title="Deploy key to host"
|
||||
onClick={onDeploy}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
title="Copy deploy command"
|
||||
onClick={() => {
|
||||
const pubKey = cred.publicKey;
|
||||
if (!pubKey) {
|
||||
toast.error(
|
||||
"No public key available — open the credential editor first",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
|
||||
navigator.clipboard.writeText(cmd);
|
||||
toast.success("Deploy command copied");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
title="Edit credential"
|
||||
onClick={onEdit}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
title="Delete credential"
|
||||
onClick={onDelete}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialFolderItem({
|
||||
folder,
|
||||
creds,
|
||||
allHosts,
|
||||
stripeOffset,
|
||||
editingFolderName,
|
||||
editingFolderValue,
|
||||
onEditingFolderNameChange,
|
||||
onEditingFolderValueChange,
|
||||
onRenameFolder,
|
||||
onDeploy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
folder: string;
|
||||
creds: Credential[];
|
||||
allHosts: Host[];
|
||||
stripeOffset: number;
|
||||
editingFolderName: string | null;
|
||||
editingFolderValue: string;
|
||||
onEditingFolderNameChange: (name: string | null) => void;
|
||||
onEditingFolderValueChange: (value: string) => void;
|
||||
onRenameFolder: (folder: string, newName: string) => Promise<void>;
|
||||
onDeploy: (cred: Credential) => void;
|
||||
onEdit: (cred: Credential) => void;
|
||||
onDelete: (cred: Credential) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`group/folder flex items-center gap-2 w-full px-3 py-2 hover:bg-muted/50 transition-colors text-left cursor-pointer ${stripeOffset % 2 === 1 ? "bg-muted/20" : ""}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3 shrink-0 text-muted-foreground/50 transition-transform ${open ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<FolderOpen
|
||||
className={`size-3.5 shrink-0 ${open ? "text-accent-brand" : "text-muted-foreground/60"}`}
|
||||
/>
|
||||
{editingFolderName === folder ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editingFolderValue}
|
||||
onChange={(e) => onEditingFolderValueChange(e.target.value)}
|
||||
onBlur={async () => {
|
||||
const newName = editingFolderValue.trim();
|
||||
onEditingFolderNameChange(null);
|
||||
if (newName && newName !== folder) {
|
||||
await onRenameFolder(folder, newName);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
if (e.key === "Escape") onEditingFolderNameChange(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[10px] font-semibold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditingFolderNameChange(null);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[13px] font-semibold text-foreground/80 truncate flex-1">
|
||||
{folder}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums shrink-0 ml-1 text-muted-foreground/40">
|
||||
{creds.length}
|
||||
</span>
|
||||
{folder !== "Uncategorized" && (
|
||||
<span
|
||||
className="opacity-0 group-hover/folder:opacity-100 transition-opacity ml-1 text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditingFolderNameChange(folder);
|
||||
onEditingFolderValueChange(folder);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-l border-border/40 ml-[30px]">
|
||||
{creds.map((cred, i) => {
|
||||
const usedByCount = allHosts.filter(
|
||||
(h) => h.credentialId === cred.id,
|
||||
).length;
|
||||
return (
|
||||
<CredentialItem
|
||||
key={cred.id}
|
||||
cred={cred}
|
||||
usedByCount={usedByCount}
|
||||
stripeIndex={stripeOffset + 1 + i}
|
||||
onDeploy={() => onDeploy(cred)}
|
||||
onEdit={() => onEdit(cred)}
|
||||
onDelete={() => onDelete(cred)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostCredentialList({
|
||||
credentialFolders,
|
||||
filteredCredentials,
|
||||
credentialsLoading,
|
||||
allHosts,
|
||||
editingFolderName,
|
||||
editingFolderValue,
|
||||
onEditingFolderNameChange,
|
||||
onEditingFolderValueChange,
|
||||
onRenameFolder,
|
||||
onDeployCredential,
|
||||
onEditCredential,
|
||||
onDeleteCredential,
|
||||
onAddCredential,
|
||||
onConfirmDialogChange,
|
||||
}: {
|
||||
credentialFolders: string[];
|
||||
filteredCredentials: Credential[];
|
||||
credentialsLoading: boolean;
|
||||
allHosts: Host[];
|
||||
editingFolderName: string | null;
|
||||
editingFolderValue: string;
|
||||
onEditingFolderNameChange: (name: string | null) => void;
|
||||
onEditingFolderValueChange: (value: string) => void;
|
||||
onRenameFolder: (folder: string, newName: string) => Promise<void>;
|
||||
onDeployCredential: (cred: Credential) => void;
|
||||
onEditCredential: (cred: Credential) => void;
|
||||
onDeleteCredential: (cred: Credential) => Promise<void>;
|
||||
onAddCredential: () => void;
|
||||
onConfirmDialogChange: (dialog: ConfirmDialog) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function handleDelete(cred: Credential) {
|
||||
onConfirmDialogChange({
|
||||
message: t("hosts.deleteCredentialConfirm", { name: cred.name }),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await onDeleteCredential(cred);
|
||||
toast.success(t("hosts.deletedCredential", { name: cred.name }));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToDeleteCredential2"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEdit(cred: Credential) {
|
||||
try {
|
||||
const full = await getCredentialDetails(Number(cred.id));
|
||||
onEditCredential({
|
||||
...cred,
|
||||
value: (
|
||||
full as CredentialWithCertificate & {
|
||||
hasKey?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
}
|
||||
).hasKey
|
||||
? "existing_key"
|
||||
: ((
|
||||
full as CredentialWithCertificate & {
|
||||
password?: string;
|
||||
}
|
||||
).password ?? ""),
|
||||
passphrase: (
|
||||
full as CredentialWithCertificate & {
|
||||
hasKeyPassword?: boolean;
|
||||
}
|
||||
).hasKeyPassword
|
||||
? "existing_key_password"
|
||||
: "",
|
||||
publicKey:
|
||||
(full as CredentialWithCertificate).certPublicKey ?? cred.publicKey,
|
||||
});
|
||||
} catch {
|
||||
onEditCredential(cred);
|
||||
}
|
||||
}
|
||||
|
||||
let globalStripe = 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col">
|
||||
{credentialFolders.map((folder) => {
|
||||
const creds = filteredCredentials.filter(
|
||||
(c) => (c.folder || "Uncategorized") === folder,
|
||||
);
|
||||
if (creds.length === 0) return null;
|
||||
const offset = globalStripe;
|
||||
globalStripe += 1 + creds.length;
|
||||
return (
|
||||
<CredentialFolderItem
|
||||
key={folder}
|
||||
folder={folder}
|
||||
creds={creds}
|
||||
allHosts={allHosts}
|
||||
stripeOffset={offset}
|
||||
editingFolderName={editingFolderName}
|
||||
editingFolderValue={editingFolderValue}
|
||||
onEditingFolderNameChange={onEditingFolderNameChange}
|
||||
onEditingFolderValueChange={onEditingFolderValueChange}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeploy={onDeployCredential}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{credentialsLoading && (
|
||||
<div className="flex flex-col px-2 py-2 space-y-1.5">
|
||||
{[60, 45, 55, 40].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="size-3 rounded-sm bg-muted/50 animate-pulse shrink-0" />
|
||||
<div
|
||||
className="h-3 rounded bg-muted/50 animate-pulse"
|
||||
style={{ width: `${w * 2}px` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-center gap-2 pt-2 text-muted-foreground/40">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span className="text-xs">{t("hosts.loadingCredentials")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!credentialsLoading && filteredCredentials.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
|
||||
<KeyRound className="size-8 text-muted-foreground/20 mb-2" />
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("hosts.noCredentialsFound")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
||||
onClick={onAddCredential}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
{t("hosts.addCredentialBtn2")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
import { TERMINAL_THEMES } from "@/lib/terminal-themes";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHostData } from "@/types";
|
||||
|
||||
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
|
||||
|
||||
export type HostProtocols = {
|
||||
enableSsh: boolean;
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
};
|
||||
|
||||
export type HostAuthType = Host["authType"];
|
||||
export type HostCursorStyle = NonNullable<
|
||||
Host["terminalConfig"]
|
||||
>["cursorStyle"];
|
||||
export type HostBellStyle = NonNullable<Host["terminalConfig"]>["bellStyle"];
|
||||
export type HostBackspaceMode = NonNullable<
|
||||
Host["terminalConfig"]
|
||||
>["backspaceMode"];
|
||||
export type HostFastScrollModifier = NonNullable<
|
||||
Host["terminalConfig"]
|
||||
>["fastScrollModifier"];
|
||||
|
||||
type SnippetListItem = {
|
||||
id: number;
|
||||
name?: string;
|
||||
title?: string;
|
||||
};
|
||||
type SnippetResponse = SnippetListItem[] | { snippets?: SnippetListItem[] };
|
||||
|
||||
export function mapSnippetResponse(
|
||||
res: unknown,
|
||||
): { id: number; name: string }[] {
|
||||
const snippetRes = res as SnippetResponse;
|
||||
return (
|
||||
Array.isArray(snippetRes) ? snippetRes : (snippetRes.snippets ?? [])
|
||||
).map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name ?? s.title ?? `Snippet ${s.id}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createHostEditorForm(host: Host | null) {
|
||||
const rawTheme = host?.terminalConfig?.theme;
|
||||
const normalizedTheme =
|
||||
!rawTheme ||
|
||||
["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes(
|
||||
rawTheme,
|
||||
)
|
||||
? "termix"
|
||||
: TERMINAL_THEMES[rawTheme]
|
||||
? rawTheme
|
||||
: "termix";
|
||||
|
||||
return {
|
||||
name: host?.name ?? "",
|
||||
ip: host?.ip ?? "",
|
||||
username: host?.username ?? "",
|
||||
sshPort: host?.sshPort ?? host?.port ?? 22,
|
||||
rdpPort: host?.rdpPort ?? 3389,
|
||||
vncPort: host?.vncPort ?? 5900,
|
||||
telnetPort: host?.telnetPort ?? 23,
|
||||
authType: host?.authType ?? "password",
|
||||
password: host?.password ?? "",
|
||||
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
|
||||
keyPassword: host?.hasKeyPassword
|
||||
? "existing_key_password"
|
||||
: (host?.keyPassword ?? ""),
|
||||
keyType: host?.keyType ?? "auto",
|
||||
keySubTab: "paste" as "paste" | "upload",
|
||||
credentialId: host?.credentialId ?? "",
|
||||
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
|
||||
folder: host?.folder ?? "",
|
||||
tags: host?.tags ?? ([] as string[]),
|
||||
tagInput: "",
|
||||
notes: host?.notes ?? "",
|
||||
pin: host?.pin ?? false,
|
||||
macAddress: host?.macAddress ?? "",
|
||||
useSocks5: host?.useSocks5 ?? false,
|
||||
socks5Host: host?.socks5Host ?? "",
|
||||
socks5Port: host?.socks5Port ?? 1080,
|
||||
socks5Username: host?.socks5Username ?? "",
|
||||
socks5Password: host?.socks5Password ?? "",
|
||||
socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0
|
||||
? "chain"
|
||||
: "single") as "single" | "chain",
|
||||
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
|
||||
enableTerminal: host?.enableTerminal ?? true,
|
||||
enableFileManager: host?.enableFileManager ?? false,
|
||||
enableDocker: host?.enableDocker ?? false,
|
||||
enableTunnel: host?.enableTunnel ?? false,
|
||||
defaultPath: host?.defaultPath ?? "/",
|
||||
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
|
||||
fontSize: host?.terminalConfig?.fontSize ?? 14,
|
||||
fontFamily:
|
||||
host?.terminalConfig?.fontFamily ?? "Caskaydia Cove Nerd Font Mono",
|
||||
theme: normalizedTheme,
|
||||
cursorStyle: (host?.terminalConfig?.cursorStyle ?? "bar") as
|
||||
| "block"
|
||||
| "underline"
|
||||
| "bar",
|
||||
cursorBlink: host?.terminalConfig?.cursorBlink ?? true,
|
||||
scrollback: host?.terminalConfig?.scrollback ?? 10000,
|
||||
letterSpacing: host?.terminalConfig?.letterSpacing ?? 0,
|
||||
lineHeight: host?.terminalConfig?.lineHeight ?? 1.0,
|
||||
bellStyle: (host?.terminalConfig?.bellStyle ?? "none") as
|
||||
| "none"
|
||||
| "sound"
|
||||
| "visual"
|
||||
| "both",
|
||||
rightClickSelectsWord: host?.terminalConfig?.rightClickSelectsWord ?? false,
|
||||
fastScrollModifier: (host?.terminalConfig?.fastScrollModifier ?? "alt") as
|
||||
| "alt"
|
||||
| "ctrl"
|
||||
| "shift",
|
||||
fastScrollSensitivity: host?.terminalConfig?.fastScrollSensitivity ?? 5,
|
||||
minimumContrastRatio: host?.terminalConfig?.minimumContrastRatio ?? 1,
|
||||
backspaceMode: (host?.terminalConfig?.backspaceMode ?? "normal") as
|
||||
| "normal"
|
||||
| "control-h",
|
||||
startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null,
|
||||
moshCommand: host?.terminalConfig?.moshCommand ?? "",
|
||||
agentForwarding: host?.terminalConfig?.agentForwarding ?? false,
|
||||
autoMosh: host?.terminalConfig?.autoMosh ?? false,
|
||||
autoTmux: host?.terminalConfig?.autoTmux ?? false,
|
||||
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
|
||||
sudoPassword: host?.terminalConfig?.sudoPassword ?? "",
|
||||
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
|
||||
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
|
||||
environmentVariables:
|
||||
host?.terminalConfig?.environmentVariables ??
|
||||
([] as { key: string; value: string }[]),
|
||||
serverTunnels: host?.serverTunnels ?? ([] as Host["serverTunnels"]),
|
||||
jumpHosts: host?.jumpHosts ?? ([] as { hostId: string }[]),
|
||||
portKnockSequence:
|
||||
host?.portKnockSequence ??
|
||||
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
|
||||
quickActions:
|
||||
host?.quickActions ?? ([] as { name: string; snippetId: string }[]),
|
||||
rdpUser: host?.rdpUser ?? "",
|
||||
rdpPassword: host?.rdpPassword ?? "",
|
||||
domain: host?.domain ?? "",
|
||||
security: host?.security ?? "",
|
||||
ignoreCert: host?.ignoreCert ?? false,
|
||||
vncPassword: host?.vncPassword ?? "",
|
||||
vncUser: host?.vncUser ?? "",
|
||||
telnetUser: host?.telnetUser ?? "",
|
||||
telnetPassword: host?.telnetPassword ?? "",
|
||||
guacamoleConfig: host?.guacamoleConfig ?? {},
|
||||
statsConfig: host?.statsConfig ?? {
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: 60,
|
||||
useGlobalStatusInterval: true,
|
||||
metricsEnabled: true,
|
||||
metricsInterval: 30,
|
||||
useGlobalMetricsInterval: true,
|
||||
enabledWidgets: [
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
"network",
|
||||
"uptime",
|
||||
"system",
|
||||
"login_stats",
|
||||
"processes",
|
||||
"ports",
|
||||
"firewall",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type HostEditorForm = ReturnType<typeof createHostEditorForm>;
|
||||
|
||||
export function buildHostEditorPayload(
|
||||
form: HostEditorForm,
|
||||
protocols: HostProtocols,
|
||||
): SSHHostData {
|
||||
return {
|
||||
connectionType: protocols.enableSsh
|
||||
? "ssh"
|
||||
: protocols.enableRdp
|
||||
? "rdp"
|
||||
: protocols.enableVnc
|
||||
? "vnc"
|
||||
: "telnet",
|
||||
name: form.name,
|
||||
ip: form.ip,
|
||||
port: protocols.enableSsh
|
||||
? Number(form.sshPort)
|
||||
: protocols.enableRdp
|
||||
? Number(form.rdpPort)
|
||||
: protocols.enableVnc
|
||||
? Number(form.vncPort)
|
||||
: Number(form.telnetPort),
|
||||
username: form.username,
|
||||
folder: form.folder,
|
||||
tags: form.tags,
|
||||
pin: form.pin,
|
||||
authType: form.authType,
|
||||
password: form.password || null,
|
||||
key: form.key === "existing_key" ? undefined : form.key || null,
|
||||
keyPassword:
|
||||
form.keyPassword === "existing_key_password"
|
||||
? undefined
|
||||
: form.keyPassword || null,
|
||||
keyType: form.keyType !== "auto" ? form.keyType : null,
|
||||
credentialId: form.credentialId ? Number(form.credentialId) : null,
|
||||
overrideCredentialUsername: form.overrideCredentialUsername,
|
||||
notes: form.notes,
|
||||
macAddress: form.macAddress || null,
|
||||
enableTerminal: form.enableTerminal,
|
||||
enableTunnel: form.enableTunnel,
|
||||
enableFileManager: form.enableFileManager,
|
||||
enableDocker: form.enableDocker,
|
||||
defaultPath: form.defaultPath || "/",
|
||||
useSocks5: form.useSocks5,
|
||||
socks5Host:
|
||||
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
|
||||
socks5Port:
|
||||
form.socks5ProxyMode === "single" ? form.socks5Port || null : null,
|
||||
socks5Username:
|
||||
form.socks5ProxyMode === "single" ? form.socks5Username || null : null,
|
||||
socks5Password:
|
||||
form.socks5ProxyMode === "single" ? form.socks5Password || null : null,
|
||||
socks5ProxyChain:
|
||||
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
|
||||
enableSsh: protocols.enableSsh,
|
||||
enableRdp: protocols.enableRdp,
|
||||
enableVnc: protocols.enableVnc,
|
||||
enableTelnet: protocols.enableTelnet,
|
||||
sshPort: Number(form.sshPort),
|
||||
rdpPort: Number(form.rdpPort),
|
||||
vncPort: Number(form.vncPort),
|
||||
telnetPort: Number(form.telnetPort),
|
||||
forceKeyboardInteractive: form.forceKeyboardInteractive,
|
||||
rdpUser: form.rdpUser || null,
|
||||
rdpPassword: form.rdpPassword || null,
|
||||
rdpDomain: form.domain || null,
|
||||
rdpSecurity: form.security || null,
|
||||
rdpIgnoreCert: form.ignoreCert,
|
||||
vncPassword: form.vncPassword || null,
|
||||
vncUser: form.vncUser || null,
|
||||
telnetUser: form.telnetUser || null,
|
||||
telnetPassword: form.telnetPassword || null,
|
||||
jumpHosts: form.jumpHosts,
|
||||
portKnockSequence: form.portKnockSequence,
|
||||
tunnelConnections: form.serverTunnels,
|
||||
quickActions: form.quickActions.map((a) => ({
|
||||
name: a.name,
|
||||
snippetId: Number(a.snippetId),
|
||||
})),
|
||||
statsConfig: form.statsConfig,
|
||||
guacamoleConfig:
|
||||
(protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet) &&
|
||||
Object.keys(form.guacamoleConfig).length > 0
|
||||
? form.guacamoleConfig
|
||||
: null,
|
||||
terminalConfig: protocols.enableSsh
|
||||
? {
|
||||
theme: form.theme,
|
||||
cursorBlink: form.cursorBlink,
|
||||
cursorStyle: form.cursorStyle,
|
||||
fontSize: Number(form.fontSize),
|
||||
fontFamily: form.fontFamily,
|
||||
scrollback: Number(form.scrollback),
|
||||
letterSpacing: Number(form.letterSpacing),
|
||||
lineHeight: Number(form.lineHeight),
|
||||
bellStyle: form.bellStyle,
|
||||
rightClickSelectsWord: form.rightClickSelectsWord,
|
||||
fastScrollModifier: form.fastScrollModifier,
|
||||
fastScrollSensitivity: Number(form.fastScrollSensitivity),
|
||||
minimumContrastRatio: Number(form.minimumContrastRatio),
|
||||
backspaceMode: form.backspaceMode,
|
||||
startupSnippetId: form.startupSnippetId ?? null,
|
||||
moshCommand: form.moshCommand || null,
|
||||
agentForwarding: form.agentForwarding,
|
||||
autoMosh: form.autoMosh,
|
||||
autoTmux: form.autoTmux,
|
||||
sudoPasswordAutoFill: form.sudoPasswordAutoFill,
|
||||
sudoPassword: form.sudoPassword || null,
|
||||
keepaliveInterval: Number(form.keepaliveInterval),
|
||||
keepaliveCountMax: Number(form.keepaliveCountMax),
|
||||
environmentVariables: form.environmentVariables,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, FolderSearch } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/input";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type { HostEditorForm } from "./HostEditorData";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
export function HostDockerTab({
|
||||
form,
|
||||
setField,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.dockerIntegration")}
|
||||
icon={<Box className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableDockerMonitor")}
|
||||
description={t("hosts.enableDockerMonitorDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableDocker}
|
||||
onChange={(v) => setField("enableDocker", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostFilesTab({
|
||||
form,
|
||||
setField,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.fileManager")}
|
||||
icon={<FolderSearch className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableFileManagerMonitor")}
|
||||
description={t("hosts.enableFileManagerMonitorDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableFileManager}
|
||||
onChange={(v) => setField("enableFileManager", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.defaultPathLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={form.defaultPath}
|
||||
onChange={(e) => setField("defaultPath", e.target.value)}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.fileManagerPathHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import {
|
||||
Globe,
|
||||
Monitor,
|
||||
MousePointerClick,
|
||||
Plus,
|
||||
Tag,
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
||||
|
||||
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
export function HostEditorGeneralTab({
|
||||
form,
|
||||
setField,
|
||||
protocols,
|
||||
handleProtocolToggle,
|
||||
hosts,
|
||||
host,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
protocols: HostProtocols;
|
||||
handleProtocolToggle: (proto: keyof HostProtocols, value: boolean) => void;
|
||||
hosts: Host[];
|
||||
host: Host | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Protocols — enable/disable each connection type */}
|
||||
<SectionCard
|
||||
title={t("hosts.protocols")}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
|
||||
{[
|
||||
{
|
||||
proto: "enableSsh" as const,
|
||||
label: t("hosts.tabSsh"),
|
||||
desc: t("hosts.secureShell"),
|
||||
icon: <Terminal className="size-4" />,
|
||||
portField: "sshPort" as const,
|
||||
},
|
||||
{
|
||||
proto: "enableRdp" as const,
|
||||
label: t("hosts.tabRdp"),
|
||||
desc: t("hosts.remoteDesktop"),
|
||||
icon: <Monitor className="size-4" />,
|
||||
portField: "rdpPort" as const,
|
||||
},
|
||||
{
|
||||
proto: "enableVnc" as const,
|
||||
label: t("hosts.tabVnc"),
|
||||
desc: t("hosts.virtualNetwork"),
|
||||
icon: <MousePointerClick className="size-4" />,
|
||||
portField: "vncPort" as const,
|
||||
},
|
||||
{
|
||||
proto: "enableTelnet" as const,
|
||||
label: t("hosts.tabTelnet"),
|
||||
desc: t("hosts.unencryptedShell"),
|
||||
icon: <Terminal className="size-4" />,
|
||||
portField: "telnetPort" as const,
|
||||
},
|
||||
].map(({ proto, label, desc, icon }) => {
|
||||
const enabled = protocols[proto];
|
||||
return (
|
||||
<div
|
||||
key={proto}
|
||||
className={`flex items-center gap-3 p-3 border transition-colors ${enabled ? "border-accent-brand/20 bg-accent-brand/5" : "border-border bg-muted/10"}`}
|
||||
>
|
||||
<div
|
||||
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span
|
||||
className={`text-xs font-bold ${enabled ? "text-foreground" : "text-muted-foreground/50"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{desc}
|
||||
</span>
|
||||
</div>
|
||||
<FakeSwitch
|
||||
checked={enabled}
|
||||
onChange={(v: boolean) => handleProtocolToggle(proto, v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.connectionDetails")}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.addressIp")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="10.0.0.1 or example.com"
|
||||
value={form.ip}
|
||||
onChange={(e) => setField("ip", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.friendlyName")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Web Server Production"
|
||||
value={form.name}
|
||||
onChange={(e) => setField("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{protocols.enableSsh && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
MAC Address
|
||||
</label>
|
||||
<Input
|
||||
placeholder="AA:BB:CC:DD:EE:FF"
|
||||
value={form.macAddress}
|
||||
onChange={(e) => setField("macAddress", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{!protocols.enableSsh &&
|
||||
!protocols.enableRdp &&
|
||||
!protocols.enableVnc &&
|
||||
!protocols.enableTelnet && (
|
||||
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
|
||||
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
|
||||
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.folderAndAdvanced")}
|
||||
icon={<Tag className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.folder")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Production"
|
||||
value={form.folder}
|
||||
onChange={(e) => setField("folder", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.tags")}
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
|
||||
{form.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"tags",
|
||||
form.tags.filter((tg) => tg !== tag),
|
||||
)
|
||||
}
|
||||
className="text-muted-foreground hover:text-destructive ml-0.5"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder={form.tags.length === 0 ? t("hosts.addTag") : ""}
|
||||
value={form.tagInput}
|
||||
onChange={(e) => setField("tagInput", e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.key === " " || e.key === "Enter") &&
|
||||
form.tagInput.trim()
|
||||
) {
|
||||
e.preventDefault();
|
||||
const tag = form.tagInput.trim();
|
||||
if (!form.tags.includes(tag))
|
||||
setField("tags", [...form.tags, tag]);
|
||||
setField("tagInput", "");
|
||||
} else if (
|
||||
e.key === "Backspace" &&
|
||||
!form.tagInput &&
|
||||
form.tags.length > 0
|
||||
) {
|
||||
setField("tags", form.tags.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.privateNotes")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder={t("hosts.privateNotesPlaceholder")}
|
||||
className="w-full px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
value={form.notes}
|
||||
onChange={(e) => setField("notes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.pinToTop")}
|
||||
description={t("hosts.pinToTopDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.pin}
|
||||
onChange={(v) => setField("pin", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-4 pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.portKnockingSequence")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
onClick={() =>
|
||||
setField("portKnockSequence", [
|
||||
...form.portKnockSequence,
|
||||
{ port: 0, protocol: "tcp" as const, delay: 0 },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.addKnockBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
{form.portKnockSequence.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground/50">
|
||||
{t("hosts.noPortKnocking")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.portKnockSequence.map((knock, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-end gap-1.5 p-1.5 bg-muted/30 border border-border"
|
||||
>
|
||||
<span className="text-[9px] font-bold text-muted-foreground/50 mb-1.5 shrink-0">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
|
||||
{t("hosts.knockPort")}
|
||||
</span>
|
||||
<Input
|
||||
className="h-7 text-xs w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
placeholder="8080"
|
||||
type="number"
|
||||
value={knock.port}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.portKnockSequence];
|
||||
updated[i] = {
|
||||
...updated[i],
|
||||
port: Number(e.target.value),
|
||||
};
|
||||
setField("portKnockSequence", updated);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
|
||||
{t("hosts.protocol")}
|
||||
</span>
|
||||
<select
|
||||
className="h-7 text-[10px] bg-background border border-border px-1"
|
||||
value={knock.protocol}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.portKnockSequence];
|
||||
updated[i] = {
|
||||
...updated[i],
|
||||
protocol: e.target.value as "tcp" | "udp",
|
||||
};
|
||||
setField("portKnockSequence", updated);
|
||||
}}
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-muted-foreground/60 uppercase font-bold tracking-wide px-0.5">
|
||||
{t("hosts.delayAfterMs")}
|
||||
</span>
|
||||
<Input
|
||||
className="h-7 text-xs w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
placeholder="100"
|
||||
type="number"
|
||||
value={knock.delay}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.portKnockSequence];
|
||||
updated[i] = {
|
||||
...updated[i],
|
||||
delay: Number(e.target.value),
|
||||
};
|
||||
setField("portKnockSequence", updated);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="text-destructive p-1 mb-0.5"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"portKnockSequence",
|
||||
form.portKnockSequence.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 border-t border-border pt-4 pb-2">
|
||||
<SettingRow
|
||||
label={t("hosts.useSocks5Proxy")}
|
||||
description={t("hosts.useSocks5ProxyDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.useSocks5}
|
||||
onChange={(v) => setField("useSocks5", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.useSocks5 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Single / Chain mode toggle */}
|
||||
<div className="flex gap-2">
|
||||
{(["single", "chain"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setField("socks5ProxyMode", m)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${form.socks5ProxyMode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{m === "single"
|
||||
? t("hosts.proxySingleMode")
|
||||
: t("hosts.proxyChainMode")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.socks5ProxyMode === "single" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3 bg-muted/20 border border-border">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyHost")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder="proxy.example.com"
|
||||
value={form.socks5Host}
|
||||
onChange={(e) => setField("socks5Host", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPort")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
placeholder="1080"
|
||||
value={form.socks5Port}
|
||||
onChange={(e) =>
|
||||
setField("socks5Port", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyUsername")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={form.socks5Username}
|
||||
onChange={(e) =>
|
||||
setField("socks5Username", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPassword")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-7 text-xs pr-8"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={form.socks5Password}
|
||||
onChange={(e) =>
|
||||
setField("socks5Password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.socks5ProxyMode === "chain" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.socks5ProxyChain.map((node, ni) => (
|
||||
<div
|
||||
key={ni}
|
||||
className="flex flex-col gap-2 p-3 bg-muted/20 border border-border"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.proxyNode")} {ni + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"socks5ProxyChain",
|
||||
form.socks5ProxyChain.filter(
|
||||
(_, idx) => idx !== ni,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyHost")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder="proxy.example.com"
|
||||
value={node.host}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = { ...u[ni], host: e.target.value };
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPort")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
type="number"
|
||||
placeholder="1080"
|
||||
value={node.port}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = {
|
||||
...u[ni],
|
||||
port: Number(e.target.value),
|
||||
};
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyType")}
|
||||
</label>
|
||||
<select
|
||||
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
value={node.type}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = { ...u[ni], type: e.target.value };
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="socks4">SOCKS4</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyUsername")}
|
||||
</label>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={node.username}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = {
|
||||
...u[ni],
|
||||
username: e.target.value,
|
||||
};
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 col-span-2">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPassword")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-7 text-xs pr-8"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={node.password}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = {
|
||||
...u[ni],
|
||||
password: e.target.value,
|
||||
};
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand self-start"
|
||||
onClick={() =>
|
||||
setField("socks5ProxyChain", [
|
||||
...form.socks5ProxyChain,
|
||||
{
|
||||
host: "",
|
||||
port: 1080,
|
||||
type: "socks5",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.addProxyNode")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection path visualization */}
|
||||
{(form.socks5ProxyMode === "single" && form.socks5Host) ||
|
||||
(form.socks5ProxyMode === "chain" &&
|
||||
form.socks5ProxyChain.length > 0) ? (
|
||||
<div className="flex items-center gap-1 flex-wrap p-2 bg-muted/30 border border-border text-[10px]">
|
||||
<span className="px-2 py-0.5 bg-background border border-border text-foreground font-mono">
|
||||
{t("hosts.you")}
|
||||
</span>
|
||||
{form.socks5ProxyMode === "single" && form.socks5Host ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
|
||||
{form.socks5Host}:{form.socks5Port}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
form.socks5ProxyChain
|
||||
.filter((n) => n.host)
|
||||
.map((n, ni) => (
|
||||
<React.Fragment key={ni}>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
|
||||
{n.host}:{n.port}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
{form.jumpHosts
|
||||
.filter((j) => j.hostId)
|
||||
.map((j, ji) => {
|
||||
const jh = hosts.find((h) => h.id === j.hostId);
|
||||
return jh ? (
|
||||
<React.Fragment key={`j${ji}`}>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="px-2 py-0.5 bg-muted border border-border text-muted-foreground font-mono">
|
||||
{jh.name || jh.ip}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : null;
|
||||
})}
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="px-2 py-0.5 bg-accent-brand/10 border border-accent-brand/30 text-accent-brand font-mono">
|
||||
{form.ip || "target"}:{form.sshPort}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.jumpHostChainLabel")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
onClick={() =>
|
||||
setField("jumpHosts", [...form.jumpHosts, { hostId: "" }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.addJumpBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
{form.jumpHosts.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground/50">
|
||||
{t("hosts.noJumpHosts")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.jumpHosts.map((jh, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 p-2 bg-background border border-border"
|
||||
>
|
||||
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<select
|
||||
className="flex h-7 flex-1 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={jh.hostId}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.jumpHosts];
|
||||
updated[i] = { hostId: e.target.value };
|
||||
setField("jumpHosts", updated);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("hosts.selectAServer")}</option>
|
||||
{hosts
|
||||
.filter((h) => (host ? h.id !== host.id : true))
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"jumpHosts",
|
||||
form.jumpHosts.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Activity,
|
||||
LayoutDashboard,
|
||||
Plus,
|
||||
Server,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type { HostEditorForm } from "./HostEditorData";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
export function HostStatsTab({
|
||||
form,
|
||||
setField,
|
||||
snippets,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
snippets: { id: number; name: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.statusChecksLabel")}
|
||||
icon={<Activity className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-0 py-1">
|
||||
<SettingRow
|
||||
label={t("hosts.enableStatusChecks")}
|
||||
description={t("hosts.enableStatusChecksDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.statusCheckEnabled}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckEnabled: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalInterval")}
|
||||
description={t("hosts.useGlobalIntervalDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.useGlobalStatusInterval}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
useGlobalStatusInterval: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.statsConfig.statusCheckEnabled &&
|
||||
!form.statsConfig.useGlobalStatusInterval && (
|
||||
<SettingRow
|
||||
label={t("hosts.checkIntervalS")}
|
||||
description={t("hosts.checkIntervalDesc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.statsConfig.statusCheckInterval}
|
||||
onChange={(e) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.metricsCollectionLabel")}
|
||||
icon={<Server className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-0 py-1">
|
||||
<SettingRow
|
||||
label={t("hosts.enableMetricsLabel")}
|
||||
description={t("hosts.enableMetricsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.metricsEnabled}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
metricsEnabled: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalMetrics")}
|
||||
description={t("hosts.useGlobalMetricsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.useGlobalMetricsInterval}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
useGlobalMetricsInterval: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.statsConfig.metricsEnabled &&
|
||||
!form.statsConfig.useGlobalMetricsInterval && (
|
||||
<SettingRow
|
||||
label={t("hosts.metricsIntervalS")}
|
||||
description={t("hosts.metricsIntervalDesc2")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.statsConfig.metricsInterval}
|
||||
onChange={(e) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
metricsInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.visibleWidgets")}
|
||||
icon={<LayoutDashboard className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-0 py-1">
|
||||
{[
|
||||
{
|
||||
id: "cpu",
|
||||
label: t("hosts.cpuUsageLabel"),
|
||||
desc: t("hosts.cpuUsageDesc"),
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
label: t("hosts.memoryLabel"),
|
||||
desc: t("hosts.memoryDesc"),
|
||||
},
|
||||
{
|
||||
id: "disk",
|
||||
label: t("hosts.storageLabel"),
|
||||
desc: t("hosts.storageDesc"),
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
label: t("hosts.networkLabel"),
|
||||
desc: t("hosts.networkDesc"),
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
label: t("hosts.uptimeLabel"),
|
||||
desc: t("hosts.uptimeDesc"),
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
label: t("hosts.systemInfoLabel"),
|
||||
desc: t("hosts.systemInfoDesc"),
|
||||
},
|
||||
{
|
||||
id: "login_stats",
|
||||
label: t("hosts.recentLoginsLabel"),
|
||||
desc: t("hosts.recentLoginsDesc"),
|
||||
},
|
||||
{
|
||||
id: "processes",
|
||||
label: t("hosts.topProcessesLabel"),
|
||||
desc: t("hosts.topProcessesDesc"),
|
||||
},
|
||||
{
|
||||
id: "ports",
|
||||
label: t("hosts.listeningPortsLabel"),
|
||||
desc: t("hosts.listeningPortsDesc"),
|
||||
},
|
||||
{
|
||||
id: "firewall",
|
||||
label: t("hosts.firewallLabel"),
|
||||
desc: t("hosts.firewallDesc"),
|
||||
},
|
||||
].map((w) => (
|
||||
<SettingRow key={w.id} label={w.label} description={w.desc}>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.enabledWidgets.includes(w.id)}
|
||||
onChange={(v) => {
|
||||
const widgets = v
|
||||
? [...form.statsConfig.enabledWidgets, w.id]
|
||||
: form.statsConfig.enabledWidgets.filter((x) => x !== w.id);
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
enabledWidgets: widgets,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.quickActionsLabel")}
|
||||
icon={<Zap className="size-3.5" />}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
onClick={() =>
|
||||
setField("quickActions", [
|
||||
...form.quickActions,
|
||||
{ name: "", snippetId: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.addActionBtn")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hosts.quickActionsToolbar")}
|
||||
</p>
|
||||
{form.quickActions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
|
||||
<Zap className="size-6" />
|
||||
<span className="text-xs">{t("hosts.noQuickActions")}</span>
|
||||
</div>
|
||||
)}
|
||||
{form.quickActions.map((a, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
|
||||
>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={t("hosts.buttonLabel")}
|
||||
value={a.name}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.quickActions];
|
||||
updated[i] = { ...updated[i], name: e.target.value };
|
||||
setField("quickActions", updated);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
value={a.snippetId}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.quickActions];
|
||||
updated[i] = {
|
||||
...updated[i],
|
||||
snippetId: e.target.value,
|
||||
};
|
||||
setField("quickActions", updated);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("hosts.selectSnippetPlaceholder")}</option>
|
||||
{snippets.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"quickActions",
|
||||
form.quickActions.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+63
-4920
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host, Credential } from "@/types/ui-types";
|
||||
|
||||
type RawSSHHost = SSHHostWithStatus & {
|
||||
hasKey?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
};
|
||||
type HostQuickAction = Host["quickActions"][number];
|
||||
type HostJumpHost = NonNullable<Host["jumpHosts"]>[number];
|
||||
type RawCredential = {
|
||||
id: number | string;
|
||||
name: string;
|
||||
username: string;
|
||||
authType?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
tags?: string[];
|
||||
publicKey?: string | null;
|
||||
};
|
||||
|
||||
function parseJson<T>(v: unknown): T | undefined {
|
||||
if (!v) return undefined;
|
||||
if (typeof v === "string") {
|
||||
try {
|
||||
return JSON.parse(v) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return v as T;
|
||||
}
|
||||
|
||||
export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
const host = h as RawSSHHost;
|
||||
return {
|
||||
id: String(h.id),
|
||||
name: h.name,
|
||||
username: h.username,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
folder: h.folder ?? "",
|
||||
online: h.status === "online",
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "",
|
||||
tags: h.tags ?? [],
|
||||
authType: h.authType,
|
||||
password: h.password,
|
||||
hasKey: !!host.hasKey || !!(typeof h.key === "string" && h.key),
|
||||
hasKeyPassword: !!host.hasKeyPassword || !!h.keyPassword,
|
||||
key: typeof h.key === "string" ? h.key : undefined,
|
||||
keyPassword: h.keyPassword,
|
||||
keyType: h.keyType,
|
||||
credentialId: h.credentialId != null ? String(h.credentialId) : undefined,
|
||||
notes: h.notes,
|
||||
pin: h.pin ?? false,
|
||||
macAddress: h.macAddress,
|
||||
enableSsh: h.enableSsh != null ? h.enableSsh : h.connectionType === "ssh",
|
||||
enableTerminal:
|
||||
h.enableTerminal ??
|
||||
(h.enableSsh != null ? h.enableSsh : h.connectionType === "ssh"),
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableRdp: h.enableRdp != null ? h.enableRdp : h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc != null ? h.enableVnc : h.connectionType === "vnc",
|
||||
enableTelnet:
|
||||
h.enableTelnet != null ? h.enableTelnet : h.connectionType === "telnet",
|
||||
sshPort:
|
||||
h.sshPort ??
|
||||
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
|
||||
rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389),
|
||||
vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900),
|
||||
telnetPort: h.telnetPort ?? (h.connectionType === "telnet" ? h.port : 23),
|
||||
rdpUser: h.rdpUser,
|
||||
rdpPassword: h.rdpPassword ?? "",
|
||||
domain: h.rdpDomain,
|
||||
security: h.rdpSecurity,
|
||||
ignoreCert: h.rdpIgnoreCert ?? false,
|
||||
vncPassword: h.vncPassword ?? "",
|
||||
vncUser: h.vncUser,
|
||||
telnetUser: h.telnetUser,
|
||||
telnetPassword: h.telnetPassword ?? "",
|
||||
quickActions: (h.quickActions ?? []).map((a: HostQuickAction) => ({
|
||||
name: a.name,
|
||||
snippetId: String(a.snippetId),
|
||||
})),
|
||||
serverTunnels: parseJson(h.tunnelConnections) ?? [],
|
||||
jumpHosts: (parseJson<HostJumpHost[]>(h.jumpHosts) ?? []).map((j) => ({
|
||||
hostId: String(j.hostId ?? j.hostid ?? j),
|
||||
})),
|
||||
portKnockSequence: parseJson(h.portKnockSequence) ?? [],
|
||||
defaultPath: h.defaultPath,
|
||||
terminalConfig: parseJson(h.terminalConfig) as Host["terminalConfig"],
|
||||
statsConfig: parseJson(h.statsConfig) as Host["statsConfig"],
|
||||
guacamoleConfig: parseJson(h.guacamoleConfig),
|
||||
forceKeyboardInteractive: h.forceKeyboardInteractive ?? false,
|
||||
useSocks5: h.useSocks5,
|
||||
socks5Host: h.socks5Host,
|
||||
socks5Port: h.socks5Port,
|
||||
socks5Username: h.socks5Username,
|
||||
socks5Password: h.socks5Password,
|
||||
socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [],
|
||||
overrideCredentialUsername: h.overrideCredentialUsername ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapCredentials(res: unknown): Credential[] {
|
||||
const arr = Array.isArray(res) ? res : [];
|
||||
return (arr as RawCredential[]).map((c) => ({
|
||||
id: String(c.id),
|
||||
name: c.name,
|
||||
username: c.username,
|
||||
type: c.authType === "key" ? "key" : "password",
|
||||
description: c.description ?? "",
|
||||
folder: c.folder ?? "",
|
||||
tags: c.tags ?? [],
|
||||
publicKey: c.publicKey ?? undefined,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Box,
|
||||
Folder,
|
||||
KeyRound,
|
||||
Monitor,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
|
||||
export type HostTabId =
|
||||
| "general"
|
||||
| "ssh"
|
||||
| "tunnels"
|
||||
| "docker"
|
||||
| "files"
|
||||
| "stats"
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
export type CredentialTabId = "general" | "auth";
|
||||
|
||||
type HostTab = {
|
||||
id: HostTabId;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
type CredentialTab = {
|
||||
id: CredentialTabId;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export function makeHostTabs(t: (key: string) => string): HostTab[] {
|
||||
return [
|
||||
{
|
||||
id: "general",
|
||||
label: t("hosts.tabGeneral"),
|
||||
icon: <Settings className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "ssh",
|
||||
label: t("hosts.tabSsh"),
|
||||
icon: <Terminal className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "tunnels",
|
||||
label: t("hosts.tabTunnels"),
|
||||
icon: <Network className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "docker",
|
||||
label: t("hosts.tabDocker"),
|
||||
icon: <Box className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "files",
|
||||
label: t("hosts.tabFiles"),
|
||||
icon: <Folder className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
label: t("hosts.tabStats"),
|
||||
icon: <Activity className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "rdp",
|
||||
label: t("hosts.tabRdp"),
|
||||
icon: <Monitor className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "vnc",
|
||||
label: t("hosts.tabVnc"),
|
||||
icon: <MousePointerClick className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "telnet",
|
||||
label: t("hosts.tabTelnet"),
|
||||
icon: <Terminal className="size-3" />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function makeCredentialTabs(
|
||||
t: (key: string) => string,
|
||||
): CredentialTab[] {
|
||||
return [
|
||||
{
|
||||
id: "general",
|
||||
label: t("hosts.tabGeneral"),
|
||||
icon: <Settings className="size-3" />,
|
||||
},
|
||||
{
|
||||
id: "auth",
|
||||
label: t("hosts.tabAuthentication"),
|
||||
icon: <KeyRound className="size-3" />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SSH_DEP_TABS = new Set(["tunnels", "docker", "files", "stats"]);
|
||||
|
||||
export function TabStrip({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: {
|
||||
tabs: { id: string; label: string; icon: ReactNode }[];
|
||||
activeTab: string;
|
||||
onTabChange: (id: string) => void;
|
||||
}) {
|
||||
const hasSshGroup = tabs.some((t) => SSH_DEP_TABS.has(t.id));
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY === 0) return;
|
||||
e.preventDefault();
|
||||
el.scrollLeft += e.deltaY;
|
||||
};
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, []);
|
||||
|
||||
const nonSshTabs = tabs.filter((t) => !SSH_DEP_TABS.has(t.id));
|
||||
const sshDepTabs = tabs.filter((t) => SSH_DEP_TABS.has(t.id));
|
||||
|
||||
const renderTab = (tab: (typeof tabs)[0]) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors shrink-0 ${
|
||||
activeTab === tab.id
|
||||
? "border-accent-brand text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="overflow-x-auto">
|
||||
<div className="flex min-w-max">
|
||||
{nonSshTabs.map(renderTab)}
|
||||
{hasSshGroup && sshDepTabs.length > 0 && (
|
||||
<div className="flex flex-col border-l border-border/40 ml-0.5">
|
||||
<div className="flex items-center gap-1 px-2 pt-0.5">
|
||||
<Terminal className="size-2.5 text-muted-foreground/30" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/30">
|
||||
SSH
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">{sshDepTabs.map(renderTab)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
revokeHostAccess,
|
||||
getUserList,
|
||||
getRoles,
|
||||
type AccessRecord,
|
||||
} from "@/main-axios";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
@@ -35,7 +36,7 @@ export function HostShareModal({
|
||||
const [shareType, setShareType] = useState<"user" | "role">("user");
|
||||
const [shareGranteeId, setShareGranteeId] = useState("");
|
||||
const [shareExpiryHours, setShareExpiryHours] = useState("");
|
||||
const [accessList, setAccessList] = useState<any[]>([]);
|
||||
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
|
||||
const [shareUsers, setShareUsers] = useState<
|
||||
{ id: string; username: string }[]
|
||||
>([]);
|
||||
@@ -55,15 +56,15 @@ export function HostShareModal({
|
||||
getRoles().catch(() => ({ roles: [] })),
|
||||
])
|
||||
.then(([accessRes, usersRes, rolesRes]) => {
|
||||
setAccessList((accessRes as any)?.accessList ?? []);
|
||||
setAccessList(accessRes.accessList ?? []);
|
||||
setShareUsers(
|
||||
((usersRes as any)?.users ?? []).map((u: any) => ({
|
||||
(usersRes.users ?? []).map((u) => ({
|
||||
id: String(u.id ?? u.userId),
|
||||
username: u.username,
|
||||
})),
|
||||
);
|
||||
setShareRoles(
|
||||
((rolesRes as any)?.roles ?? []).map((r: any) => ({
|
||||
(rolesRes.roles ?? []).map((r) => ({
|
||||
id: String(r.id),
|
||||
name: r.name,
|
||||
})),
|
||||
@@ -84,7 +85,7 @@ export function HostShareModal({
|
||||
async function refreshAccessList() {
|
||||
if (!host) return;
|
||||
const res = await getHostAccess(Number(host.id));
|
||||
setAccessList((res as any)?.accessList ?? []);
|
||||
setAccessList(res.accessList ?? []);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
@@ -233,7 +234,7 @@ export function HostShareModal({
|
||||
{t("hosts.guac.noAccessEntries")}
|
||||
</div>
|
||||
)}
|
||||
{accessList.map((r: any, i: number) => {
|
||||
{accessList.map((r, i) => {
|
||||
const expired =
|
||||
r.expiresAt && new Date(r.expiresAt) < new Date();
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { getSSHHosts, bulkImportSSHHosts } from "@/main-axios";
|
||||
import { getSSHHosts, bulkImportSSHHosts, exportAllSSHHosts } from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
|
||||
@@ -102,7 +102,12 @@ function hostPassesFilters(host: Host, filters: FilterState): boolean {
|
||||
if (!ok) return false;
|
||||
}
|
||||
if (filters.authType.length > 0) {
|
||||
if (!filters.authType.includes(host.authType as FilterState["authType"][number])) return false;
|
||||
if (
|
||||
!filters.authType.includes(
|
||||
host.authType as FilterState["authType"][number],
|
||||
)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
if (filters.protocol.length > 0) {
|
||||
const ok =
|
||||
@@ -235,18 +240,23 @@ export function HostsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportHosts() {
|
||||
const data = JSON.stringify({ hosts: rawHosts }, null, 2);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-hosts.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("hosts.hostsExported"));
|
||||
async function handleExportHosts() {
|
||||
try {
|
||||
const result = await exportAllSSHHosts();
|
||||
const data = JSON.stringify(result, null, 2);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-hosts.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("hosts.hostsExported"));
|
||||
} catch {
|
||||
toast.error(t("hosts.exportFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadSample() {
|
||||
@@ -355,14 +365,17 @@ export function HostsPanel({
|
||||
toast.error("Cannot import more than 100 hosts at once");
|
||||
return;
|
||||
}
|
||||
const normalized = hostsArray.map((h: any) => ({
|
||||
...h,
|
||||
port: h.port ?? h.sshPort ?? 22,
|
||||
enableSsh: h.enableSsh ?? h.connectionType === "ssh",
|
||||
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
||||
enableTelnet: h.enableTelnet ?? h.connectionType === "telnet",
|
||||
}));
|
||||
const normalized = hostsArray.map(
|
||||
(h: Record<string, unknown>) => ({
|
||||
...h,
|
||||
port: h.port ?? h.sshPort ?? 22,
|
||||
enableSsh: h.enableSsh ?? h.connectionType === "ssh",
|
||||
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
|
||||
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
|
||||
enableTelnet:
|
||||
h.enableTelnet ?? h.connectionType === "telnet",
|
||||
}),
|
||||
);
|
||||
const result = await bulkImportSSHHosts(
|
||||
normalized,
|
||||
importOverwriteRef.current,
|
||||
@@ -378,8 +391,10 @@ export function HostsPanel({
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
toast.success(`Import complete: ${msg}`);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? "Failed to import hosts");
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to import hosts",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -562,7 +577,9 @@ export function HostsPanel({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel>{t("hosts.filterStatusGroup")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("hosts.filterStatusGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{(["online", "offline", "pinned"] as const).map((val) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
@@ -570,23 +587,35 @@ export function HostsPanel({
|
||||
onCheckedChange={() => handleFilterToggle("status", val)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t(`hosts.filter${val.charAt(0).toUpperCase() + val.slice(1)}`)}
|
||||
{t(
|
||||
`hosts.filter${val.charAt(0).toUpperCase() + val.slice(1)}`,
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("hosts.filterAuthGroup")}</DropdownMenuLabel>
|
||||
{(["password", "key", "credential", "none", "opkssh"] as const).map((val) => (
|
||||
<DropdownMenuLabel>
|
||||
{t("hosts.filterAuthGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{(
|
||||
["password", "key", "credential", "none", "opkssh"] as const
|
||||
).map((val) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
checked={filterState.authType.includes(val)}
|
||||
onCheckedChange={() => handleFilterToggle("authType", val)}
|
||||
onCheckedChange={() =>
|
||||
handleFilterToggle("authType", val)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t(`hosts.filterAuth${val.charAt(0).toUpperCase() + val.slice(1)}`)}
|
||||
{t(
|
||||
`hosts.filterAuth${val.charAt(0).toUpperCase() + val.slice(1)}`,
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("hosts.filterProtocolGroup")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("hosts.filterProtocolGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{(
|
||||
[
|
||||
["ssh", "Ssh"],
|
||||
@@ -598,14 +627,18 @@ export function HostsPanel({
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
checked={filterState.protocol.includes(val)}
|
||||
onCheckedChange={() => handleFilterToggle("protocol", val)}
|
||||
onCheckedChange={() =>
|
||||
handleFilterToggle("protocol", val)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t(`hosts.filterProtocol${key}`)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("hosts.filterFeaturesGroup")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("hosts.filterFeaturesGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{(
|
||||
[
|
||||
["terminal", "Terminal"],
|
||||
@@ -617,7 +650,9 @@ export function HostsPanel({
|
||||
<DropdownMenuCheckboxItem
|
||||
key={val}
|
||||
checked={filterState.features.includes(val)}
|
||||
onCheckedChange={() => handleFilterToggle("features", val)}
|
||||
onCheckedChange={() =>
|
||||
handleFilterToggle("features", val)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t(`hosts.filterFeature${key}`)}
|
||||
@@ -626,12 +661,16 @@ export function HostsPanel({
|
||||
{allTags.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("hosts.filterTagsGroup")}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("hosts.filterTagsGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{allTags.map((tag) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tag}
|
||||
checked={filterState.tags.includes(tag)}
|
||||
onCheckedChange={() => handleFilterToggle("tags", tag)}
|
||||
onCheckedChange={() =>
|
||||
handleFilterToggle("tags", tag)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{tag}
|
||||
@@ -660,7 +699,12 @@ export function HostsPanel({
|
||||
className={`flex flex-col flex-1 min-h-0 ${managerEditing ? "hidden" : ""}`}
|
||||
>
|
||||
<SidebarTree
|
||||
children={hostTree ? applyFilters(sortHostTree(hostTree, sortKey), filterState).children : []}
|
||||
children={
|
||||
hostTree
|
||||
? applyFilters(sortHostTree(hostTree, sortKey), filterState)
|
||||
.children
|
||||
: []
|
||||
}
|
||||
onOpenTab={onOpenTab}
|
||||
onEditHost={onEditHost}
|
||||
onShareHost={(host) => setShareModalHost(host)}
|
||||
|
||||
+383
-104
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useState, useEffect, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Box,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
Cpu,
|
||||
FolderOpen,
|
||||
FolderSearch,
|
||||
Key,
|
||||
Link,
|
||||
Loader2,
|
||||
MemoryStick,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
Share2,
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -41,9 +44,12 @@ import {
|
||||
bulkUpdateSSHHosts,
|
||||
createSSHHost,
|
||||
deleteSSHHost,
|
||||
getHostPassword,
|
||||
renameFolder,
|
||||
wakeOnLan,
|
||||
} from "@/main-axios";
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
import type { SSHHostData } from "@/types/index";
|
||||
|
||||
export function isFolder(item: Host | HostFolder): item is HostFolder {
|
||||
return "children" in item;
|
||||
@@ -159,6 +165,38 @@ function collectAllFolders(children: (Host | HostFolder)[]): string[] {
|
||||
return Array.from(names).sort();
|
||||
}
|
||||
|
||||
async function writeClipboardText(value: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
function canCopyHostPassword(host: Host): boolean {
|
||||
return (
|
||||
host.authType === "password" ||
|
||||
host.authType === "credential" ||
|
||||
!!host.hasPassword ||
|
||||
!!host.password
|
||||
);
|
||||
}
|
||||
|
||||
function canCopyHostSudoPassword(host: Host): boolean {
|
||||
return (
|
||||
!!host.hasSudoPassword ||
|
||||
!!host.sudoPassword ||
|
||||
!!host.terminalConfig?.sudoPassword
|
||||
);
|
||||
}
|
||||
|
||||
function folderHostCount(folder: HostFolder): {
|
||||
total: number;
|
||||
online: number;
|
||||
@@ -221,6 +259,30 @@ export function HostItem({
|
||||
const v = localStorage.getItem("showHostTags");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const isTouchOnly =
|
||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||
const shouldUseClickTray = trayOnClick || isTouchOnly;
|
||||
const showPasswordCopy = canCopyHostPassword(host);
|
||||
const showSudoPasswordCopy = canCopyHostSudoPassword(host);
|
||||
|
||||
async function handleCopyPassword(
|
||||
e: MouseEvent,
|
||||
field: "password" | "sudoPassword",
|
||||
) {
|
||||
e.stopPropagation();
|
||||
const password = await getHostPassword(Number(host.id), field);
|
||||
if (!password) {
|
||||
toast.error(t("nav.failedToCopyPassword"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await writeClipboardText(password);
|
||||
toast.success(t("nav.passwordCopied"));
|
||||
} catch {
|
||||
toast.error(t("nav.failedToCopyPassword"));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () =>
|
||||
@@ -262,16 +324,12 @@ export function HostItem({
|
||||
onToggleSelect?.();
|
||||
return;
|
||||
}
|
||||
// On touch devices open the action tray instead of immediately launching a tab
|
||||
if (window.matchMedia("(hover: none)").matches) {
|
||||
// Touch devices open the tray instead of launching
|
||||
if (isTouchOnly) {
|
||||
e.stopPropagation();
|
||||
onTrayOpenChange?.(!isTrayOpen);
|
||||
return;
|
||||
}
|
||||
if (trayOnClick) {
|
||||
onTrayOpenChange?.(!isTrayOpen);
|
||||
return;
|
||||
}
|
||||
if (host.enableSsh) onOpenTab("terminal");
|
||||
else if (host.enableRdp) onOpenTab("rdp");
|
||||
else if (host.enableVnc) onOpenTab("vnc");
|
||||
@@ -303,12 +361,28 @@ export function HostItem({
|
||||
{host.pin && (
|
||||
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
||||
)}
|
||||
{!selectionMode && shouldUseClickTray && (
|
||||
<button
|
||||
title={
|
||||
isTrayOpen
|
||||
? t("hosts.collapseActions")
|
||||
: t("hosts.expandActions")
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTrayOpenChange?.(!isTrayOpen);
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center size-5 rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3 transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address — only visible on hover (or click when trayOnClick) or while menu is open */}
|
||||
<span
|
||||
className={`text-[11px] text-muted-foreground/55 truncate leading-none pl-3 transition-opacity duration-100 ${!trayOnClick ? "group-hover:opacity-100 group-hover:h-auto" : ""} ${isMenuOpen || (trayOnClick && isTrayOpen) ? "opacity-100 h-auto" : "opacity-0 h-0 overflow-hidden"}`}
|
||||
>
|
||||
{/* Address — always visible */}
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
|
||||
@@ -331,9 +405,88 @@ export function HostItem({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action tray — slides open on CSS hover, on click (when trayOnClick), or while menu is open */}
|
||||
{/* Connection buttons — always visible in click-tray mode, inside hover tray otherwise */}
|
||||
{shouldUseClickTray && !selectionMode && (
|
||||
<div className="flex items-center flex-wrap gap-1 pl-2 pb-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab(type);
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
{host.enableSsh &&
|
||||
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
|
||||
getSshActions(host).length > 0 && (
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
)}
|
||||
{host.enableRdp && (
|
||||
<button
|
||||
title="RDP"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("rdp");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Monitor className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableVnc && (
|
||||
<button
|
||||
title="VNC"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("vnc");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MousePointerClick className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableTelnet && (
|
||||
<button
|
||||
title="Telnet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("telnet");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MessagesSquare className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<button
|
||||
title={t("hosts.wakeOnLanAction")}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(
|
||||
t("hosts.wakeOnLanSuccess", { name: host.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.wakeOnLanError"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action tray — slides open on hover (default) or via chevron in click-tray mode */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!trayOnClick ? "group-hover:max-h-[300px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (trayOnClick && isTrayOpen)) && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!shouldUseClickTray ? "group-hover:max-h-[300px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (shouldUseClickTray && isTrayOpen)) && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
|
||||
>
|
||||
{host.online &&
|
||||
((host.cpu != null && host.cpu > 0) ||
|
||||
@@ -371,85 +524,105 @@ export function HostItem({
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 pt-1.5 pl-2 pb-1">
|
||||
{/* Connection buttons — wrap naturally to a second line */}
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab(type);
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
{host.enableSsh &&
|
||||
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
|
||||
getSshActions(host).length > 0 && (
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
{/* Connection buttons — only shown here in hover mode */}
|
||||
{!shouldUseClickTray && (
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab(type);
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
{host.enableSsh &&
|
||||
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
|
||||
getSshActions(host).length > 0 && (
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
)}
|
||||
{host.enableRdp && (
|
||||
<button
|
||||
title="RDP"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("rdp");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Monitor className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableRdp && (
|
||||
<button
|
||||
title="RDP"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("rdp");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Monitor className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableVnc && (
|
||||
<button
|
||||
title="VNC"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("vnc");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MousePointerClick className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableTelnet && (
|
||||
<button
|
||||
title="Telnet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("telnet");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MessagesSquare className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<button
|
||||
title={t("hosts.wakeOnLanAction")}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(
|
||||
t("hosts.wakeOnLanSuccess", { name: host.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.wakeOnLanError"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{host.enableVnc && (
|
||||
<button
|
||||
title="VNC"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("vnc");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MousePointerClick className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableTelnet && (
|
||||
<button
|
||||
title="Telnet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("telnet");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MessagesSquare className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<button
|
||||
title={t("hosts.wakeOnLanAction")}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(
|
||||
t("hosts.wakeOnLanSuccess", { name: host.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.wakeOnLanError"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator + management buttons row — always fixed position */}
|
||||
{/* Management buttons row */}
|
||||
<div className="flex items-center gap-1 pt-0.5 border-t border-border/40 mt-0.5">
|
||||
{showPasswordCopy && (
|
||||
<button
|
||||
title={t("nav.copyPassword")}
|
||||
onClick={(e) => handleCopyPassword(e, "password")}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Key className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{showSudoPasswordCopy && (
|
||||
<button
|
||||
title={t("nav.copySudoPassword")}
|
||||
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Key className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onEditHost && (
|
||||
<button
|
||||
title="Edit Host"
|
||||
@@ -497,6 +670,22 @@ export function HostItem({
|
||||
<Copy className="size-3.5 mr-2" />
|
||||
{t("hosts.copyAddress")}
|
||||
</DropdownMenuItem>
|
||||
{showPasswordCopy && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleCopyPassword(e, "password")}
|
||||
>
|
||||
<Key className="size-3.5 mr-2" />
|
||||
{t("nav.copyPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showSudoPasswordCopy && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
|
||||
>
|
||||
<Key className="size-3.5 mr-2" />
|
||||
{t("nav.copySudoPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Link className="size-3.5 mr-2" />
|
||||
@@ -667,6 +856,11 @@ export function FolderItem({
|
||||
onMenuOpenChange,
|
||||
openTrayHostId,
|
||||
onTrayOpenChange,
|
||||
editingFolderName,
|
||||
editingFolderValue,
|
||||
onEditingFolderNameChange,
|
||||
onEditingFolderValueChange,
|
||||
onRenameFolder,
|
||||
}: {
|
||||
folder: HostFolder;
|
||||
depth?: number;
|
||||
@@ -686,6 +880,11 @@ export function FolderItem({
|
||||
onMenuOpenChange: (hostId: string | null) => void;
|
||||
openTrayHostId: string | null;
|
||||
onTrayOpenChange: (hostId: string | null) => void;
|
||||
editingFolderName: string | null;
|
||||
editingFolderValue: string;
|
||||
onEditingFolderNameChange: (name: string | null) => void;
|
||||
onEditingFolderValueChange: (value: string) => void;
|
||||
onRenameFolder: (oldName: string, newName: string) => Promise<void>;
|
||||
}) {
|
||||
const { total, online } = folderHostCount(folder);
|
||||
|
||||
@@ -693,12 +892,13 @@ export function FolderItem({
|
||||
|
||||
const isOpen = query ? true : openFolders.has(folder.name);
|
||||
const stripeIndex = stripeMap.get(folder) ?? 0;
|
||||
const isEditing = editingFolderName === folder.name;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => !query && onToggleFolder(folder.name)}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 hover:bg-muted/50 transition-colors text-left cursor-pointer ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
|
||||
onClick={() => !query && !isEditing && onToggleFolder(folder.name)}
|
||||
className={`group/folder flex items-center gap-2 w-full px-3 py-2 hover:bg-muted/50 transition-colors text-left cursor-pointer ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3 shrink-0 text-muted-foreground/50 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
@@ -706,15 +906,61 @@ export function FolderItem({
|
||||
<FolderOpen
|
||||
className={`size-3.5 shrink-0 ${isOpen ? "text-accent-brand" : "text-muted-foreground/60"}`}
|
||||
/>
|
||||
<span className="text-[13px] font-semibold text-foreground/80 truncate flex-1">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums shrink-0 ml-1">
|
||||
{online > 0 && (
|
||||
<span className="text-accent-brand font-semibold">{online}</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/40">/{total}</span>
|
||||
</span>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editingFolderValue}
|
||||
onChange={(e) => onEditingFolderValueChange(e.target.value)}
|
||||
onBlur={async () => {
|
||||
const newName = editingFolderValue.trim();
|
||||
onEditingFolderNameChange(null);
|
||||
if (newName && newName !== folder.name) {
|
||||
await onRenameFolder(folder.name, newName);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
if (e.key === "Escape") onEditingFolderNameChange(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[10px] font-semibold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
|
||||
/>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditingFolderNameChange(null);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[13px] font-semibold text-foreground/80 truncate flex-1">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums shrink-0 ml-1">
|
||||
{online > 0 && (
|
||||
<span className="text-accent-brand font-semibold">
|
||||
{online}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/40">/{total}</span>
|
||||
</span>
|
||||
<span
|
||||
className="opacity-0 group-hover/folder:opacity-100 transition-opacity ml-1 text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditingFolderNameChange(folder.name);
|
||||
onEditingFolderValueChange(folder.name);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-2.5" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-l border-border/40 ml-[30px]">
|
||||
@@ -740,6 +986,11 @@ export function FolderItem({
|
||||
onMenuOpenChange={onMenuOpenChange}
|
||||
openTrayHostId={openTrayHostId}
|
||||
onTrayOpenChange={onTrayOpenChange}
|
||||
editingFolderName={editingFolderName}
|
||||
editingFolderValue={editingFolderValue}
|
||||
onEditingFolderNameChange={onEditingFolderNameChange}
|
||||
onEditingFolderValueChange={onEditingFolderValueChange}
|
||||
onRenameFolder={onRenameFolder}
|
||||
/>
|
||||
) : (
|
||||
<HostItem
|
||||
@@ -798,6 +1049,10 @@ export function SidebarTree({
|
||||
);
|
||||
const [openMenuHostId, setOpenMenuHostId] = useState<string | null>(null);
|
||||
const [openTrayHostId, setOpenTrayHostId] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editingFolderValue, setEditingFolderValue] = useState("");
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
message: string;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
@@ -806,7 +1061,11 @@ export function SidebarTree({
|
||||
function toggleFolder(name: string) {
|
||||
setOpenFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(name) ? next.delete(name) : next.add(name);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -814,7 +1073,11 @@ export function SidebarTree({
|
||||
function toggleSelect(id: string) {
|
||||
setSelectedHostIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -836,7 +1099,7 @@ export function SidebarTree({
|
||||
|
||||
async function handleDuplicateHost(host: Host) {
|
||||
try {
|
||||
await createSSHHost({
|
||||
const duplicateHost: SSHHostData = {
|
||||
name: `${host.name} (copy)`,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
@@ -893,7 +1156,8 @@ export function SidebarTree({
|
||||
statsConfig: host.statsConfig,
|
||||
guacamoleConfig: host.guacamoleConfig ?? null,
|
||||
terminalConfig: host.terminalConfig ?? null,
|
||||
} as any);
|
||||
};
|
||||
await createSSHHost(duplicateHost);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
toast.success(t("hosts.duplicatedHost", { name: host.name }));
|
||||
} catch {
|
||||
@@ -901,6 +1165,16 @@ export function SidebarTree({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameFolder(oldName: string, newName: string) {
|
||||
try {
|
||||
await renameFolder(oldName, newName);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
toast.success(t("hosts.folderRenamedTo", { name: newName }));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToRenameFolder"));
|
||||
}
|
||||
}
|
||||
|
||||
const allHosts = collectAllHosts(children);
|
||||
const allFolders = collectAllFolders(children);
|
||||
|
||||
@@ -966,6 +1240,11 @@ export function SidebarTree({
|
||||
onMenuOpenChange={setOpenMenuHostId}
|
||||
openTrayHostId={openTrayHostId}
|
||||
onTrayOpenChange={setOpenTrayHostId}
|
||||
editingFolderName={editingFolderName}
|
||||
editingFolderValue={editingFolderValue}
|
||||
onEditingFolderNameChange={setEditingFolderName}
|
||||
onEditingFolderValueChange={setEditingFolderValue}
|
||||
onRenameFolder={handleRenameFolder}
|
||||
/>
|
||||
) : (
|
||||
<HostItem
|
||||
|
||||
@@ -501,6 +501,18 @@ type AccessRecord = {
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
type SnippetPayload = Omit<Snippet, "id" | "order">;
|
||||
type RawSnippet = SnippetPayload & {
|
||||
id: number;
|
||||
order?: number | null;
|
||||
};
|
||||
type RawSnippetFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: FolderIconId | null;
|
||||
};
|
||||
|
||||
function ShareSnippetDialog({
|
||||
snippet,
|
||||
onClose,
|
||||
@@ -524,15 +536,13 @@ function ShareSnippetDialog({
|
||||
Promise.all([getUserList(), getRoles(), getSnippetAccess(snippet.id)])
|
||||
.then(([usersData, rolesData, accessData]) => {
|
||||
setUsers(
|
||||
(usersData?.users || []).map((u: any) => ({
|
||||
id: u.id,
|
||||
(usersData?.users || []).map((u) => ({
|
||||
id: u.userId,
|
||||
username: u.username,
|
||||
})),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setRoles((rolesData?.roles || []).map((r: any) => r));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setAccessList((accessData as any).accessList || []);
|
||||
setRoles(rolesData?.roles || []);
|
||||
setAccessList(accessData.accessList || []);
|
||||
})
|
||||
.catch(() => toast.error(t("newUi.sidebar.snippets.shareLoadError")))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -547,8 +557,7 @@ function ShareSnippetDialog({
|
||||
targetRoleId: targetType === "role" ? parseInt(targetId) : undefined,
|
||||
});
|
||||
toast.success(t("newUi.sidebar.snippets.shareSuccess"));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const accessData = (await getSnippetAccess(snippet.id)) as any;
|
||||
const accessData = await getSnippetAccess(snippet.id);
|
||||
setAccessList(accessData.accessList || []);
|
||||
setTargetId("");
|
||||
} catch {
|
||||
@@ -561,8 +570,7 @@ function ShareSnippetDialog({
|
||||
try {
|
||||
await revokeSnippetAccess(snippet.id, accessId);
|
||||
toast.success(t("newUi.sidebar.snippets.revokeSuccess"));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const accessData = (await getSnippetAccess(snippet.id)) as any;
|
||||
const accessData = await getSnippetAccess(snippet.id);
|
||||
setAccessList(accessData.accessList || []);
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.revokeFailed"));
|
||||
@@ -974,9 +982,8 @@ export function SnippetsPanel({
|
||||
|
||||
getSnippets()
|
||||
.then((data) => {
|
||||
const arr = Array.isArray(data) ? data : [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapped: Snippet[] = arr.map((s: any) => ({
|
||||
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
|
||||
const mapped: Snippet[] = arr.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
@@ -990,9 +997,8 @@ export function SnippetsPanel({
|
||||
|
||||
getSnippetFolders()
|
||||
.then((data) => {
|
||||
const arr = Array.isArray(data) ? data : [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapped: SnippetFolder[] = arr.map((f: any) => ({
|
||||
const arr: RawSnippetFolder[] = Array.isArray(data) ? data : [];
|
||||
const mapped: SnippetFolder[] = arr.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
color: f.color ?? FOLDER_COLORS[0],
|
||||
@@ -1007,7 +1013,11 @@ export function SnippetsPanel({
|
||||
function toggleTab(id: string) {
|
||||
setSelectedTabIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -1018,15 +1028,13 @@ export function SnippetsPanel({
|
||||
) {
|
||||
try {
|
||||
if (id !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await apiUpdateSnippet(id, data as any);
|
||||
await apiUpdateSnippet(id, data);
|
||||
updateSnippets((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
|
||||
);
|
||||
toast.success(t("newUi.sidebar.snippets.updateSuccess"));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const created = (await apiCreateSnippet(data as any)) as any;
|
||||
const created = await apiCreateSnippet(data);
|
||||
updateSnippets((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@@ -1048,12 +1056,14 @@ export function SnippetsPanel({
|
||||
|
||||
async function handleCreateFolder(f: Omit<SnippetFolder, "id" | "open">) {
|
||||
try {
|
||||
const created = (await apiCreateSnippetFolder({
|
||||
const created = await apiCreateSnippetFolder({
|
||||
name: f.name,
|
||||
color: f.color,
|
||||
icon: f.icon,
|
||||
})) as any;
|
||||
setFolders((prev) => [...prev, { ...f, id: created.id, open: true }]);
|
||||
});
|
||||
const id =
|
||||
typeof created.id === "number" ? created.id : Number(created.id);
|
||||
setFolders((prev) => [...prev, { ...f, id, open: true }]);
|
||||
toast.success(t("newUi.sidebar.snippets.folderCreateSuccess"));
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.folderCreateFailed"));
|
||||
|
||||
@@ -35,7 +35,11 @@ export function SshToolsPanel({
|
||||
function toggleTab(id: string) {
|
||||
setSelectedTabIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -51,7 +55,7 @@ export function SshToolsPanel({
|
||||
function broadcast(data: string) {
|
||||
for (const tabId of selectedTabIds) {
|
||||
const tab = terminalTabs.find((t) => t.id === tabId);
|
||||
(tab?.terminalRef?.current as any)?.sendInput?.(data);
|
||||
tab?.terminalRef?.current?.sendInput?.(data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
disableTOTP,
|
||||
getVersionInfo,
|
||||
getUserRoles,
|
||||
saveUserPreferences,
|
||||
} from "@/main-axios";
|
||||
import type { UserRole } from "@/main-axios";
|
||||
import type React from "react";
|
||||
@@ -137,6 +138,8 @@ function AccordionSection({
|
||||
<div className="border border-border bg-card overflow-hidden">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-content`}
|
||||
className="flex items-center gap-2 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">{icon}</span>
|
||||
@@ -148,12 +151,35 @@ function AccordionSection({
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border px-3 pb-3">{children}</div>
|
||||
<div id={`${id}-content`} className="border-t border-border px-3 pb-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string) {
|
||||
return (error as ApiErrorLike).response?.data?.error || fallback;
|
||||
}
|
||||
|
||||
type CreatedProfileApiKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
token?: string;
|
||||
tokenPrefix?: string;
|
||||
createdAt?: string;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
function NewApiKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -162,7 +188,7 @@ function NewApiKeyDialog({
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onAdd: (key: any) => void;
|
||||
onAdd: (key: CreatedProfileApiKey) => void;
|
||||
userId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -296,10 +322,9 @@ function PasswordChangeSection({
|
||||
setNewPw("");
|
||||
setConfirmPw("");
|
||||
onLogout?.();
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
toast.error(
|
||||
e?.response?.data?.error ||
|
||||
t("newUi.sidebar.userProfile.passwordUpdateFailed"),
|
||||
apiErrorMessage(e, t("newUi.sidebar.userProfile.passwordUpdateFailed")),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -527,24 +552,41 @@ export function UserProfilePanel({
|
||||
setVersionStatus(info.status ?? "up_to_date");
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
function saveAppearancePreference(prefs: {
|
||||
theme?: ThemeId;
|
||||
fontSize?: FontSizeId;
|
||||
accentColor?: string;
|
||||
language?: string;
|
||||
}) {
|
||||
void saveUserPreferences(prefs).catch(() => {});
|
||||
}
|
||||
|
||||
function handleThemeChange(id: ThemeId) {
|
||||
setTheme(id);
|
||||
saveAppearancePreference({ theme: id });
|
||||
}
|
||||
|
||||
function handleAccentChange(value: string) {
|
||||
setAccentColor(value);
|
||||
setCustomColorInput(value);
|
||||
localStorage.setItem("termix-accent", value);
|
||||
applyAccentColor(value);
|
||||
saveAppearancePreference({ accentColor: value });
|
||||
}
|
||||
|
||||
function handleFontSizeChange(id: FontSizeId) {
|
||||
setFontSize(id);
|
||||
applyFontSize(id);
|
||||
saveAppearancePreference({ fontSize: id });
|
||||
}
|
||||
|
||||
function handleLanguageChange(code: string) {
|
||||
setLanguage(code);
|
||||
localStorage.setItem("i18nextLng", code);
|
||||
i18n.changeLanguage(code);
|
||||
saveAppearancePreference({ language: code });
|
||||
}
|
||||
|
||||
function toggle(id: UserProfileSection) {
|
||||
@@ -578,10 +620,9 @@ export function UserProfilePanel({
|
||||
setTotpEnabled(true);
|
||||
setTotpStep("backup");
|
||||
toast.success(t("newUi.sidebar.userProfile.totpEnabledSuccess"));
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
toast.error(
|
||||
e?.response?.data?.error ||
|
||||
t("newUi.sidebar.userProfile.totpInvalidCode"),
|
||||
apiErrorMessage(e, t("newUi.sidebar.userProfile.totpInvalidCode")),
|
||||
);
|
||||
} finally {
|
||||
setTotpLoading(false);
|
||||
@@ -600,10 +641,9 @@ export function UserProfilePanel({
|
||||
setShowDisableTotp(false);
|
||||
setDisableTotpInput("");
|
||||
toast.success(t("newUi.sidebar.userProfile.totpDisabledSuccess"));
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
toast.error(
|
||||
e?.response?.data?.error ||
|
||||
t("newUi.sidebar.userProfile.totpDisableFailed"),
|
||||
apiErrorMessage(e, t("newUi.sidebar.userProfile.totpDisableFailed")),
|
||||
);
|
||||
} finally {
|
||||
setTotpLoading(false);
|
||||
@@ -630,9 +670,9 @@ export function UserProfilePanel({
|
||||
await deleteAccount(deletePassword);
|
||||
await logoutUser().catch(() => {});
|
||||
window.location.reload();
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
toast.error(
|
||||
e?.response?.data?.error || t("newUi.sidebar.userProfile.deleteFailed"),
|
||||
apiErrorMessage(e, t("newUi.sidebar.userProfile.deleteFailed")),
|
||||
);
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
@@ -796,7 +836,7 @@ export function UserProfilePanel({
|
||||
<div className="relative">
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as ThemeId)}
|
||||
onChange={(e) => handleThemeChange(e.target.value as ThemeId)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none pr-7"
|
||||
>
|
||||
{THEMES.map((th) => (
|
||||
@@ -812,7 +852,7 @@ export function UserProfilePanel({
|
||||
<button
|
||||
key={th.id}
|
||||
title={themeLabel[th.id]}
|
||||
onClick={() => setTheme(th.id)}
|
||||
onClick={() => handleThemeChange(th.id)}
|
||||
className={`h-4 flex-1 border transition-all ${theme === th.id ? "border-accent-brand ring-1 ring-accent-brand" : "border-border/50"}`}
|
||||
style={{ background: th.preview }}
|
||||
/>
|
||||
@@ -990,7 +1030,9 @@ export function UserProfilePanel({
|
||||
onChange={(v) => {
|
||||
onPrefsChange?.({ reopenTabsOnLogin: v });
|
||||
import("@/main-axios").then(({ saveUserPreferences }) => {
|
||||
saveUserPreferences({ reopenTabsOnLogin: v }).catch(() => {});
|
||||
saveUserPreferences({ reopenTabsOnLogin: v }).catch(
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user