mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: port the demo redesign with Manage and Settings tabs
This commit is contained in:
1 parent
f8151cb885
commit
35c954b79a
128 files changed
+13262
-8143
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
@@ -52,6 +53,7 @@ export function AdminApiKeysSection({
|
||||
newKeyLoading,
|
||||
}: AdminApiKeysSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
@@ -244,6 +246,11 @@ export function AdminApiKeysSection({
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("admin.revokeKeyConfirm", { name: key.name }),
|
||||
confirmLabel: t("admin.revokeKey"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteApiKey(key.id);
|
||||
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -112,6 +113,7 @@ export function AdminUsersSection({
|
||||
onPageChange,
|
||||
}: UsersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const hasPrev = page > 0;
|
||||
const hasNext = page + 1 < pageCount;
|
||||
@@ -249,6 +251,13 @@ export function AdminUsersSection({
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
disabled={user.isAdmin}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("admin.deleteUserConfirm", {
|
||||
username: user.username,
|
||||
}),
|
||||
description: t("manage.cannotBeUndone"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteUser(user.username);
|
||||
setUsers((prev) => prev.filter((u) => u.id !== user.id));
|
||||
@@ -321,6 +330,7 @@ export function AdminSessionsSection({
|
||||
loadSessions,
|
||||
}: SessionsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
@@ -375,6 +385,13 @@ export function AdminSessionsSection({
|
||||
size="sm"
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("admin.revokeAllSessionsConfirm", {
|
||||
username: session.username ?? "",
|
||||
}),
|
||||
confirmLabel: t("admin.revokeAll"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await revokeAllUserSessions(session.userId);
|
||||
setSessions((prev) =>
|
||||
@@ -393,6 +410,11 @@ export function AdminSessionsSection({
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("admin.revokeSessionConfirm"),
|
||||
confirmLabel: t("admin.revokeSession"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await revokeSession(session.id);
|
||||
setSessions((prev) =>
|
||||
@@ -447,6 +469,7 @@ export function AdminRolesSection({
|
||||
createRoleLoading,
|
||||
}: RolesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
|
||||
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
||||
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
|
||||
@@ -652,6 +675,12 @@ export function AdminRolesSection({
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("admin.deleteRoleConfirm", {
|
||||
name: role.displayName,
|
||||
}),
|
||||
});
|
||||
if (!ok) return;
|
||||
await deleteRole(role.id);
|
||||
setRoles((prev) =>
|
||||
prev.filter((r) => r.id !== role.id),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBranding } from "@/contexts/BrandingContext";
|
||||
@@ -102,11 +103,15 @@ const USERS_PAGE_SIZE = 25;
|
||||
export function AdminSettingsPanel({
|
||||
onEditingChange,
|
||||
onOpenHostTab,
|
||||
section,
|
||||
}: {
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
onOpenHostTab?: (host: Host) => void;
|
||||
/** Shows just this section, flat, as a Settings page. */
|
||||
section?: AdminSection;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [openSections, setOpenSections] = useState<
|
||||
Set<AdminSection | FeatureSectionId>
|
||||
>(() => new Set(["general"]));
|
||||
@@ -567,6 +572,13 @@ export function AdminSettingsPanel({
|
||||
|
||||
async function handleDeleteEditUser() {
|
||||
if (!editUserTarget) return;
|
||||
const ok = await confirm({
|
||||
title: t("admin.deleteUserConfirm", {
|
||||
username: editUserTarget.username,
|
||||
}),
|
||||
description: t("manage.cannotBeUndone"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setEditUserLoading(true);
|
||||
try {
|
||||
await deleteUser(editUserTarget.username);
|
||||
@@ -734,6 +746,9 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const isOpen = (id: AdminSection | FeatureSectionId) =>
|
||||
section ? section === id : openSections.has(id);
|
||||
|
||||
if (manageUser) {
|
||||
return (
|
||||
<AdminUserManagePanel
|
||||
@@ -758,9 +773,15 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
|
||||
<div
|
||||
className={
|
||||
section
|
||||
? "flex w-full flex-col gap-2"
|
||||
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto"
|
||||
}
|
||||
>
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSections.has("general")}
|
||||
open={isOpen("general")}
|
||||
onToggle={() => toggle("general")}
|
||||
notificationPrivateEndpoints={notificationPrivateEndpoints}
|
||||
onSaveNotificationPrivateEndpoints={
|
||||
@@ -790,7 +811,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminUsersSection
|
||||
open={openSections.has("users")}
|
||||
open={isOpen("users")}
|
||||
onToggle={() => toggle("users")}
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
@@ -812,7 +833,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminSessionsSection
|
||||
open={openSections.has("sessions")}
|
||||
open={isOpen("sessions")}
|
||||
onToggle={() => toggle("sessions")}
|
||||
sessions={sessions}
|
||||
setSessions={setSessions}
|
||||
@@ -820,7 +841,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminRolesSection
|
||||
open={openSections.has("roles")}
|
||||
open={isOpen("roles")}
|
||||
onToggle={() => toggle("roles")}
|
||||
roles={roles}
|
||||
setRoles={setRoles}
|
||||
@@ -837,12 +858,12 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminHostDefaultsSection
|
||||
open={openSections.has("host-defaults")}
|
||||
open={isOpen("host-defaults")}
|
||||
onToggle={() => toggle("host-defaults")}
|
||||
/>
|
||||
|
||||
<AdminBrandingSection
|
||||
open={openSections.has("branding")}
|
||||
open={isOpen("branding")}
|
||||
onToggle={() => toggle("branding")}
|
||||
settings={brandingSettings}
|
||||
setSettings={setBrandingSettings}
|
||||
@@ -852,7 +873,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminDatabaseSection
|
||||
open={openSections.has("database")}
|
||||
open={isOpen("database")}
|
||||
onToggle={() => toggle("database")}
|
||||
importFile={importFile}
|
||||
setImportFile={setImportFile}
|
||||
@@ -863,7 +884,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminSSLSection
|
||||
open={openSections.has("ssl")}
|
||||
open={isOpen("ssl")}
|
||||
onToggle={() => toggle("ssl")}
|
||||
status={tlsStatus}
|
||||
manualCertDraft={manualCertDraft}
|
||||
@@ -875,7 +896,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminApiKeysSection
|
||||
open={openSections.has("api-keys")}
|
||||
open={isOpen("api-keys")}
|
||||
onToggle={() => toggle("api-keys")}
|
||||
apiKeys={apiKeys}
|
||||
setApiKeys={setApiKeys}
|
||||
@@ -896,7 +917,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminAuditLogSection
|
||||
open={openSections.has("audit-log")}
|
||||
open={isOpen("audit-log")}
|
||||
onToggle={() => toggle("audit-log")}
|
||||
users={users}
|
||||
/>
|
||||
@@ -906,7 +927,7 @@ export function AdminSettingsPanel({
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
scope="admin"
|
||||
open={openSections.has(featureSectionId(plugin.id))}
|
||||
open={isOpen(featureSectionId(plugin.id))}
|
||||
onToggle={() => toggle(featureSectionId(plugin.id))}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -372,7 +372,7 @@ function certState(
|
||||
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
|
||||
none: "text-muted-foreground",
|
||||
valid: "text-green-500",
|
||||
expiring: "text-yellow-500",
|
||||
expiring: "text-warning",
|
||||
expired: "text-destructive",
|
||||
};
|
||||
|
||||
@@ -470,7 +470,7 @@ export function AdminSSLSection({
|
||||
</div>
|
||||
|
||||
{cert && !cert.selfSigned && !status?.renewal && (
|
||||
<div className="p-2 border border-yellow-500/40 bg-yellow-500/10 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
<div className="p-2 border border-warning/40 bg-warning/10 text-[10px] text-warning">
|
||||
{t("admin.sslNotRenewed", { date: expiry })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useSettingsEmbedded } from "@/settings/settings-embed";
|
||||
|
||||
export function AdminToggle({
|
||||
on,
|
||||
@@ -36,6 +37,15 @@ export function AccordionSection({
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const embedded = useSettingsEmbedded();
|
||||
if (embedded) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="flex flex-col border border-border bg-card px-3 pb-3">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="border border-border bg-card overflow-hidden shrink-0">
|
||||
<button
|
||||
|
||||
+368
-395
@@ -1,3 +1,4 @@
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -9,13 +10,6 @@ import {
|
||||
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";
|
||||
@@ -63,83 +57,80 @@ export function AdminCreateUserDialog({
|
||||
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>
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("admin.createUserTitle")}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.createUserDesc")}
|
||||
</p>
|
||||
<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
|
||||
placeholder={t("admin.createUserEnterUsername")}
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
placeholder={t("placeholders.enterPassword")}
|
||||
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>
|
||||
<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={t("placeholders.enterPassword")}
|
||||
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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("admin.createUserPasswordHint")}
|
||||
</span>
|
||||
</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>
|
||||
</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>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,222 +164,210 @@ export function AdminEditUserDialog({
|
||||
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.isExternal && editUserTarget.passwordHash
|
||||
? t("admin.authTypeDual")
|
||||
: editUserTarget.isExternal
|
||||
? t("admin.authTypeExternal")
|
||||
: 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")}
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("admin.editUserTitle", { username: editUserTarget?.username })}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{t("admin.editUserDesc")}</p>
|
||||
{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>
|
||||
{editUserRolesLoading ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("common.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 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.isExternal && editUserTarget.passwordHash
|
||||
? t("admin.authTypeDual")
|
||||
: editUserTarget.isExternal
|
||||
? t("admin.authTypeExternal")
|
||||
: t("admin.authTypeLocal")}
|
||||
</span>
|
||||
</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 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>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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("common.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>
|
||||
)}
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -424,45 +403,42 @@ export function AdminUnlinkAccountDialog({
|
||||
};
|
||||
|
||||
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.unlinkAccountTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("admin.unlinkAccountDesc", {
|
||||
username: unlinkAccountTarget?.username,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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.unlinkAccountWarning")}
|
||||
</span>
|
||||
</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 || !unlinkAccountTarget}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting
|
||||
? t("admin.unlinkAccountInProgress")
|
||||
: t("admin.unlinkAccount")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("admin.unlinkAccountTitle")}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.unlinkAccountDesc", {
|
||||
username: unlinkAccountTarget?.username,
|
||||
})}
|
||||
</p>
|
||||
<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.unlinkAccountWarning")}
|
||||
</span>
|
||||
</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 || !unlinkAccountTarget}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting
|
||||
? t("admin.unlinkAccountInProgress")
|
||||
: t("admin.unlinkAccount")}
|
||||
</Button>
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -529,74 +505,71 @@ export function AdminLinkAccountDialog({
|
||||
};
|
||||
|
||||
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">
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("admin.linkAccountTitle")}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isExternalInitiator
|
||||
? t("admin.linkAccountDesc", {
|
||||
username: linkAccountTarget?.username,
|
||||
})
|
||||
: t("admin.linkAccountDescLocal", {
|
||||
username: linkAccountTarget?.username,
|
||||
})}
|
||||
</p>
|
||||
<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">
|
||||
{isExternalInitiator
|
||||
? t("admin.linkAccountDesc", {
|
||||
username: linkAccountTarget?.username,
|
||||
})
|
||||
: t("admin.linkAccountDescLocal", {
|
||||
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">
|
||||
{isExternalInitiator
|
||||
? t("admin.linkAccountTargetUsername")
|
||||
: t("admin.linkAccountExternalUsername")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={otherUsername}
|
||||
onChange={(e) => setOtherUsername(e.target.value)}
|
||||
placeholder={
|
||||
isExternalInitiator
|
||||
? t("admin.linkAccountTargetPlaceholder")
|
||||
: t("admin.linkAccountExternalPlaceholder")
|
||||
}
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
? t("admin.linkAccountTargetUsername")
|
||||
: t("admin.linkAccountExternalUsername")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={otherUsername}
|
||||
onChange={(e) => setOtherUsername(e.target.value)}
|
||||
placeholder={
|
||||
isExternalInitiator
|
||||
? t("admin.linkAccountTargetPlaceholder")
|
||||
: t("admin.linkAccountExternalPlaceholder")
|
||||
}
|
||||
autoFocus
|
||||
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 || !otherUsername.trim()}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting
|
||||
? t("admin.linkAccountInProgress")
|
||||
: t("admin.linkAccounts")}
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</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 || !otherUsername.trim()}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting
|
||||
? t("admin.linkAccountInProgress")
|
||||
: t("admin.linkAccounts")}
|
||||
</Button>
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { useEffect, useState } from "react";
|
||||
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
|
||||
import { getUserList } from "@/main-axios";
|
||||
@@ -50,7 +51,8 @@ import { AdminSecondFactorsSection } from "./AdminSecondFactorsSection";
|
||||
import { HostEditor } from "./HostEditor";
|
||||
import { mapCredentials, sshHostToHost } from "./HostManagerData";
|
||||
import type { AdminSession, AdminUser } from "./AdminManagementSections";
|
||||
import { makeCredentialTabs, makeHostTabs, TabStrip } from "./HostManagerTabs";
|
||||
import { TabStrip } from "./HostManagerTabs";
|
||||
import { BackButton, PanelShell } from "@/components/panel-layout";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
|
||||
type ApiErrorLike = {
|
||||
@@ -114,14 +116,18 @@ export function AdminUserManagePanel({
|
||||
.catch(() => {});
|
||||
}, [user.id]);
|
||||
const [editor, setEditor] = useState<EditorState>(null);
|
||||
const [editorTab, setEditorTab] = useState("general");
|
||||
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
|
||||
hostProtocolFlags(null),
|
||||
);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const confirm = useConfirm();
|
||||
const setConfirmDialog = (
|
||||
request: { message: string; onConfirm: () => void } | null,
|
||||
) => {
|
||||
if (!request) return;
|
||||
void confirm({ title: request.message }).then((ok) => {
|
||||
if (ok) void request.onConfirm();
|
||||
});
|
||||
};
|
||||
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
@@ -323,57 +329,34 @@ export function AdminUserManagePanel({
|
||||
</span>
|
||||
);
|
||||
|
||||
// Full-panel host/credential editor view (mirrors HostManager's editor view)
|
||||
// Editing one of the user's hosts or credentials takes over the panel.
|
||||
if (editor) {
|
||||
const isHost = editor.kind === "host";
|
||||
const tabs = isHost
|
||||
? makeHostTabs(
|
||||
t,
|
||||
editorProtocols as unknown as Record<string, boolean>,
|
||||
).filter((tab) => tab.id !== "ssh" || editorProtocols.enableSsh)
|
||||
: makeCredentialTabs(t);
|
||||
|
||||
const close = () => setEditor(null);
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex flex-col shrink-0 border-b border-border">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditor(null);
|
||||
setEditorTab("general");
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{t("admin.manageEditorBack", { username: user.username })}
|
||||
</span>
|
||||
</button>
|
||||
<TabStrip
|
||||
tabs={tabs}
|
||||
activeTab={editorTab}
|
||||
onTabChange={setEditorTab}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
{isHost ? (
|
||||
<PanelShell
|
||||
leading={<BackButton onClick={close} />}
|
||||
title={t("admin.manageEditorBack", { username: user.username })}
|
||||
status={
|
||||
editor.kind === "host"
|
||||
? (editor.host?.name ?? t("manage.newHost"))
|
||||
: (editor.credential?.name ?? t("manage.newCredential"))
|
||||
}
|
||||
scroll={false}
|
||||
>
|
||||
{editor.kind === "host" ? (
|
||||
<HostEditor
|
||||
key={editor.host ? editor.host.id : "new-host"}
|
||||
host={editor.host}
|
||||
activeTab={editorTab}
|
||||
onBack={() => {
|
||||
setEditor(null);
|
||||
setEditorTab("general");
|
||||
}}
|
||||
onBack={close}
|
||||
onSave={() => {
|
||||
setEditor(null);
|
||||
setEditorTab("general");
|
||||
close();
|
||||
reloadHosts();
|
||||
}}
|
||||
protocols={editorProtocols}
|
||||
onProtocolChange={(p) =>
|
||||
setEditorProtocols((prev) => ({ ...prev, ...p }))
|
||||
}
|
||||
onTabChange={setEditorTab}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
adminTargetUserId={user.id}
|
||||
@@ -382,7 +365,6 @@ export function AdminUserManagePanel({
|
||||
<CredentialEditorView
|
||||
key={editor.credential ? editor.credential.id : "new-cred"}
|
||||
credential={editor.credential}
|
||||
activeTab={editorTab}
|
||||
existingFolders={Array.from(
|
||||
new Set(
|
||||
credentials
|
||||
@@ -390,19 +372,15 @@ export function AdminUserManagePanel({
|
||||
.filter((f): f is string => !!f),
|
||||
),
|
||||
).sort()}
|
||||
onBack={() => {
|
||||
setEditor(null);
|
||||
setEditorTab("general");
|
||||
}}
|
||||
onBack={close}
|
||||
onSave={() => {
|
||||
setEditor(null);
|
||||
setEditorTab("general");
|
||||
close();
|
||||
reloadCredentials();
|
||||
}}
|
||||
adminTargetUserId={user.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PanelShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -678,7 +656,6 @@ export function AdminUserManagePanel({
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditorProtocols(hostProtocolFlags(null));
|
||||
setEditorTab("general");
|
||||
setEditor({ kind: "host", host: null });
|
||||
}}
|
||||
>
|
||||
@@ -724,7 +701,6 @@ export function AdminUserManagePanel({
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditorProtocols(hostProtocolFlags(host));
|
||||
setEditorTab("general");
|
||||
setEditor({ kind: "host", host });
|
||||
}}
|
||||
>
|
||||
@@ -790,7 +766,6 @@ export function AdminUserManagePanel({
|
||||
size="sm"
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditorTab("general");
|
||||
setEditor({ kind: "credential", credential: null });
|
||||
}}
|
||||
>
|
||||
@@ -827,7 +802,6 @@ export function AdminUserManagePanel({
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditorTab("general");
|
||||
setEditor({ kind: "credential", credential: cred });
|
||||
}}
|
||||
>
|
||||
@@ -1013,32 +987,6 @@ export function AdminUserManagePanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog overlay */}
|
||||
{confirmDialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
|
||||
<p className="text-sm text-foreground">{confirmDialog.message}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmDialog(null)}
|
||||
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
confirmDialog.onConfirm();
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
|
||||
>
|
||||
{t("common.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+321
-443
@@ -1,106 +1,122 @@
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LogOut,
|
||||
PanelRight,
|
||||
Pin,
|
||||
Settings,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
SquareArrowOutUpRight,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import type { TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { Skeleton } from "@/components/skeleton";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useRailItems, type RailItemDef } from "./rail-items";
|
||||
import {
|
||||
groupRailItems,
|
||||
useRailItems,
|
||||
type RailGroup,
|
||||
type RailItemDef,
|
||||
} from "./rail-items";
|
||||
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
|
||||
import { RailBadge } from "./RailBadge";
|
||||
import { rem } from "@/lib/rem";
|
||||
|
||||
/** Core rail views; plugins add their own ids at runtime. */
|
||||
export type CoreRailView =
|
||||
| "hosts"
|
||||
| "credentials"
|
||||
| "quick-connect"
|
||||
| ToolsTab
|
||||
| "connections"
|
||||
| "user-profile"
|
||||
| "admin-settings";
|
||||
"hosts" | "credentials" | "quick-connect" | ToolsTab | "connections";
|
||||
|
||||
export type RailView = CoreRailView | (string & {});
|
||||
|
||||
type RailItem =
|
||||
| {
|
||||
kind?: undefined;
|
||||
view: RailView;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
| {
|
||||
kind: "tab";
|
||||
tabType: TabType;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
| { kind: "separator" };
|
||||
const btnBase =
|
||||
"relative flex items-center h-7 shrink-0 transition-colors gap-2.5 focus-visible:ring-1 focus-visible:ring-ring";
|
||||
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
|
||||
const idle = "text-muted-foreground hover:text-foreground hover:bg-muted/60";
|
||||
const activeClass = "text-accent-brand bg-accent-brand/10";
|
||||
|
||||
function buildRailButtons(
|
||||
items: RailItemDef[],
|
||||
t: (key: string) => string,
|
||||
hidden: Set<string>,
|
||||
): RailItem[] {
|
||||
const all: RailItem[] = [];
|
||||
for (const item of items) {
|
||||
const Icon = item.icon;
|
||||
if (item.kind === "tab") {
|
||||
all.push({
|
||||
kind: "tab",
|
||||
tabType: item.id as TabType,
|
||||
icon: <Icon size={16} />,
|
||||
title: t(item.labelKey),
|
||||
useBadge: item.useBadge,
|
||||
});
|
||||
} else {
|
||||
all.push({
|
||||
view: item.id as RailView,
|
||||
icon: <Icon size={16} />,
|
||||
title: t(item.labelKey),
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
useBadge: item.useBadge,
|
||||
});
|
||||
}
|
||||
if (item.separatorAfter) all.push({ kind: "separator" });
|
||||
}
|
||||
|
||||
// Filter out hidden items, then collapse consecutive/leading/trailing separators
|
||||
const filtered = all.filter((item) => {
|
||||
if (item.kind === "separator") return true;
|
||||
if ("tabType" in item) return !hidden.has(item.tabType);
|
||||
return !hidden.has(item.view);
|
||||
});
|
||||
|
||||
const result: RailItem[] = [];
|
||||
for (const item of filtered) {
|
||||
if (item.kind === "separator") {
|
||||
if (result.length === 0 || result[result.length - 1].kind === "separator")
|
||||
continue;
|
||||
result.push(item);
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
if (result[result.length - 1]?.kind === "separator") result.pop();
|
||||
return result;
|
||||
function RailLabel({
|
||||
expanded,
|
||||
children,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
expanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const btnBase =
|
||||
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
|
||||
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
|
||||
function RailIcon({
|
||||
children,
|
||||
useBadge,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
{children}
|
||||
{useBadge && (
|
||||
<RailBadge useBadge={useBadge} className="-top-1.5 -right-2" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Rule({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{ width: expanded ? `calc(100% - ${rem(16)})` : rem(20) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The band name rides on the divider between bands, so expanding the rail
|
||||
* never pushes items down from under the pointer.
|
||||
*/
|
||||
function GroupDivider({
|
||||
group,
|
||||
expanded,
|
||||
}: {
|
||||
group: RailGroup;
|
||||
expanded: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="my-0.5 flex h-3 shrink-0 items-center gap-1.5 px-2.5">
|
||||
<span
|
||||
aria-hidden={!expanded}
|
||||
className={`shrink-0 overflow-hidden text-[10px] font-semibold uppercase leading-none tracking-widest whitespace-nowrap text-muted-foreground/60 transition-[opacity,max-width] duration-200 ${
|
||||
expanded ? "max-w-32 opacity-100 delay-75" : "max-w-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{t(`nav.group.${group}`)}
|
||||
</span>
|
||||
<div className="h-px min-w-3 flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuTarget {
|
||||
id: string;
|
||||
title: string;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
hideable: boolean;
|
||||
}
|
||||
|
||||
export function AppRail({
|
||||
railView,
|
||||
@@ -111,6 +127,8 @@ export function AppRail({
|
||||
onRailClick,
|
||||
onOpenTab,
|
||||
onOpenInRightDock,
|
||||
onOpenSettings,
|
||||
onOpenPalette,
|
||||
onLogout,
|
||||
}: {
|
||||
railView: RailView;
|
||||
@@ -122,6 +140,8 @@ export function AppRail({
|
||||
onRailClick: (view: RailView) => void;
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
onOpenInRightDock?: (view: RailView) => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenPalette?: () => void;
|
||||
onLogout: (options?: { manual?: boolean }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -134,22 +154,9 @@ export function AppRail({
|
||||
readRailPreference("showPinAppRailButton"),
|
||||
);
|
||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||||
// Which promotable item was right-clicked, so the menu can offer to open it
|
||||
// as a tab. Null when the right-click landed on empty rail space.
|
||||
const [menuTarget, setMenuTarget] = useState<{
|
||||
view: RailView;
|
||||
title: string;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
} | null>(null);
|
||||
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("hiddenRailTabs");
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
|
||||
const [managing, setManaging] = useState(false);
|
||||
const hiddenTabs = useHiddenRailTabs();
|
||||
|
||||
useEffect(() => {
|
||||
const pinHandler = () => setPinned(readRailPreference("pinAppRail"));
|
||||
@@ -194,33 +201,20 @@ export function AppRail({
|
||||
// Plugins add and remove rail items at runtime, and permissions hide some.
|
||||
const railItems = useRailItems();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem("hiddenRailTabs");
|
||||
setHiddenTabs(stored ? new Set(JSON.parse(stored)) : new Set());
|
||||
} catch {
|
||||
setHiddenTabs(new Set());
|
||||
}
|
||||
};
|
||||
window.addEventListener("hiddenRailTabsChanged", handler);
|
||||
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || (expandOnHover && hovered);
|
||||
const railButtons = buildRailButtons(
|
||||
railItems.filter((item) => item.placement !== "footer"),
|
||||
t,
|
||||
hiddenTabs,
|
||||
const railExpanded = pinned || (expandOnHover && hovered) || managing;
|
||||
const mainItems = railItems.filter((item) => item.placement !== "footer");
|
||||
const bands = useMemo(
|
||||
() => groupRailItems(mainItems.filter((item) => !hiddenTabs.has(item.id))),
|
||||
[mainItems, hiddenTabs],
|
||||
);
|
||||
const hiddenItems = mainItems.filter((item) => hiddenTabs.has(item.id));
|
||||
const footerItems = railItems.filter(
|
||||
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
|
||||
);
|
||||
const setRailPinned = (nextPinned: boolean) => {
|
||||
setPinned(nextPinned);
|
||||
localStorage.setItem("pinAppRail", String(nextPinned));
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hiddenItems.length === 0) setManaging(false);
|
||||
}, [hiddenItems.length]);
|
||||
|
||||
const togglePinned = () => {
|
||||
setRailPreference("pinAppRail", !pinned);
|
||||
@@ -232,29 +226,92 @@ export function AppRail({
|
||||
setMenuPos(null);
|
||||
};
|
||||
|
||||
const renderItem = (item: RailItemDef) => {
|
||||
const Icon = item.icon;
|
||||
const title = t(item.labelKey);
|
||||
const isTab = item.kind === "tab";
|
||||
const active = !isTab && sidebarOpen && railView === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
onRailClick(item.id as RailView);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.id as TabType);
|
||||
}}
|
||||
onContextMenu={() =>
|
||||
setMenuTarget({
|
||||
id: item.id,
|
||||
title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
hideable: !item.alwaysVisible && item.hideable !== false,
|
||||
})
|
||||
}
|
||||
data-rail-item=""
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${active ? activeClass : idle}`}
|
||||
>
|
||||
<RailIcon useBadge={item.useBadge}>
|
||||
<Icon size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{title}</RailLabel>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
|
||||
style={{ width: rem(railExpanded ? 160 : 40) }}
|
||||
style={{ width: rem(railExpanded ? 172 : 40) }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
// Keep the menu on screen when right-clicking near the viewport edges
|
||||
const MENU_W = 190;
|
||||
const MENU_H = 96;
|
||||
const MENU_W = 200;
|
||||
const MENU_H = 180;
|
||||
setMenuPos({
|
||||
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
||||
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
|
||||
});
|
||||
if (!(e.target as HTMLElement).closest("[data-rail-promotable]")) {
|
||||
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
|
||||
setMenuTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{onOpenPalette && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPalette}
|
||||
title={t("nav.searchEverything")}
|
||||
aria-label={t("nav.searchEverything")}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${idle}`}
|
||||
>
|
||||
<RailIcon>
|
||||
<Search size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
|
||||
</button>
|
||||
<Rule expanded={railExpanded} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
||||
{!pluginsSettled
|
||||
? Array.from({ length: railButtons.length || 8 }, (_, i) => (
|
||||
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
|
||||
<div
|
||||
key={`rail-skeleton-${i}`}
|
||||
style={btnStyle}
|
||||
@@ -263,307 +320,106 @@ export function AppRail({
|
||||
<Skeleton className="size-4 shrink-0" />
|
||||
</div>
|
||||
))
|
||||
: railButtons.map((item, i) =>
|
||||
item.kind === "separator" ? (
|
||||
<div
|
||||
key={`sep-${i}`}
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{
|
||||
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
|
||||
}}
|
||||
/>
|
||||
) : "tabType" in item ? (
|
||||
<button
|
||||
key={item.tabType}
|
||||
onClick={() => onOpenTab?.(item.tabType)}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
: bands.map((band, i) => (
|
||||
<div key={band.group} className="flex flex-col gap-1">
|
||||
{i > 0 && (
|
||||
<GroupDivider group={band.group} expanded={railExpanded} />
|
||||
)}
|
||||
{band.items.map(renderItem)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pluginsSettled && hiddenItems.length > 0 && (
|
||||
<>
|
||||
<Rule expanded={railExpanded} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManaging((v) => !v)}
|
||||
aria-expanded={managing}
|
||||
title={t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${managing ? activeClass : idle}`}
|
||||
>
|
||||
<RailIcon>
|
||||
<EyeOff size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>
|
||||
{t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||
</RailLabel>
|
||||
</button>
|
||||
{managing &&
|
||||
hiddenItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={`hidden-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => void toggleHiddenRailTab(item.id)}
|
||||
title={t("nav.showInRail")}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={(e) => {
|
||||
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
||||
onOpenTab?.(item.view as TabType);
|
||||
return;
|
||||
}
|
||||
onRailClick(item.view);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.view as TabType);
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (item.promotable || item.rightDockable)
|
||||
setMenuTarget({
|
||||
view: item.view,
|
||||
title: item.title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
});
|
||||
}}
|
||||
data-rail-promotable={
|
||||
item.promotable || item.rightDockable ? "" : undefined
|
||||
}
|
||||
title={
|
||||
item.promotable
|
||||
? `${item.title}\n${t("nav.openAsTabHint")}`
|
||||
: item.title
|
||||
}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.view
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<RailIcon>
|
||||
<Icon size={16} />
|
||||
</RailIcon>
|
||||
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||
{showPinButton && (
|
||||
<button
|
||||
onClick={() => setRailPinned(!pinned)}
|
||||
style={btnStyle}
|
||||
title={
|
||||
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
|
||||
}
|
||||
className={`${btnBase} ${
|
||||
pinned
|
||||
? "text-accent-brand bg-accent-brand/10 hover:text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
<Pin size={16} />
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{showPinButton && (
|
||||
<div
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{
|
||||
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{footerItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const title = t(item.labelKey);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={(e) => {
|
||||
if (item.kind === "tab") {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
onRailClick(item.id as RailView);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.id as TabType);
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (item.promotable || item.rightDockable)
|
||||
setMenuTarget({
|
||||
view: item.id as RailView,
|
||||
title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
});
|
||||
}}
|
||||
data-rail-promotable={
|
||||
item.promotable || item.rightDockable ? "" : undefined
|
||||
}
|
||||
title={
|
||||
item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title
|
||||
}
|
||||
type="button"
|
||||
onClick={() => setRailPreference("pinAppRail", !pinned)}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.id
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{(
|
||||
[
|
||||
{
|
||||
view: "user-profile" as RailView,
|
||||
icon: <User size={16} />,
|
||||
title: t("nav.userProfile"),
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
view: "admin-settings" as RailView,
|
||||
icon: <Settings size={16} />,
|
||||
title: t("nav.admin"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] as {
|
||||
view: RailView;
|
||||
icon: ReactElement;
|
||||
title: string;
|
||||
promotable?: boolean;
|
||||
}[]
|
||||
).map((item) => (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={(e) => {
|
||||
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
||||
onOpenTab?.(item.view as TabType);
|
||||
return;
|
||||
title={
|
||||
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
|
||||
}
|
||||
onRailClick(item.view);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.view as TabType);
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (item.promotable)
|
||||
setMenuTarget({
|
||||
view: item.view,
|
||||
title: item.title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: true,
|
||||
});
|
||||
}}
|
||||
data-rail-promotable={item.promotable ? "" : undefined}
|
||||
title={
|
||||
item.promotable
|
||||
? `${item.title}\n${t("nav.openAsTabHint")}`
|
||||
: item.title
|
||||
}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.view
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
className={`${btnBase} ${pinned ? activeClass : idle}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="mx-2 my-1 border-t border-border" />
|
||||
<RailIcon>
|
||||
<Pin size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>
|
||||
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
|
||||
</RailLabel>
|
||||
</button>
|
||||
<Rule expanded={railExpanded} />
|
||||
</>
|
||||
)}
|
||||
{footerItems.map(renderItem)}
|
||||
{footerItems.length > 0 && <Rule expanded={railExpanded} />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLogout({ manual: true })}
|
||||
style={btnStyle}
|
||||
title={t("common.logout")}
|
||||
aria-label={t("common.logout")}
|
||||
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
<RailIcon>
|
||||
<LogOut size={16} />
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{t("common.logout")}
|
||||
</span>
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<button
|
||||
onClick={() => onRailClick("user-profile")}
|
||||
title={t("nav.userProfile")}
|
||||
aria-label={t("nav.userProfile")}
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
style={{ padding: "0 8px" }}
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
title={t("nav.openSettings")}
|
||||
aria-label={t("nav.openSettings")}
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
|
||||
style={{ padding: `0 ${rem(8)}` }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||
@@ -577,7 +433,7 @@ export function AppRail({
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
||||
{username || "User"}
|
||||
{username || t("nav.roleUser")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
||||
@@ -589,79 +445,101 @@ export function AppRail({
|
||||
{menuPos && (
|
||||
<div
|
||||
data-rail-context-menu
|
||||
role="menu"
|
||||
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
|
||||
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[190px]"
|
||||
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
|
||||
>
|
||||
{menuTarget && (
|
||||
<>
|
||||
{menuTarget.promotable && (
|
||||
<button
|
||||
<MenuItem
|
||||
icon={<SquareArrowOutUpRight className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenTab?.(menuTarget.view as TabType);
|
||||
onOpenTab?.(menuTarget.id as TabType);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
<SquareArrowOutUpRight className="size-3" />
|
||||
</span>
|
||||
{t("nav.openAsTab")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuTarget.rightDockable && (
|
||||
<button
|
||||
<MenuItem
|
||||
icon={<PanelRight className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenInRightDock?.(menuTarget.view);
|
||||
onOpenInRightDock?.(menuTarget.id);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
<PanelRight className="size-3" />
|
||||
</span>
|
||||
{t("nav.openInRightDock")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuTarget.hideable && (
|
||||
<MenuItem
|
||||
icon={<EyeOff className="size-3" />}
|
||||
onClick={() => {
|
||||
void toggleHiddenRailTab(menuTarget.id);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.hideFromRail")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
<MenuItem
|
||||
icon={pinned ? <Check className="size-3" /> : null}
|
||||
onClick={togglePinned}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={pinned}
|
||||
checked={pinned}
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
{pinned && <Check className="size-3" />}
|
||||
</span>
|
||||
{t("newUi.sidebar.userProfile.pinAppRail")}
|
||||
</button>
|
||||
<button
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={expandOnHover ? <Check className="size-3" /> : null}
|
||||
onClick={toggleExpandOnHover}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={expandOnHover}
|
||||
checked={expandOnHover}
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
{expandOnHover && <Check className="size-3" />}
|
||||
</span>
|
||||
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
<MenuItem
|
||||
icon={<SlidersHorizontal className="size-3" />}
|
||||
onClick={() => {
|
||||
onRailClick("user-profile");
|
||||
onOpenSettings();
|
||||
setMenuPos(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
<SlidersHorizontal className="size-3" />
|
||||
</span>
|
||||
{t("nav.sidebarSettings")}
|
||||
</button>
|
||||
{t("nav.openSettings")}
|
||||
</MenuItem>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
onClick,
|
||||
checked,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
checked?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role={checked === undefined ? "menuitem" : "menuitemcheckbox"}
|
||||
aria-checked={checked}
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
@@ -298,6 +299,7 @@ export function ConnectionsPanel({
|
||||
onReorderTabs?: (tabs: Tab[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
const [persistMinutes, setPersistMinutes] = useState(30);
|
||||
@@ -597,6 +599,13 @@ export function ConnectionsPanel({
|
||||
onReopenTab(record, liveSession?.sessionId ?? null);
|
||||
}}
|
||||
onClose={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("connections.forgetConfirm", {
|
||||
name: host?.name ?? record.label,
|
||||
}),
|
||||
confirmLabel: t("connections.forget"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await deleteOpenTab(record.id).catch(() => {});
|
||||
onForgetBackground(record.id);
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { EditorPane, EditorSection } from "@/manage/EditorPane";
|
||||
import { PluginComponent } from "@/plugin-host/component-registry";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
@@ -23,7 +25,7 @@ import { FolderPathPicker } from "./FolderPathPicker";
|
||||
|
||||
export function CredentialEditorView({
|
||||
credential,
|
||||
activeTab,
|
||||
banner,
|
||||
onBack,
|
||||
onSave,
|
||||
adminTargetUserId,
|
||||
@@ -31,7 +33,8 @@ export function CredentialEditorView({
|
||||
saveAsNewHost,
|
||||
}: {
|
||||
credential: Credential | null;
|
||||
activeTab: string;
|
||||
/** Shown above the form. */
|
||||
banner?: React.ReactNode;
|
||||
onBack: () => void;
|
||||
onSave: (
|
||||
saved: Record<string, unknown>,
|
||||
@@ -170,9 +173,73 @@ export function CredentialEditorView({
|
||||
}
|
||||
};
|
||||
|
||||
const nameMissing = !credForm.name.trim();
|
||||
const bands = [
|
||||
{
|
||||
id: "credential",
|
||||
label: t("manage.bandCredential"),
|
||||
items: [
|
||||
{
|
||||
id: "general",
|
||||
label: t("hosts.basicInformation"),
|
||||
icon: <Info className="size-3.5" />,
|
||||
error: nameMissing,
|
||||
},
|
||||
{
|
||||
id: "auth",
|
||||
label: t("hosts.authDetailsSection"),
|
||||
icon: <Lock className="size-3.5" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "general" && (
|
||||
<EditorPane
|
||||
bands={bands}
|
||||
banner={banner}
|
||||
errorCount={nameMissing ? 1 : 0}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
disabled={saving || savingAsNew}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
{saveAsNewHost && credential && !adminTargetUserId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="px-4"
|
||||
onClick={handleSaveAsNew}
|
||||
disabled={saving || savingAsNew || nameMissing}
|
||||
title={t("hosts.saveCredentialAsNewDesc")}
|
||||
>
|
||||
{savingAsNew
|
||||
? t("hosts.savingBtn")
|
||||
: t("hosts.saveCredentialAsNewBtn")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-6"
|
||||
onClick={handleSave}
|
||||
disabled={saving || savingAsNew || nameMissing}
|
||||
>
|
||||
{saving
|
||||
? t("hosts.savingBtn")
|
||||
: credential
|
||||
? t("hosts.updateCredentialBtn")
|
||||
: t("hosts.addCredentialBtn")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<EditorSection id="general" showHeading={false}>
|
||||
<SectionCard
|
||||
title={t("hosts.basicInformation")}
|
||||
icon={<Info className="size-3.5" />}
|
||||
@@ -265,9 +332,9 @@ export function CredentialEditorView({
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
</EditorSection>
|
||||
|
||||
{activeTab === "auth" && (
|
||||
<EditorSection id="auth" showHeading={false}>
|
||||
<SectionCard
|
||||
title={t("hosts.authDetailsSection")}
|
||||
icon={<Lock className="size-3.5" />}
|
||||
@@ -544,42 +611,7 @@ export function CredentialEditorView({
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 mt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={saving || savingAsNew}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
{saveAsNewHost && credential && !adminTargetUserId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-6"
|
||||
onClick={handleSaveAsNew}
|
||||
disabled={saving || savingAsNew}
|
||||
title={t("hosts.saveCredentialAsNewDesc")}
|
||||
>
|
||||
{savingAsNew
|
||||
? t("hosts.savingBtn")
|
||||
: t("hosts.saveCredentialAsNewBtn")}
|
||||
</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 || savingAsNew}
|
||||
>
|
||||
{saving
|
||||
? t("hosts.savingBtn")
|
||||
: credential
|
||||
? t("hosts.updateCredentialBtn")
|
||||
: t("hosts.addCredentialBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</EditorSection>
|
||||
</EditorPane>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Upload } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||
import {
|
||||
deleteCredential,
|
||||
deployCredentialToHost,
|
||||
duplicateCredential,
|
||||
getCredentials,
|
||||
getSSHHosts,
|
||||
renameCredentialFolder,
|
||||
updateCredential,
|
||||
} from "@/main-axios";
|
||||
import { requestManage } from "@/manage/manage-requests";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
import type {
|
||||
CredentialSidebarFilterState,
|
||||
CredentialSortKey,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
import { CredentialShareModal } from "./CredentialShareModal";
|
||||
import { CredentialSidebarTree } from "./credential-tree";
|
||||
import { credentialPassesFilters, sortCredentials } from "./credential-sort";
|
||||
import { enabledHostProtocols } from "./host-protocols";
|
||||
import { mapCredentials, sshHostToHost } from "./HostManagerData";
|
||||
|
||||
const UNCATEGORIZED = "Uncategorized";
|
||||
|
||||
/**
|
||||
* The credentials sidebar list. Editing happens in the Manage tab; this keeps
|
||||
* the tree, its folder moves and the quick actions on each row.
|
||||
*/
|
||||
export function CredentialList({
|
||||
search = "",
|
||||
sort = "default",
|
||||
arrangeLocked = true,
|
||||
filter,
|
||||
density = "comfortable",
|
||||
trayTrigger = "hover",
|
||||
showTags = true,
|
||||
onTagsChange,
|
||||
}: {
|
||||
search?: string;
|
||||
sort?: CredentialSortKey;
|
||||
arrangeLocked?: boolean;
|
||||
filter?: CredentialSidebarFilterState;
|
||||
density?: "comfortable" | "compact";
|
||||
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
|
||||
showTags?: boolean;
|
||||
onTagsChange?: (tags: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shareCredential, setShareCredential] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
const [deployTarget, setDeployTarget] = useState<Credential | null>(null);
|
||||
const [deployHostId, setDeployHostId] = useState("");
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [editingFolderName, setEditingFolderName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editingFolderValue, setEditingFolderValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [credentials]);
|
||||
|
||||
useEffect(() => {
|
||||
const reloadHosts = () => {
|
||||
getSSHHosts()
|
||||
.then((raw) => setHosts(raw.map(sshHostToHost)))
|
||||
.catch(() => {});
|
||||
};
|
||||
const reloadCredentials = () => {
|
||||
getCredentials()
|
||||
.then((res) => setCredentials(mapCredentials(res)))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
reloadHosts();
|
||||
reloadCredentials();
|
||||
window.addEventListener("termix:hosts-changed", reloadHosts);
|
||||
window.addEventListener("ssh-hosts:changed", reloadHosts);
|
||||
window.addEventListener("termix:credentials-changed", reloadCredentials);
|
||||
return () => {
|
||||
window.removeEventListener("termix:hosts-changed", reloadHosts);
|
||||
window.removeEventListener("ssh-hosts:changed", reloadHosts);
|
||||
window.removeEventListener(
|
||||
"termix:credentials-changed",
|
||||
reloadCredentials,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const needle = search.toLowerCase();
|
||||
const searched = credentials.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(needle) ||
|
||||
c.username.toLowerCase().includes(needle),
|
||||
);
|
||||
const filtered = sortCredentials(
|
||||
filter
|
||||
? searched.filter((c) => credentialPassesFilters(c, filter))
|
||||
: searched,
|
||||
sort,
|
||||
);
|
||||
const folderNames = Array.from(
|
||||
new Set(filtered.map((c) => c.folder || UNCATEGORIZED)),
|
||||
).sort();
|
||||
const folderTree = folderNames.map((name) => ({
|
||||
name,
|
||||
children: filtered.filter((c) => (c.folder || UNCATEGORIZED) === name),
|
||||
}));
|
||||
const usedByCounts = new Map<string, number>();
|
||||
for (const cred of filtered) {
|
||||
usedByCounts.set(
|
||||
cred.id,
|
||||
hosts.filter((h) => h.credentialId === cred.id).length,
|
||||
);
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
const res = await getCredentials();
|
||||
setCredentials(mapCredentials(res));
|
||||
};
|
||||
|
||||
const renameFolder = async (folder: string, newName: string) => {
|
||||
try {
|
||||
await renameCredentialFolder(folder, newName);
|
||||
await reload();
|
||||
toast.success(t("credentials.folderRenamedTo", { name: newName }));
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToRenameFolder"));
|
||||
}
|
||||
};
|
||||
|
||||
const moveToFolder = async (credentialId: string, targetFolder: string) => {
|
||||
const cred = credentials.find((c) => c.id === credentialId);
|
||||
if (!cred || (cred.folder || UNCATEGORIZED) === targetFolder) return;
|
||||
const folderValue = targetFolder === UNCATEGORIZED ? "" : targetFolder;
|
||||
try {
|
||||
await updateCredential(Number(credentialId), { folder: folderValue });
|
||||
setCredentials((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === credentialId ? { ...c, folder: folderValue } : c,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToMoveCredential"));
|
||||
}
|
||||
};
|
||||
|
||||
const clone = async (cred: Credential) => {
|
||||
try {
|
||||
await duplicateCredential(Number(cred.id), {
|
||||
name: t("credentials.clonedCredentialName", { name: cred.name }),
|
||||
});
|
||||
await reload();
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
toast.success(t("credentials.clonedCredential", { name: cred.name }));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
toast.error(msg || t("credentials.failedToCloneCredential"));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (cred: Credential) => {
|
||||
const ok = await confirm({
|
||||
title: t("credentials.deleteCredentialConfirm", { name: cred.name }),
|
||||
description: t("manage.cannotBeUndone"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteCredential(Number(cred.id));
|
||||
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
toast.success(t("credentials.deletedCredential", { name: cred.name }));
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToDeleteCredential"));
|
||||
}
|
||||
};
|
||||
|
||||
const deploy = async () => {
|
||||
if (!deployTarget || !deployHostId) return;
|
||||
setDeploying(true);
|
||||
try {
|
||||
await deployCredentialToHost(
|
||||
Number(deployTarget.id),
|
||||
Number(deployHostId),
|
||||
);
|
||||
toast.success(t("credentials.keyDeployedSuccess"));
|
||||
setDeployTarget(null);
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToDeployKey"));
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{!loading && filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("credentials.noCredentialsFound")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3 h-7 border-accent-brand/40 text-xs text-accent-brand hover:bg-accent-brand/10"
|
||||
onClick={() =>
|
||||
requestManage({ kind: "credential", credentialId: null })
|
||||
}
|
||||
>
|
||||
{t("credentials.addCredentialBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CredentialSidebarTree
|
||||
folders={folderTree}
|
||||
usedByCounts={usedByCounts}
|
||||
query=""
|
||||
loading={loading}
|
||||
arrangeLocked={arrangeLocked}
|
||||
density={density}
|
||||
trayTrigger={trayTrigger}
|
||||
showTags={showTags}
|
||||
editingFolderName={editingFolderName}
|
||||
editingFolderValue={editingFolderValue}
|
||||
onEditingFolderNameChange={setEditingFolderName}
|
||||
onEditingFolderValueChange={setEditingFolderValue}
|
||||
onRenameFolder={renameFolder}
|
||||
onMoveCredentialToFolder={moveToFolder}
|
||||
onDeployCredential={(cred) => {
|
||||
setDeployHostId("");
|
||||
setDeployTarget(cred);
|
||||
}}
|
||||
onEditCredential={(cred) =>
|
||||
requestManage({ kind: "credential", credentialId: cred.id })
|
||||
}
|
||||
onCloneCredential={clone}
|
||||
onDeleteCredential={remove}
|
||||
onShareCredential={setShareCredential}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CredentialShareModal
|
||||
credential={shareCredential}
|
||||
onClose={() => setShareCredential(null)}
|
||||
/>
|
||||
|
||||
<InlineView
|
||||
open={!!deployTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeployTarget(null);
|
||||
}}
|
||||
icon={<Upload className="size-4" />}
|
||||
title={t("credentials.deployDialogTitle")}
|
||||
footer={
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeployTarget(null)}
|
||||
disabled={deploying}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
||||
disabled={!deployHostId || deploying}
|
||||
onClick={() => void deploy()}
|
||||
>
|
||||
{deploying
|
||||
? t("credentials.deployingBtn")
|
||||
: t("credentials.deployBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("credentials.deployDialogDesc", { name: deployTarget?.name })}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.targetHostLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={deployHostId}
|
||||
onChange={(e) => setDeployHostId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("credentials.selectHostOption")}</option>
|
||||
{hosts
|
||||
.filter(
|
||||
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
|
||||
)
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
</InlineView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ListChecks,
|
||||
Search,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -72,6 +72,7 @@ export function CredentialShareModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||
const [search, setSearch] = useState("");
|
||||
const [shareUsers, setShareUsers] = useState<
|
||||
@@ -228,6 +229,13 @@ export function CredentialShareModal({
|
||||
|
||||
async function handleRevoke(record: AccessRecord) {
|
||||
if (!credentialId) return;
|
||||
const who =
|
||||
record.username ?? record.roleDisplayName ?? record.roleName ?? "";
|
||||
const ok = await confirm({
|
||||
title: t("sharing.revokeConfirm", { name: who }),
|
||||
confirmLabel: t("sharing.revoke"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await revokeCredentialAccess(credentialId, record.id);
|
||||
setAccessList((prev) => prev.filter((entry) => entry.id !== record.id));
|
||||
@@ -237,20 +245,16 @@ export function CredentialShareModal({
|
||||
}
|
||||
}
|
||||
|
||||
if (!credential) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("credentials.share.title", { name: credential.name ?? "" })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<InlineView
|
||||
open={!!credential}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
icon={<Share2 className="size-4" />}
|
||||
title={t("credentials.share.title", { name: credential?.name ?? "" })}
|
||||
bare
|
||||
>
|
||||
{loadError && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||
@@ -570,6 +574,6 @@ export function CredentialShareModal({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { HostManager } from "@/sidebar/HostManager";
|
||||
import { CredentialList } from "@/sidebar/CredentialList";
|
||||
import { requestManage } from "@/manage/manage-requests";
|
||||
import { useCredentialSidebarPreferences } from "@/sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
|
||||
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
||||
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
|
||||
@@ -30,8 +31,7 @@ import {
|
||||
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
|
||||
|
||||
export function CredentialsPanel({
|
||||
onEditingChange,
|
||||
active = true,
|
||||
active: _active = true,
|
||||
}: {
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
active?: boolean;
|
||||
@@ -41,7 +41,6 @@ export function CredentialsPanel({
|
||||
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
|
||||
useCredentialSidebarPreferences();
|
||||
const [search, setSearch] = useState("");
|
||||
const [managerEditing, setManagerEditing] = useState(false);
|
||||
const [allTags, setAllTags] = useState<string[]>([]);
|
||||
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
|
||||
|
||||
@@ -94,14 +93,9 @@ export function CredentialsPanel({
|
||||
}));
|
||||
}
|
||||
|
||||
function handleEditingChange(editing: boolean) {
|
||||
setManagerEditing(editing);
|
||||
onEditingChange?.(editing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{!managerEditing && (
|
||||
{
|
||||
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
|
||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm">
|
||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
@@ -297,9 +291,7 @@ export function CredentialsPanel({
|
||||
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
|
||||
<button
|
||||
onClick={() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:add-credential"),
|
||||
)
|
||||
requestManage({ kind: "credential", credentialId: null })
|
||||
}
|
||||
title={t("credentials.addCredential")}
|
||||
className="flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 transition-colors"
|
||||
@@ -312,21 +304,18 @@ export function CredentialsPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<HostManager
|
||||
hideListHeader
|
||||
externalSearch={managerEditing ? undefined : search}
|
||||
externalSort={sortKey}
|
||||
externalArrangeLocked={arrangeLocked}
|
||||
externalFilter={filterState}
|
||||
<CredentialList
|
||||
search={search}
|
||||
sort={sortKey}
|
||||
arrangeLocked={arrangeLocked}
|
||||
filter={filterState}
|
||||
density={sidebarPrefs.display.density}
|
||||
trayTrigger={sidebarPrefs.display.trayTrigger}
|
||||
showTags={sidebarPrefs.display.showTags}
|
||||
onTagsChange={setAllTags}
|
||||
onEditingChange={handleEditingChange}
|
||||
active={active}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Rows3, SquareStack } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type {
|
||||
CredentialDensity,
|
||||
@@ -32,108 +26,104 @@ export function CustomizeCredentialsSidebarPanel({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("credentials.customizeSidebar")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("credentials.customizeSidebarDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<SectionCard
|
||||
title={t("credentials.customizeDensityTitle")}
|
||||
icon={<Rows3 className="size-3.5" />}
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("credentials.customizeSidebar")}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("credentials.customizeSidebarDescription")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<SectionCard
|
||||
title={t("credentials.customizeDensityTitle")}
|
||||
icon={<Rows3 className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("credentials.customizeDensityTitle")}
|
||||
description={
|
||||
preferences.display.density === "comfortable"
|
||||
? t("credentials.densityComfortableDesc")
|
||||
: t("credentials.densityCompactDesc")
|
||||
}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("credentials.customizeDensityTitle")}
|
||||
description={
|
||||
preferences.display.density === "comfortable"
|
||||
? t("credentials.densityComfortableDesc")
|
||||
: t("credentials.densityCompactDesc")
|
||||
<select
|
||||
value={preferences.display.density}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
density: e.target.value as CredentialDensity,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<select
|
||||
value={preferences.display.density}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
density: e.target.value as CredentialDensity,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="comfortable">
|
||||
{t("credentials.displayDensityComfortable")}
|
||||
</option>
|
||||
<option value="compact">
|
||||
{t("credentials.displayDensityCompact")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
<option value="comfortable">
|
||||
{t("credentials.displayDensityComfortable")}
|
||||
</option>
|
||||
<option value="compact">
|
||||
{t("credentials.displayDensityCompact")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("credentials.customizeBehaviorTitle")}
|
||||
icon={<SquareStack className="size-3.5" />}
|
||||
<SectionCard
|
||||
title={t("credentials.customizeBehaviorTitle")}
|
||||
icon={<SquareStack className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("credentials.showCredentialTags")}
|
||||
description={t("credentials.showCredentialTagsDesc")}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("credentials.showCredentialTags")}
|
||||
description={t("credentials.showCredentialTagsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.showTags}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, showTags: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("credentials.actionsVisibility")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("credentials.actionsVisibilityDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
value={preferences.display.trayTrigger}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
trayTrigger: e.target.value as CredentialTrayTrigger,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="always">{t("credentials.actionsAlways")}</option>
|
||||
<option value="actionsOnly">
|
||||
{t("credentials.actionsOnly")}
|
||||
</option>
|
||||
<option value="hover">{t("credentials.actionsHover")}</option>
|
||||
<option value="click">{t("credentials.actionsClick")}</option>
|
||||
</select>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.showTags}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, showTags: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("credentials.actionsVisibility")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("credentials.actionsVisibilityDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<select
|
||||
value={preferences.display.trayTrigger}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
trayTrigger: e.target.value as CredentialTrayTrigger,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="always">{t("credentials.actionsAlways")}</option>
|
||||
<option value="actionsOnly">
|
||||
{t("credentials.actionsOnly")}
|
||||
</option>
|
||||
<option value="hover">{t("credentials.actionsHover")}</option>
|
||||
<option value="click">{t("credentials.actionsClick")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
{t("credentials.customizeSortHint")}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
{t("credentials.customizeSortHint")}
|
||||
</p>
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Rows3, SquareStack } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type {
|
||||
HostClickBehavior,
|
||||
@@ -33,186 +27,182 @@ export function CustomizeSidebarPanel({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("hosts.customizeSidebar")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("hosts.customizeSidebarDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<SectionCard
|
||||
title={t("hosts.customizeDensityTitle")}
|
||||
icon={<Rows3 className="size-3.5" />}
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("hosts.customizeSidebar")}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hosts.customizeSidebarDescription")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<SectionCard
|
||||
title={t("hosts.customizeDensityTitle")}
|
||||
icon={<Rows3 className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("hosts.customizeDensityTitle")}
|
||||
description={
|
||||
preferences.display.density === "comfortable"
|
||||
? t("hosts.densityComfortableDesc")
|
||||
: t("hosts.densityCompactDesc")
|
||||
}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("hosts.customizeDensityTitle")}
|
||||
description={
|
||||
preferences.display.density === "comfortable"
|
||||
? t("hosts.densityComfortableDesc")
|
||||
: t("hosts.densityCompactDesc")
|
||||
<select
|
||||
value={preferences.display.density}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
density: e.target.value as HostDensity,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<select
|
||||
value={preferences.display.density}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
density: e.target.value as HostDensity,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="comfortable">
|
||||
{t("hosts.displayDensityComfortable")}
|
||||
</option>
|
||||
<option value="compact">
|
||||
{t("hosts.displayDensityCompact")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
<option value="comfortable">
|
||||
{t("hosts.displayDensityComfortable")}
|
||||
</option>
|
||||
<option value="compact">
|
||||
{t("hosts.displayDensityCompact")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.customizeBehaviorTitle")}
|
||||
icon={<SquareStack className="size-3.5" />}
|
||||
<SectionCard
|
||||
title={t("hosts.customizeBehaviorTitle")}
|
||||
icon={<SquareStack className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.showHostTags")}
|
||||
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.showHostTags")}
|
||||
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.showTags}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, showTags: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.showFolderPaths")}
|
||||
description={t("hosts.showFolderPathsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.showFolderPaths}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, showFolderPaths: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("hosts.actionsVisibility")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("hosts.actionsVisibilityDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
value={preferences.display.trayTrigger}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
trayTrigger: e.target.value as HostTrayTrigger,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="always">{t("hosts.actionsAlways")}</option>
|
||||
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
|
||||
<option value="hover">{t("hosts.actionsHover")}</option>
|
||||
<option value="click">{t("hosts.actionsClick")}</option>
|
||||
</select>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.showTags}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, showTags: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.showFolderPaths")}
|
||||
description={t("hosts.showFolderPathsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.showFolderPaths}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, showFolderPaths: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("hosts.actionsVisibility")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("hosts.actionsVisibilityDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("hosts.hostClickBehavior")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("hosts.hostClickBehaviorDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
value={preferences.display.hostClickBehavior}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
hostClickBehavior: e.target.value as HostClickBehavior,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
|
||||
<option value="focusExisting">
|
||||
{t("hosts.hostClickFocusExisting")}
|
||||
</option>
|
||||
<option value="focusExistingDoubleClickNew">
|
||||
{t("hosts.hostClickFocusExistingDoubleNew")}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
value={preferences.display.trayTrigger}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
trayTrigger: e.target.value as HostTrayTrigger,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="always">{t("hosts.actionsAlways")}</option>
|
||||
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
|
||||
<option value="hover">{t("hosts.actionsHover")}</option>
|
||||
<option value="click">{t("hosts.actionsClick")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("hosts.hostClickBehavior")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("hosts.hostClickBehaviorDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.openOnDoubleClick")}
|
||||
description={t("hosts.openOnDoubleClickDesc")}
|
||||
<select
|
||||
value={preferences.display.hostClickBehavior}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
hostClickBehavior: e.target.value as HostClickBehavior,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.openOnDoubleClick}
|
||||
disabled={
|
||||
preferences.display.hostClickBehavior ===
|
||||
"focusExistingDoubleClickNew"
|
||||
}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, openOnDoubleClick: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.statusColors")}
|
||||
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.statusColorScheme === "status"}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
statusColorScheme: v ? "status" : "accent",
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
|
||||
<option value="focusExisting">
|
||||
{t("hosts.hostClickFocusExisting")}
|
||||
</option>
|
||||
<option value="focusExistingDoubleClickNew">
|
||||
{t("hosts.hostClickFocusExistingDoubleNew")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.openOnDoubleClick")}
|
||||
description={t("hosts.openOnDoubleClickDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.openOnDoubleClick}
|
||||
disabled={
|
||||
preferences.display.hostClickBehavior ===
|
||||
"focusExistingDoubleClickNew"
|
||||
}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, openOnDoubleClick: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.statusColors")}
|
||||
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.statusColorScheme === "status"}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
statusColorScheme: v ? "status" : "accent",
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
{t("hosts.customizeSortHint")}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
{t("hosts.customizeSortHint")}
|
||||
</p>
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
@@ -124,125 +118,126 @@ export function FolderMetadataDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{mode === "create"
|
||||
? t("hosts.createFolderTitle")
|
||||
: t("hosts.editFolderTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("hosts.folderDialogDescription")}
|
||||
</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">
|
||||
{t("hosts.folderNameLabel")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("hosts.folderNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isDuplicate) handleSubmit();
|
||||
}}
|
||||
aria-invalid={isDuplicate}
|
||||
className={isDuplicate ? "border-destructive" : undefined}
|
||||
/>
|
||||
{isDuplicate ? (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{t("hosts.folderNameDuplicate")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.folderNestingHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderColor")}
|
||||
</label>
|
||||
<HexColorPicker value={color} onChange={setColor} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderIcon")}
|
||||
</label>
|
||||
<IconPicker value={icon} color={color} onChange={setIcon} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={credentialId}
|
||||
onChange={(e) => setCredentialId(e.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.folderCredentialNone")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<>
|
||||
{mode === "create"
|
||||
? t("hosts.createFolderTitle")
|
||||
: t("hosts.editFolderTitle")}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hosts.folderDialogDescription")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderNameLabel")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("hosts.folderNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isDuplicate) handleSubmit();
|
||||
}}
|
||||
aria-invalid={isDuplicate}
|
||||
className={isDuplicate ? "border-destructive" : undefined}
|
||||
/>
|
||||
{isDuplicate ? (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{t("hosts.folderNameDuplicate")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.folderCredentialHint")}
|
||||
{t("hosts.folderNestingHint")}
|
||||
</p>
|
||||
</div>
|
||||
{syncLinked && (
|
||||
<SettingRow
|
||||
label={t("hosts.folderLocalOnly")}
|
||||
description={t("hosts.folderLocalOnlyDesc")}
|
||||
>
|
||||
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
|
||||
</SettingRow>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderPreview")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
|
||||
<FolderIconEl
|
||||
icon={icon}
|
||||
className="size-4 shrink-0"
|
||||
style={{ color }}
|
||||
/>
|
||||
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
|
||||
{name
|
||||
? splitPath(name).map((seg, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5">
|
||||
{i > 0 && (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
{seg}
|
||||
</span>
|
||||
))
|
||||
: t("hosts.folderNameFallback")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderColor")}
|
||||
</label>
|
||||
<HexColorPicker value={color} onChange={setColor} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderIcon")}
|
||||
</label>
|
||||
<IconPicker value={icon} color={color} onChange={setIcon} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={credentialId}
|
||||
onChange={(e) => setCredentialId(e.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.folderCredentialNone")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.folderCredentialHint")}
|
||||
</p>
|
||||
</div>
|
||||
{syncLinked && (
|
||||
<SettingRow
|
||||
label={t("hosts.folderLocalOnly")}
|
||||
description={t("hosts.folderLocalOnlyDesc")}
|
||||
>
|
||||
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
|
||||
</SettingRow>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderPreview")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
|
||||
<FolderIconEl
|
||||
icon={icon}
|
||||
className="size-4 shrink-0"
|
||||
style={{ color }}
|
||||
/>
|
||||
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
|
||||
{name
|
||||
? splitPath(name).map((seg, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5">
|
||||
{i > 0 && (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
{seg}
|
||||
</span>
|
||||
))
|
||||
: t("hosts.folderNameFallback")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("hosts.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || isDuplicate}
|
||||
>
|
||||
{mode === "create"
|
||||
? t("hosts.createFolderButton")
|
||||
: t("hosts.saveFolderButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("hosts.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || isDuplicate}
|
||||
>
|
||||
{mode === "create"
|
||||
? t("hosts.createFolderButton")
|
||||
: t("hosts.saveFolderButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
getCredentials,
|
||||
getHostAuthOverride,
|
||||
@@ -123,78 +116,18 @@ export function HostAuthOverrideModal({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("hosts.sharing.authOverrideTitleProtocol", {
|
||||
protocol: authProtocolLabel(protocol, t),
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
ownerAuthShared
|
||||
? "hosts.sharing.authOverrideDescriptionShared"
|
||||
: "hosts.sharing.authOverrideDescriptionPrivate",
|
||||
{ host: host.name },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-4 text-center text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : loadError ? (
|
||||
<p className="border border-destructive/30 bg-destructive/5 p-3 text-destructive">
|
||||
{t("hosts.sharing.authOverrideLoadError")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor={`auth-override-${host.id}`}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>
|
||||
{t("hosts.sharing.authOverrideCredentialLabel")}
|
||||
</label>
|
||||
<select
|
||||
id={`auth-override-${host.id}`}
|
||||
value={selectedId}
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
ownerAuthShared
|
||||
? "hosts.sharing.useSharedAuthentication"
|
||||
: "hosts.sharing.noPersonalCredential",
|
||||
)}
|
||||
</option>
|
||||
{credentials.map((credential) => (
|
||||
<option key={credential.id} value={credential.id}>
|
||||
{credential.username
|
||||
? `${credential.name} (${credential.username})`
|
||||
: credential.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{credentials.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.sharing.authOverrideNoCredentials")}
|
||||
</p>
|
||||
)}
|
||||
{overrideState?.required && selectedId === "" && (
|
||||
<p className="border border-amber-500/30 bg-amber-500/5 p-2 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{t("hosts.sharing.authOverrideRequired")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.sharing.authOverridePrivateHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<>
|
||||
{t("hosts.sharing.authOverrideTitleProtocol", {
|
||||
protocol: authProtocolLabel(protocol, t),
|
||||
})}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -212,9 +145,70 @@ export function HostAuthOverrideModal({
|
||||
>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
ownerAuthShared
|
||||
? "hosts.sharing.authOverrideDescriptionShared"
|
||||
: "hosts.sharing.authOverrideDescriptionPrivate",
|
||||
{ host: host.name },
|
||||
)}
|
||||
</p>
|
||||
{loading ? (
|
||||
<p className="py-4 text-center text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : loadError ? (
|
||||
<p className="border border-destructive/30 bg-destructive/5 p-3 text-destructive">
|
||||
{t("hosts.sharing.authOverrideLoadError")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor={`auth-override-${host.id}`}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>
|
||||
{t("hosts.sharing.authOverrideCredentialLabel")}
|
||||
</label>
|
||||
<select
|
||||
id={`auth-override-${host.id}`}
|
||||
value={selectedId}
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
ownerAuthShared
|
||||
? "hosts.sharing.useSharedAuthentication"
|
||||
: "hosts.sharing.noPersonalCredential",
|
||||
)}
|
||||
</option>
|
||||
{credentials.map((credential) => (
|
||||
<option key={credential.id} value={credential.id}>
|
||||
{credential.username
|
||||
? `${credential.name} (${credential.username})`
|
||||
: credential.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{credentials.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.sharing.authOverrideNoCredentials")}
|
||||
</p>
|
||||
)}
|
||||
{overrideState?.required && selectedId === "" && (
|
||||
<p className="border border-warning/30 bg-warning/5 p-2 text-[10px] text-warning">
|
||||
{t("hosts.sharing.authOverrideRequired")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.sharing.authOverridePrivateHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</InlineView>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+873
-809
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,7 @@ import { getSSHFolders, isElectron } from "@/main-axios";
|
||||
import { connectionOriginAppliesTo } from "./HostEditorData";
|
||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
||||
import type { HostFormErrors } from "@/manage/host-validation";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useHostProtocols } from "./host-protocols";
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ export function HostEditorGeneralTab({
|
||||
hosts,
|
||||
host,
|
||||
simpleMode = false,
|
||||
errors = {},
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
@@ -44,6 +46,7 @@ export function HostEditorGeneralTab({
|
||||
host: Host | null;
|
||||
/** Hides organizational/advanced fields; their values still save unchanged. */
|
||||
simpleMode?: boolean;
|
||||
errors?: HostFormErrors;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const syncLinked = !!useSyncStatus()?.linked;
|
||||
@@ -210,8 +213,14 @@ export function HostEditorGeneralTab({
|
||||
<Input
|
||||
placeholder={t("placeholders.hostAddress")}
|
||||
value={form.ip}
|
||||
aria-invalid={!!errors.ip}
|
||||
onChange={(e) => setField("ip", e.target.value)}
|
||||
/>
|
||||
{errors.ip && (
|
||||
<span className="text-[10px] text-destructive">
|
||||
{t(errors.ip)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
+124
-130
@@ -1,3 +1,4 @@
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -7,13 +8,6 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
|
||||
import { isFolder } from "@/sidebar/SidebarTree";
|
||||
import {
|
||||
@@ -220,126 +214,13 @@ export function HostExportDialog({
|
||||
const canExport = !loading && count > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="flex flex-col w-[95vw] sm:max-w-6xl max-h-[85dvh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("hosts.export.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
|
||||
{/* Scope + host picker */}
|
||||
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.export.scope")}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={scope === "all" ? "default" : "outline"}
|
||||
onClick={() => setScope("all")}
|
||||
>
|
||||
{t("hosts.export.scopeAll")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={scope === "selected" ? "default" : "outline"}
|
||||
onClick={() => setScope("selected")}
|
||||
>
|
||||
{t("hosts.export.scopeSelected")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-7 h-8 text-xs"
|
||||
placeholder={t("hosts.export.searchHosts")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={scope === "all"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
|
||||
{visibleHosts.map((host) => {
|
||||
const key = hostKey(host as unknown as Record<string, unknown>);
|
||||
return (
|
||||
<label
|
||||
key={String(host.id)}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={scope === "all" || selectedKeys.has(key)}
|
||||
disabled={scope === "all"}
|
||||
onCheckedChange={() => toggleHost(key)}
|
||||
/>
|
||||
<span className="truncate">{host.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Field groups */}
|
||||
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.export.include")}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs opacity-60">
|
||||
<Checkbox checked disabled />
|
||||
<span>{t("hosts.export.groupConnection")}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox
|
||||
checked={withCredentials}
|
||||
onCheckedChange={(v) => setWithCredentials(v === true)}
|
||||
/>
|
||||
<span>{t("hosts.export.groupCredentials")}</span>
|
||||
</label>
|
||||
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
|
||||
<label
|
||||
key={group.key}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={groups.has(group.key)}
|
||||
onCheckedChange={() => toggleGroup(group.key)}
|
||||
/>
|
||||
<span>{t(`hosts.export.${group.label}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.export.preview")}
|
||||
</div>
|
||||
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{count === 0 ? (
|
||||
t("hosts.export.noneSelected")
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-x-3">
|
||||
<span>
|
||||
{t("hosts.export.summary", {
|
||||
selected: count,
|
||||
total: exportableHosts.length,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{withCredentials
|
||||
? t("hosts.export.credentialsIncluded")
|
||||
: t("hosts.export.credentialsExcluded")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={(next) => !next && onClose()}
|
||||
title={t("hosts.export.title")}
|
||||
width="wide"
|
||||
footer={
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("hosts.export.cancel")}
|
||||
</Button>
|
||||
@@ -352,8 +233,121 @@ export function HostExportDialog({
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.export.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
|
||||
{/* Scope + host picker */}
|
||||
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.export.scope")}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={scope === "all" ? "default" : "outline"}
|
||||
onClick={() => setScope("all")}
|
||||
>
|
||||
{t("hosts.export.scopeAll")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={scope === "selected" ? "default" : "outline"}
|
||||
onClick={() => setScope("selected")}
|
||||
>
|
||||
{t("hosts.export.scopeSelected")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-7 h-8 text-xs"
|
||||
placeholder={t("hosts.export.searchHosts")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={scope === "all"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
|
||||
{visibleHosts.map((host) => {
|
||||
const key = hostKey(host as unknown as Record<string, unknown>);
|
||||
return (
|
||||
<label
|
||||
key={String(host.id)}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={scope === "all" || selectedKeys.has(key)}
|
||||
disabled={scope === "all"}
|
||||
onCheckedChange={() => toggleHost(key)}
|
||||
/>
|
||||
<span className="truncate">{host.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Field groups */}
|
||||
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.export.include")}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs opacity-60">
|
||||
<Checkbox checked disabled />
|
||||
<span>{t("hosts.export.groupConnection")}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox
|
||||
checked={withCredentials}
|
||||
onCheckedChange={(v) => setWithCredentials(v === true)}
|
||||
/>
|
||||
<span>{t("hosts.export.groupCredentials")}</span>
|
||||
</label>
|
||||
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
|
||||
<label
|
||||
key={group.key}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={groups.has(group.key)}
|
||||
onCheckedChange={() => toggleGroup(group.key)}
|
||||
/>
|
||||
<span>{t(`hosts.export.${group.label}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.export.preview")}
|
||||
</div>
|
||||
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{count === 0 ? (
|
||||
t("hosts.export.noneSelected")
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-x-3">
|
||||
<span>
|
||||
{t("hosts.export.summary", {
|
||||
selected: count,
|
||||
total: exportableHosts.length,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{withCredentials
|
||||
? t("hosts.export.credentialsIncluded")
|
||||
: t("hosts.export.credentialsExcluded")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -1,928 +0,0 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
enabledHostProtocols,
|
||||
hostProtocolFlags,
|
||||
type HostProtocols,
|
||||
} from "./host-protocols";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { HostDefaultsLevel } from "@/types/host-defaults";
|
||||
import { getFolderDefaultsId } from "@/api/host-defaults-api";
|
||||
import {
|
||||
HostDefaultsEditorView,
|
||||
type HostDefaultsEditorTarget,
|
||||
} from "./host-defaults/HostDefaultsEditorView";
|
||||
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
||||
import { CredentialShareModal } from "./CredentialShareModal";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { ArrowLeft, ChevronDown, Search, X } from "lucide-react";
|
||||
import { useUiPreference } from "@/contexts/UiPreferencesContext";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getCredentials,
|
||||
getCredentialDetails,
|
||||
deleteCredential,
|
||||
duplicateCredential,
|
||||
updateCredential,
|
||||
deployCredentialToHost,
|
||||
renameCredentialFolder,
|
||||
} from "@/main-axios";
|
||||
|
||||
import type { Host, Credential } from "@/types/ui-types";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type {
|
||||
CredentialSidebarFilterState,
|
||||
CredentialSortKey,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
import { CredentialEditorView } from "./CredentialEditorView";
|
||||
import { HostEditor } from "./HostEditor";
|
||||
import { mapCredentials, sshHostToHost } from "./HostManagerData";
|
||||
import { CredentialSidebarTree } from "./credential-tree";
|
||||
import { sortCredentials, credentialPassesFilters } from "./credential-sort";
|
||||
import {
|
||||
makeCredentialTabs,
|
||||
makeHostTabs,
|
||||
makeHostSshSubTabs,
|
||||
isSshGroupTab,
|
||||
useHostEditorSections,
|
||||
TabStrip,
|
||||
} from "./HostManagerTabs";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
export function HostManager({
|
||||
pendingEditId,
|
||||
pendingAction,
|
||||
onEditingChange,
|
||||
hideListHeader,
|
||||
externalSearch,
|
||||
externalSort,
|
||||
externalArrangeLocked = true,
|
||||
externalFilter,
|
||||
density = "comfortable",
|
||||
trayTrigger = "hover",
|
||||
showTags = true,
|
||||
onTagsChange,
|
||||
active = true,
|
||||
}: {
|
||||
pendingEditId?: MutableRefObject<string | null>;
|
||||
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
hideListHeader?: boolean;
|
||||
externalSearch?: string;
|
||||
externalSort?: CredentialSortKey;
|
||||
externalArrangeLocked?: boolean;
|
||||
externalFilter?: CredentialSidebarFilterState;
|
||||
density?: "comfortable" | "compact";
|
||||
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
|
||||
showTags?: boolean;
|
||||
onTagsChange?: (tags: string[]) => void;
|
||||
active?: boolean;
|
||||
} = {}) {
|
||||
// Re-render when a plugin adds or removes a host editor tab.
|
||||
useHostEditorSections();
|
||||
const { t } = useTranslation();
|
||||
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
|
||||
// A level of host defaults open in the editor instead of a host.
|
||||
const [editingDefaults, setEditingDefaults] =
|
||||
useState<HostDefaultsEditorTarget | null>(null);
|
||||
// Fields a plugin filled in for a new host, keyed so a second draft remounts
|
||||
// the editor.
|
||||
const [hostDraft, setHostDraft] = useState<{
|
||||
key: number;
|
||||
draft: HostDraft;
|
||||
} | null>(null);
|
||||
const [shareCredential, setShareCredential] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
const [editingCredential, setEditingCredential] = useState<
|
||||
Credential | "new" | null
|
||||
>(null);
|
||||
const [activeHostTab, setActiveHostTab] = useState("general");
|
||||
const [activeCredentialTab, setActiveCredentialTab] = useState("general");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const effectiveSearch = externalSearch ?? searchQuery;
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [credentialsLoading, setCredentialsLoading] = useState(true);
|
||||
const [deployDialog, setDeployDialog] = useState<{
|
||||
cred: Credential;
|
||||
hostId: string;
|
||||
} | null>(null);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [hostEditorDirty, setHostEditorDirty] = useState(false);
|
||||
const [showUnsavedHostDialog, setShowUnsavedHostDialog] = useState(false);
|
||||
const [editingProtocols, setEditingProtocols] = useState<HostProtocols>(() =>
|
||||
hostProtocolFlags(null),
|
||||
);
|
||||
const simpleEditor = useUiPreference("hostEditor", "mode") === "simple";
|
||||
// Expanding advanced is a per-session choice; fixing one host's SSH options
|
||||
// shouldn't quietly move the whole app off the Simple preset.
|
||||
const [showAdvancedEditor, setShowAdvancedEditor] = useState(false);
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
useEffect(() => {
|
||||
hostsRef.current = hosts;
|
||||
}, [hosts]);
|
||||
const [editingCredFolderName, setEditingCredFolderName] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [editingCredFolderValue, setEditingCredFolderValue] = useState("");
|
||||
// Remembers the host being edited when its credential is opened for
|
||||
// editing, so "back" can return to that host instead of the list.
|
||||
const [credentialReturnHost, setCredentialReturnHost] = useState<
|
||||
Host | "new" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
|
||||
}, [credentials]);
|
||||
|
||||
const applyPendingEdit = (hostList: Host[]) => {
|
||||
if (pendingEditId?.current) {
|
||||
const id = pendingEditId.current;
|
||||
pendingEditId.current = null;
|
||||
const host = hostList.find((h) => h.id === id);
|
||||
if (host) {
|
||||
setEditingHost(host);
|
||||
setEditingCredential(null);
|
||||
setActiveHostTab("general");
|
||||
setEditingProtocols(hostProtocolFlags(host));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const reloadHosts = () => {
|
||||
getSSHHosts()
|
||||
.then((raw) => {
|
||||
const converted = raw.map(sshHostToHost);
|
||||
setHosts(converted);
|
||||
applyPendingEdit(converted);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const reloadCredentials = () => {
|
||||
getCredentials()
|
||||
.then((res) => setCredentials(mapCredentials(res)))
|
||||
.catch(() => {})
|
||||
.finally(() => setCredentialsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadHosts();
|
||||
reloadCredentials();
|
||||
|
||||
window.addEventListener("termix:hosts-changed", reloadHosts);
|
||||
window.addEventListener("ssh-hosts:changed", reloadHosts);
|
||||
window.addEventListener("hosts:refresh", reloadHosts);
|
||||
window.addEventListener("termix:credentials-changed", reloadCredentials);
|
||||
return () => {
|
||||
window.removeEventListener("termix:hosts-changed", reloadHosts);
|
||||
window.removeEventListener("ssh-hosts:changed", reloadHosts);
|
||||
window.removeEventListener("hosts:refresh", reloadHosts);
|
||||
window.removeEventListener(
|
||||
"termix:credentials-changed",
|
||||
reloadCredentials,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingAction?.current) {
|
||||
const action = pendingAction.current;
|
||||
pendingAction.current = null;
|
||||
if (action === "add-host") {
|
||||
setEditingHost("new");
|
||||
setHostDraft(null);
|
||||
setEditingCredential(null);
|
||||
setEditingProtocols(hostProtocolFlags(null));
|
||||
setActiveHostTab("general");
|
||||
} else if (action === "add-credential") {
|
||||
setEditingCredential("new");
|
||||
setEditingHost(null);
|
||||
setActiveCredentialTab("general");
|
||||
}
|
||||
}
|
||||
}, [pendingEditId, pendingAction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handleAddHost = (e: Event) => {
|
||||
const draft = (e as CustomEvent<HostDraft | undefined>).detail;
|
||||
setEditingHost("new");
|
||||
setHostDraft(draft ? { key: Date.now(), draft } : null);
|
||||
setEditingCredential(null);
|
||||
setEditingProtocols(hostProtocolFlags(null));
|
||||
setActiveHostTab("general");
|
||||
};
|
||||
const handleAddCredential = () => {
|
||||
setEditingCredential("new");
|
||||
setEditingHost(null);
|
||||
setActiveCredentialTab("general");
|
||||
};
|
||||
const handleEditDefaults = (e: Event) => {
|
||||
const detail = (
|
||||
e as CustomEvent<{ level: HostDefaultsLevel; folderName?: string }>
|
||||
).detail;
|
||||
if (!detail) return;
|
||||
const open = (target: HostDefaultsEditorTarget) => {
|
||||
setEditingHost(null);
|
||||
setEditingCredential(null);
|
||||
setEditingDefaults(target);
|
||||
};
|
||||
if (detail.level === "folder" && detail.folderName) {
|
||||
const folderName = detail.folderName;
|
||||
getFolderDefaultsId(folderName)
|
||||
.then((folderId) => open({ level: "folder", folderId, folderName }))
|
||||
.catch(() => toast.error(t("hostDefaults.loadFailed")));
|
||||
} else if (detail.level !== "folder") {
|
||||
open({ level: detail.level });
|
||||
}
|
||||
};
|
||||
const handleEditHost = (e: Event) => {
|
||||
const id = (e as CustomEvent<string>).detail;
|
||||
const host = hostsRef.current.find((h) => h.id === id);
|
||||
if (host) {
|
||||
setEditingHost(host);
|
||||
setEditingCredential(null);
|
||||
setActiveHostTab("general");
|
||||
setEditingProtocols(hostProtocolFlags(host));
|
||||
}
|
||||
};
|
||||
window.addEventListener("host-manager:add-host", handleAddHost);
|
||||
window.addEventListener("host-manager:add-credential", handleAddCredential);
|
||||
window.addEventListener("host-manager:edit-host", handleEditHost);
|
||||
window.addEventListener("host-manager:edit-defaults", handleEditDefaults);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"host-manager:edit-defaults",
|
||||
handleEditDefaults,
|
||||
);
|
||||
window.removeEventListener("host-manager:add-host", handleAddHost);
|
||||
window.removeEventListener(
|
||||
"host-manager:add-credential",
|
||||
handleAddCredential,
|
||||
);
|
||||
window.removeEventListener("host-manager:edit-host", handleEditHost);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
const allHosts = hosts;
|
||||
const searchedCredentials = credentials.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
|
||||
c.username.toLowerCase().includes(effectiveSearch.toLowerCase()),
|
||||
);
|
||||
const filteredCredentials = sortCredentials(
|
||||
externalFilter
|
||||
? searchedCredentials.filter((c) =>
|
||||
credentialPassesFilters(c, externalFilter),
|
||||
)
|
||||
: searchedCredentials,
|
||||
externalSort ?? "default",
|
||||
);
|
||||
|
||||
const credentialFolderNames = Array.from(
|
||||
new Set(filteredCredentials.map((c) => c.folder || "Uncategorized")),
|
||||
).sort();
|
||||
const credentialFolderTree = credentialFolderNames.map((name) => ({
|
||||
name,
|
||||
children: filteredCredentials.filter(
|
||||
(c) => (c.folder || "Uncategorized") === name,
|
||||
),
|
||||
}));
|
||||
const usedByCounts = new Map<string, number>();
|
||||
for (const cred of filteredCredentials) {
|
||||
usedByCounts.set(
|
||||
cred.id,
|
||||
allHosts.filter((h) => h.credentialId === cred.id).length,
|
||||
);
|
||||
}
|
||||
|
||||
const handleRenameCredentialFolder = async (
|
||||
folder: string,
|
||||
newName: string,
|
||||
) => {
|
||||
try {
|
||||
await renameCredentialFolder(folder, newName);
|
||||
const res = await getCredentials();
|
||||
setCredentials(mapCredentials(res));
|
||||
toast.success(t("credentials.folderRenamedTo", { name: newName }));
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToRenameFolder"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCredentialToFolder = async (
|
||||
credentialId: string,
|
||||
targetFolder: string,
|
||||
) => {
|
||||
const cred = credentials.find((c) => c.id === credentialId);
|
||||
if (!cred || (cred.folder || "Uncategorized") === targetFolder) return;
|
||||
const folderValue = targetFolder === "Uncategorized" ? "" : targetFolder;
|
||||
try {
|
||||
await updateCredential(Number(credentialId), { folder: folderValue });
|
||||
setCredentials((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === credentialId ? { ...c, folder: folderValue } : c,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToMoveCredential"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneCredential = async (cred: Credential) => {
|
||||
try {
|
||||
await duplicateCredential(Number(cred.id), {
|
||||
name: t("credentials.clonedCredentialName", { name: cred.name }),
|
||||
});
|
||||
const res = await getCredentials();
|
||||
setCredentials(mapCredentials(res));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
toast.success(t("credentials.clonedCredential", { name: cred.name }));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
toast.error(msg || t("credentials.failedToCloneCredential"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCredential = async (cred: Credential) => {
|
||||
await deleteCredential(Number(cred.id));
|
||||
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
|
||||
};
|
||||
|
||||
function handleConfirmDeleteCredential(cred: Credential) {
|
||||
setConfirmDialog({
|
||||
message: t("credentials.deleteCredentialConfirm", { name: cred.name }),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await handleDeleteCredential(cred);
|
||||
toast.success(
|
||||
t("credentials.deletedCredential", { name: cred.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToDeleteCredential"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEditCredential(cred: Credential) {
|
||||
try {
|
||||
const full = (await getCredentialDetails(Number(cred.id))) as {
|
||||
hasKey?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
password?: string;
|
||||
certPublicKey?: string;
|
||||
};
|
||||
setEditingCredential({
|
||||
...cred,
|
||||
value: full.hasKey ? "existing_key" : (full.password ?? ""),
|
||||
password: full.password ?? "",
|
||||
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
|
||||
publicKey: full.certPublicKey ?? cred.publicKey,
|
||||
});
|
||||
setActiveCredentialTab("general");
|
||||
} catch {
|
||||
setEditingCredential(cred);
|
||||
setActiveCredentialTab("general");
|
||||
}
|
||||
}
|
||||
|
||||
// Opens a host's credential for editing from within the host editor,
|
||||
// remembering the host so "back" returns to it instead of the list.
|
||||
async function handleEditCredentialFromHost(credentialId: string) {
|
||||
const cred = credentials.find((c) => String(c.id) === String(credentialId));
|
||||
if (!cred) return;
|
||||
setCredentialReturnHost(editingHost);
|
||||
await handleEditCredential(cred);
|
||||
// The editor view keys off editingHost, so it has to be cleared or the
|
||||
// host editor keeps rendering over the credential we just opened.
|
||||
setEditingHost(null);
|
||||
}
|
||||
|
||||
// Editor view: full-width with top tab bar instead of side nav
|
||||
const closeHostEditor = () => {
|
||||
setHostEditorDirty(false);
|
||||
setShowUnsavedHostDialog(false);
|
||||
setEditingHost(null);
|
||||
setEditingDefaults(null);
|
||||
setActiveHostTab("general");
|
||||
};
|
||||
|
||||
const requestCloseHostEditor = () => {
|
||||
if (hostEditorDirty) {
|
||||
setShowUnsavedHostDialog(true);
|
||||
return;
|
||||
}
|
||||
closeHostEditor();
|
||||
};
|
||||
|
||||
const renderEditorView = () => {
|
||||
if (editingDefaults) {
|
||||
return (
|
||||
<HostDefaultsEditorView
|
||||
target={editingDefaults}
|
||||
onClose={requestCloseHostEditor}
|
||||
onDirtyChange={setHostEditorDirty}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isHost = !!editingHost;
|
||||
// Simple mode keeps General and SSH -- between them they hold everything
|
||||
// needed to reach a host (name, address, username, auth) -- and hides the
|
||||
// other protocol tabs plus the seven SSH sub-tabs. Nothing is lost:
|
||||
// createHostEditorForm still materializes every field with its default and
|
||||
// buildHostEditorPayload still serializes all of them, so hiding a tab
|
||||
// hides its inputs, not its values.
|
||||
const collapseAdvanced = isHost && simpleEditor && !showAdvancedEditor;
|
||||
const protocols = editingProtocols as unknown as Record<string, boolean>;
|
||||
// Plugin tabs decide their own visibility from the host's protocols.
|
||||
const tabs = isHost
|
||||
? makeHostTabs(t, protocols).filter((tab) => {
|
||||
if (tab.id === "general") return true;
|
||||
if (tab.id === "ssh") return editingProtocols.enableSsh;
|
||||
return !collapseAdvanced;
|
||||
})
|
||||
: makeCredentialTabs(t);
|
||||
// Collapsing while on a now-hidden tab would leave nothing selected. The
|
||||
// SSH group collapses to its own "ssh" tab, everything else to General.
|
||||
// The top-level strip only lists general, ssh and protocol tabs -- the SSH
|
||||
// sub-tabs live in the secondary strip, so they count as visible whenever
|
||||
// the SSH group is expanded.
|
||||
const sshSubTabs = makeHostSshSubTabs(t, protocols);
|
||||
// Registered SSH-group tabs count too: checking a fixed list here is what
|
||||
// left plugin tabs unreachable.
|
||||
const hostTabVisible =
|
||||
tabs.some((tab) => tab.id === activeHostTab) ||
|
||||
(!collapseAdvanced &&
|
||||
editingProtocols.enableSsh &&
|
||||
sshSubTabs.some((tab) => tab.id === activeHostTab));
|
||||
const effectiveHostTab = hostTabVisible
|
||||
? activeHostTab
|
||||
: collapseAdvanced && isSshGroupTab(activeHostTab)
|
||||
? "ssh"
|
||||
: "general";
|
||||
const activeTab = isHost ? effectiveHostTab : activeCredentialTab;
|
||||
const setActiveTab = isHost ? setActiveHostTab : setActiveCredentialTab;
|
||||
const showSshSubTabs =
|
||||
isHost &&
|
||||
!collapseAdvanced &&
|
||||
editingProtocols.enableSsh &&
|
||||
isSshGroupTab(activeHostTab);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Back bar + tab strip */}
|
||||
<div className="flex flex-col shrink-0 border-b border-border">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isHost) {
|
||||
requestCloseHostEditor();
|
||||
} else if (credentialReturnHost) {
|
||||
setEditingHost(credentialReturnHost);
|
||||
setCredentialReturnHost(null);
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
} else {
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{isHost
|
||||
? t("hosts.backToHosts")
|
||||
: credentialReturnHost
|
||||
? t("hosts.backToHost")
|
||||
: t("credentials.backToCredentials")}
|
||||
</span>
|
||||
{isHost && editingHost !== "new" && (
|
||||
<span
|
||||
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
|
||||
title={(editingHost as Host).name}
|
||||
>
|
||||
{(editingHost as Host).name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<TabStrip
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => {
|
||||
if (isHost && id === "ssh") {
|
||||
if (!isSshGroupTab(activeHostTab)) {
|
||||
setActiveHostTab("ssh");
|
||||
}
|
||||
} else {
|
||||
setActiveTab(id);
|
||||
}
|
||||
}}
|
||||
isActive={
|
||||
isHost
|
||||
? (id) =>
|
||||
id === "ssh"
|
||||
? isSshGroupTab(activeHostTab)
|
||||
: activeHostTab === id
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{showSshSubTabs && (
|
||||
<TabStrip
|
||||
tabs={sshSubTabs}
|
||||
activeTab={activeHostTab}
|
||||
onTabChange={setActiveHostTab}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
{isHost && simpleEditor && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (showAdvancedEditor) setActiveHostTab("general");
|
||||
setShowAdvancedEditor(!showAdvancedEditor);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-t border-border/50"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3 transition-transform ${showAdvancedEditor ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{showAdvancedEditor
|
||||
? t("hosts.hideAdvancedSettings")
|
||||
: t("hosts.showAdvancedSettings")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
{isHost ? (
|
||||
<HostEditor
|
||||
key={
|
||||
editingHost === "new"
|
||||
? `new-host-${hostDraft?.key ?? ""}`
|
||||
: (editingHost as Host).id
|
||||
}
|
||||
host={editingHost === "new" ? null : (editingHost as Host)}
|
||||
draft={editingHost === "new" ? hostDraft?.draft : undefined}
|
||||
activeTab={effectiveHostTab}
|
||||
simpleMode={collapseAdvanced}
|
||||
onBack={requestCloseHostEditor}
|
||||
onSave={(saved) => {
|
||||
const updated = sshHostToHost(
|
||||
saved as unknown as SSHHostWithStatus,
|
||||
);
|
||||
setHosts((prev) => {
|
||||
const idx = prev.findIndex((h) => h.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = updated;
|
||||
return next;
|
||||
}
|
||||
return [...prev, updated];
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
closeHostEditor();
|
||||
}}
|
||||
protocols={editingProtocols}
|
||||
onProtocolChange={(p) =>
|
||||
setEditingProtocols((prev) => ({ ...prev, ...p }))
|
||||
}
|
||||
onDirtyChange={setHostEditorDirty}
|
||||
onTabChange={setActiveHostTab}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
onEditCredential={handleEditCredentialFromHost}
|
||||
/>
|
||||
) : (
|
||||
<CredentialEditorView
|
||||
key={
|
||||
editingCredential === "new"
|
||||
? "new-cred"
|
||||
: (editingCredential as Credential).id
|
||||
}
|
||||
credential={
|
||||
editingCredential === "new"
|
||||
? null
|
||||
: (editingCredential as Credential)
|
||||
}
|
||||
activeTab={activeCredentialTab}
|
||||
existingFolders={Array.from(
|
||||
new Set(
|
||||
credentials
|
||||
.map((c) => c.folder)
|
||||
.filter((f): f is string => !!f),
|
||||
),
|
||||
).sort()}
|
||||
saveAsNewHost={
|
||||
credentialReturnHost ? credentialReturnHost : undefined
|
||||
}
|
||||
onBack={() => {
|
||||
if (credentialReturnHost) {
|
||||
setEditingHost(credentialReturnHost);
|
||||
setCredentialReturnHost(null);
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
return;
|
||||
}
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
}}
|
||||
onSave={(saved, options) => {
|
||||
// The save endpoints are typed as an untyped record; these are
|
||||
// the fields this view reads back off the response.
|
||||
const result = saved as Partial<Credential> & {
|
||||
id: number | string;
|
||||
authType?: string;
|
||||
};
|
||||
setCredentials((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === String(result.id));
|
||||
const updated: Credential = {
|
||||
id: String(result.id),
|
||||
name: result.name ?? "",
|
||||
username: result.username ?? "",
|
||||
type: result.authType === "key" ? "key" : "password",
|
||||
value: result.value,
|
||||
password: result.password,
|
||||
publicKey: result.publicKey,
|
||||
passphrase: result.passphrase,
|
||||
description: result.description,
|
||||
folder: result.folder ?? "",
|
||||
tags: result.tags ?? [],
|
||||
};
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = updated;
|
||||
return next;
|
||||
}
|
||||
return [...prev, updated];
|
||||
});
|
||||
if (options?.assignToHost && credentialReturnHost) {
|
||||
const returnHost = credentialReturnHost;
|
||||
setCredentialReturnHost(null);
|
||||
if (returnHost !== "new") {
|
||||
setHosts((prev) =>
|
||||
prev.map((h) =>
|
||||
h.id === returnHost.id
|
||||
? { ...h, credentialId: String(result.id) }
|
||||
: h,
|
||||
),
|
||||
);
|
||||
}
|
||||
setEditingHost(
|
||||
returnHost === "new"
|
||||
? "new"
|
||||
: { ...returnHost, credentialId: String(result.id) },
|
||||
);
|
||||
setActiveHostTab("ssh");
|
||||
setEditingCredential(null);
|
||||
return;
|
||||
}
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isEditing = !!editingHost || !!editingCredential || !!editingDefaults;
|
||||
|
||||
useEffect(() => {
|
||||
if (active) onEditingChange?.(isEditing);
|
||||
}, [isEditing, active]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{isEditing ? (
|
||||
renderEditorView()
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Search bar — hidden when parent supplies its own */}
|
||||
{!hideListHeader && (
|
||||
<div className="px-2 py-1.5 shrink-0 border-b border-border/40">
|
||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
|
||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("credentials.searchCredentialsPlaceholder")}
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!credentialsLoading && filteredCredentials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("credentials.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={() => {
|
||||
setEditingCredential("new");
|
||||
setActiveCredentialTab("general");
|
||||
}}
|
||||
>
|
||||
{t("credentials.addCredentialBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CredentialSidebarTree
|
||||
folders={credentialFolderTree}
|
||||
usedByCounts={usedByCounts}
|
||||
query=""
|
||||
loading={credentialsLoading}
|
||||
arrangeLocked={externalArrangeLocked}
|
||||
density={density}
|
||||
trayTrigger={trayTrigger}
|
||||
showTags={showTags}
|
||||
editingFolderName={editingCredFolderName}
|
||||
editingFolderValue={editingCredFolderValue}
|
||||
onEditingFolderNameChange={setEditingCredFolderName}
|
||||
onEditingFolderValueChange={setEditingCredFolderValue}
|
||||
onRenameFolder={handleRenameCredentialFolder}
|
||||
onMoveCredentialToFolder={handleMoveCredentialToFolder}
|
||||
onDeployCredential={(cred) =>
|
||||
setDeployDialog({ cred, hostId: "" })
|
||||
}
|
||||
onEditCredential={handleEditCredential}
|
||||
onCloneCredential={handleCloneCredential}
|
||||
onDeleteCredential={handleConfirmDeleteCredential}
|
||||
onShareCredential={setShareCredential}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CredentialShareModal
|
||||
credential={shareCredential}
|
||||
onClose={() => setShareCredential(null)}
|
||||
/>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirmDialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
|
||||
<p className="text-sm text-foreground">{confirmDialog.message}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmDialog(null)}
|
||||
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
confirmDialog.onConfirm();
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
|
||||
>
|
||||
{t("hosts.deleteConfirmBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnsavedHostDialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t("common.unsavedChanges")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hosts.unsavedChangesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowUnsavedHostDialog(false)}
|
||||
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{t("hosts.keepEditing")}
|
||||
</button>
|
||||
<button
|
||||
onClick={closeHostEditor}
|
||||
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
|
||||
>
|
||||
{t("hosts.discardChanges")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deploy credential dialog */}
|
||||
{deployDialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold">
|
||||
{t("credentials.deployDialogTitle")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setDeployDialog(null)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("credentials.deployDialogDesc", {
|
||||
name: deployDialog.cred.name,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.targetHostLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={deployDialog.hostId}
|
||||
onChange={(e) =>
|
||||
setDeployDialog({ ...deployDialog, hostId: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">{t("credentials.selectHostOption")}</option>
|
||||
{allHosts
|
||||
.filter(
|
||||
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
|
||||
)
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeployDialog(null)}
|
||||
disabled={deploying}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
||||
disabled={!deployDialog.hostId || deploying}
|
||||
onClick={async () => {
|
||||
setDeploying(true);
|
||||
try {
|
||||
await deployCredentialToHost(
|
||||
Number(deployDialog.cred.id),
|
||||
Number(deployDialog.hostId),
|
||||
);
|
||||
toast.success(t("credentials.keyDeployedSuccess"));
|
||||
setDeployDialog(null);
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToDeployKey"));
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deploying
|
||||
? t("credentials.deployingBtn")
|
||||
: t("credentials.deployBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,6 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
KeyRound,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type ComponentType, type ReactNode } from "react";
|
||||
import { KeyRound, Settings, Terminal } from "lucide-react";
|
||||
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
||||
|
||||
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
|
||||
@@ -196,115 +183,4 @@ export function makeCredentialTabs(
|
||||
];
|
||||
}
|
||||
|
||||
export function TabStrip({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
isActive,
|
||||
variant = "primary",
|
||||
}: {
|
||||
tabs: { id: string; label: string; icon: ReactNode }[];
|
||||
activeTab: string;
|
||||
onTabChange: (id: string) => void;
|
||||
isActive?: (id: string) => boolean;
|
||||
variant?: "primary" | "secondary";
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const [scroll, setScroll] = useState({ left: false, right: false });
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const update = () =>
|
||||
setScroll({
|
||||
left: el.scrollLeft > 1,
|
||||
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
|
||||
});
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
if (el.firstElementChild) observer.observe(el.firstElementChild);
|
||||
el.addEventListener("scroll", update);
|
||||
update();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
el.removeEventListener("scroll", update);
|
||||
};
|
||||
}, [tabs]);
|
||||
|
||||
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 renderTab = (tab: (typeof tabs)[0]) => {
|
||||
const active = isActive ? isActive(tab.id) : activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 ${
|
||||
variant === "secondary" ? "py-1.5 text-[11px]" : "py-2 text-xs"
|
||||
} font-medium whitespace-nowrap border-b-2 transition-colors shrink-0 ${
|
||||
active
|
||||
? "border-accent-brand text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const overflow = scroll.left || scroll.right;
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-0 items-center ${variant === "secondary" ? "border-t border-border bg-card" : ""}`}
|
||||
>
|
||||
{overflow && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.scrollTabsLeft")}
|
||||
title={t("common.scrollTabsLeft")}
|
||||
disabled={!scroll.left}
|
||||
className="shrink-0 p-1 disabled:opacity-30"
|
||||
onClick={() =>
|
||||
ref.current?.scrollBy({
|
||||
left: -ref.current.clientWidth * 0.75,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<div ref={ref} className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
|
||||
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
|
||||
</div>
|
||||
{overflow && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.scrollTabsRight")}
|
||||
title={t("common.scrollTabsRight")}
|
||||
disabled={!scroll.right}
|
||||
className="shrink-0 p-1 disabled:opacity-30"
|
||||
onClick={() =>
|
||||
ref.current?.scrollBy({
|
||||
left: ref.current.clientWidth * 0.75,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { TabStrip } from "@/components/tab-strip";
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
type FolderAccessRule,
|
||||
} from "@/api/rbac-api";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ListChecks,
|
||||
Search,
|
||||
@@ -70,6 +70,7 @@ export function HostShareModal({
|
||||
folder?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const isFolderShare = !host && !!folder;
|
||||
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -278,23 +279,20 @@ export function HostShareModal({
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{isFolderShare
|
||||
? t("hosts.shareFolderTitle", { name: folder ?? "" })
|
||||
: t("hosts.shareHostTitle", { name: host?.name ?? "" })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onClose();
|
||||
}}
|
||||
icon={<Share2 className="size-4" />}
|
||||
title={
|
||||
isFolderShare
|
||||
? t("hosts.shareFolderTitle", { name: folder ?? "" })
|
||||
: t("hosts.shareHostTitle", { name: host?.name ?? "" })
|
||||
}
|
||||
bare
|
||||
>
|
||||
{sharingLoadError && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||
@@ -544,8 +542,19 @@ export function HostShareModal({
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive"
|
||||
title={t("hosts.sharing.folderRuleRemove")}
|
||||
onClick={() => {
|
||||
void revokeFolderAccess(rule.id).then(refreshFolderRules);
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("sharing.revokeConfirm", {
|
||||
name:
|
||||
rule.username ??
|
||||
rule.roleDisplayName ??
|
||||
rule.roleName ??
|
||||
"",
|
||||
}),
|
||||
confirmLabel: t("sharing.revoke"),
|
||||
});
|
||||
if (ok)
|
||||
void revokeFolderAccess(rule.id).then(refreshFolderRules);
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
@@ -626,6 +635,17 @@ export function HostShareModal({
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("sharing.revokeConfirm", {
|
||||
name:
|
||||
record.username ??
|
||||
record.roleDisplayName ??
|
||||
record.roleName ??
|
||||
"",
|
||||
}),
|
||||
confirmLabel: t("sharing.revoke"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await revokeHostAccess(
|
||||
Number(host!.id),
|
||||
@@ -676,6 +696,6 @@ export function HostShareModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SidebarTree, isFolder } from "@/sidebar/SidebarTree";
|
||||
import { HostManager } from "@/sidebar/HostManager";
|
||||
import { HostShareModal } from "@/sidebar/HostShareModal";
|
||||
import { HostExportDialog } from "@/sidebar/HostExportDialog";
|
||||
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
|
||||
@@ -200,7 +199,6 @@ export function HostsPanel({
|
||||
onEditHost,
|
||||
hostTree,
|
||||
loading,
|
||||
onEditingChange,
|
||||
active = true,
|
||||
}: {
|
||||
onOpenTab: (
|
||||
@@ -215,6 +213,7 @@ export function HostsPanel({
|
||||
onEditHost: (host: Host) => void;
|
||||
hostTree?: HostFolder;
|
||||
loading?: boolean;
|
||||
/** Unused since editing moved to the Manage tab. */
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
active?: boolean;
|
||||
}) {
|
||||
@@ -238,7 +237,6 @@ export function HostsPanel({
|
||||
const hostProtocols = useHostProtocols();
|
||||
const sshAuthProviders = useSshAuthProviders();
|
||||
const [hostSearch, setHostSearch] = useState("");
|
||||
const [managerEditing, setManagerEditing] = useState(false);
|
||||
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -345,11 +343,6 @@ export function HostsPanel({
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleEditingChange(editing: boolean) {
|
||||
setManagerEditing(editing);
|
||||
onEditingChange?.(editing);
|
||||
}
|
||||
|
||||
function toggleSelectionMode() {
|
||||
setSelectionMode((v) => !v);
|
||||
}
|
||||
@@ -422,7 +415,7 @@ export function HostsPanel({
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{!managerEditing && (
|
||||
{
|
||||
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
|
||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-none">
|
||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
@@ -1023,7 +1016,7 @@ export function HostsPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
<CustomizeSidebarPanel
|
||||
open={customizePanelOpen}
|
||||
onOpenChange={setCustomizePanelOpen}
|
||||
@@ -1031,7 +1024,7 @@ export function HostsPanel({
|
||||
update={updateSidebarPrefs}
|
||||
/>
|
||||
|
||||
{active && !managerEditing && (
|
||||
{active && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<SidebarTree
|
||||
children={
|
||||
@@ -1069,12 +1062,6 @@ export function HostsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
|
||||
>
|
||||
<HostManager onEditingChange={handleEditingChange} active={active} />
|
||||
</div>
|
||||
|
||||
<HostShareModal
|
||||
open={shareModalHost !== null}
|
||||
onClose={() => setShareModalHost(null)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import {
|
||||
Check,
|
||||
Gauge,
|
||||
@@ -9,14 +10,6 @@ import {
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
@@ -45,10 +38,7 @@ export function InterfacePresetSettings({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useUiPreferencesContext();
|
||||
const [pendingPreset, setPendingPreset] = useState<Exclude<
|
||||
UiPreset,
|
||||
"custom"
|
||||
> | null>(null);
|
||||
const confirm = useConfirm();
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
// The chosen preset stays highlighted even once knobs diverge from it --
|
||||
@@ -87,18 +77,25 @@ export function InterfacePresetSettings({
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
async function confirmPreset() {
|
||||
if (!pendingPreset) return;
|
||||
async function choosePreset(preset: Exclude<UiPreset, "custom">) {
|
||||
const ok = await confirm({
|
||||
title: t("newUi.sidebar.userProfile.presetConfirmTitle", {
|
||||
preset: t(`newUi.sidebar.userProfile.preset_${preset}`),
|
||||
}),
|
||||
description: t("newUi.sidebar.userProfile.presetConfirmDesc"),
|
||||
confirmLabel: t("newUi.sidebar.userProfile.presetConfirmApply"),
|
||||
destructive: false,
|
||||
});
|
||||
if (!ok) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
ctx.setPreset(pendingPreset);
|
||||
ctx.setPreset(preset);
|
||||
// Picking a preset is a deliberate reset, so any knob the user had
|
||||
// pinned goes back to following the preset.
|
||||
ctx.clearAllOverrides();
|
||||
await applyPresetSideEffects(pendingPreset);
|
||||
await applyPresetSideEffects(preset);
|
||||
} finally {
|
||||
setApplying(false);
|
||||
setPendingPreset(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +115,8 @@ export function InterfacePresetSettings({
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setPendingPreset(preset)}
|
||||
disabled={applying}
|
||||
onClick={() => void choosePreset(preset)}
|
||||
aria-pressed={active}
|
||||
className={`flex w-full items-start gap-2 border p-2 text-left transition-colors ${
|
||||
active
|
||||
@@ -203,44 +201,6 @@ export function InterfacePresetSettings({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!pendingPreset}
|
||||
onOpenChange={(open) => !open && setPendingPreset(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold">
|
||||
{t("newUi.sidebar.userProfile.presetConfirmTitle", {
|
||||
preset: pendingPreset
|
||||
? t(`newUi.sidebar.userProfile.preset_${pendingPreset}`)
|
||||
: "",
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.presetConfirmDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPendingPreset(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={applying}
|
||||
onClick={confirmPreset}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.presetConfirmApply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getUserPreferences,
|
||||
parseCustomKeybindings,
|
||||
} from "@/api/open-tabs-api";
|
||||
import { saveUserPreferences } from "@/main-axios";
|
||||
import {
|
||||
useKeybindingActions,
|
||||
useKeybindingDefaults,
|
||||
type KeybindingActionDef,
|
||||
} from "@/shell/keybinding-registry";
|
||||
import type {
|
||||
CustomKeybinding,
|
||||
KeybindingAction,
|
||||
KeyCombo,
|
||||
} from "@/types/keybindings";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { FixedShortcutsList } from "./FixedShortcutsList";
|
||||
|
||||
function formatCombo(combo: KeyCombo): string {
|
||||
const parts: string[] = [];
|
||||
if (combo.ctrl) parts.push("Ctrl");
|
||||
if (combo.alt) parts.push("Alt");
|
||||
if (combo.shift) parts.push("Shift");
|
||||
if (combo.meta) parts.push("Cmd");
|
||||
parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
|
||||
return parts.join(" + ");
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
/** An action without the keys an editor cleared, so it saves clean. */
|
||||
function withoutEmpty(action: KeybindingAction): KeybindingAction {
|
||||
const out: KeybindingAction = { type: action.type };
|
||||
for (const [key, value] of Object.entries(action)) {
|
||||
if (key !== "type" && value !== undefined && value !== null) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appearance > Keybindings. The actions come from the keybinding registry:
|
||||
* the shell's own and whatever the running plugins register, each with its
|
||||
* own parameter editor. A saved binding whose plugin is off stays in the
|
||||
* list and keeps working once the plugin is back.
|
||||
*/
|
||||
export function KeybindingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const actions = useKeybindingActions();
|
||||
const defaults = useKeybindingDefaults();
|
||||
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [overridesDefaultId, setOverridesDefaultId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [draft, setDraft] = useState<KeybindingAction>({ type: "" });
|
||||
|
||||
const actionById = useMemo(
|
||||
() => new Map(actions.map((action) => [action.id, action])),
|
||||
[actions],
|
||||
);
|
||||
const draftDef: KeybindingActionDef | undefined = actionById.get(draft.type);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
getUserPreferences()
|
||||
.then((prefs) =>
|
||||
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
|
||||
)
|
||||
.catch(() => setBindings([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
const conflictWarning = useMemo(() => {
|
||||
if (!recordedCombo) return null;
|
||||
const conflict = bindings.find(
|
||||
(kb) =>
|
||||
kb.id !== editingId &&
|
||||
kb.enabled &&
|
||||
kb.combo.ctrl === recordedCombo.ctrl &&
|
||||
kb.combo.alt === recordedCombo.alt &&
|
||||
kb.combo.shift === recordedCombo.shift &&
|
||||
kb.combo.meta === recordedCombo.meta &&
|
||||
kb.combo.key === recordedCombo.key,
|
||||
);
|
||||
if (conflict) {
|
||||
return t("newUi.sidebar.keybindings.conflictWarning", {
|
||||
combo: formatCombo(conflict.combo),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [recordedCombo, bindings, editingId, t]);
|
||||
|
||||
function actionLabel(type: string): string {
|
||||
const def = actionById.get(type);
|
||||
return def
|
||||
? t(def.labelKey)
|
||||
: t("newUi.sidebar.keybindings.unavailableAction", { type });
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingId(null);
|
||||
setOverridesDefaultId(undefined);
|
||||
setRecordedCombo(null);
|
||||
setRecording(false);
|
||||
setDraft({ type: actions[0]?.id ?? "" });
|
||||
}
|
||||
|
||||
function openAddForm() {
|
||||
resetForm();
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEditForm(kb: CustomKeybinding) {
|
||||
setEditingId(kb.id);
|
||||
setOverridesDefaultId(kb.overridesDefaultId);
|
||||
setRecordedCombo(kb.combo);
|
||||
setRecording(false);
|
||||
setDraft({ ...kb.action });
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openOverrideDefaultForm(defaultId: string, combo: KeyCombo) {
|
||||
resetForm();
|
||||
setOverridesDefaultId(defaultId);
|
||||
setRecordedCombo(combo);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function persist(updated: CustomKeybinding[]) {
|
||||
try {
|
||||
await saveUserPreferences({
|
||||
customKeybindings: JSON.stringify(updated),
|
||||
});
|
||||
setBindings(updated);
|
||||
window.dispatchEvent(new Event("customKeybindingsChanged"));
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.keybindings.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
persist(bindings.filter((kb) => kb.id !== id));
|
||||
}
|
||||
|
||||
function handleResetAll() {
|
||||
persist([]);
|
||||
}
|
||||
|
||||
function startRecording(inputEl: HTMLInputElement) {
|
||||
setRecording(true);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
const isAlnum = /^[a-zA-Z0-9]$/.test(e.key);
|
||||
const combo: KeyCombo = {
|
||||
key: isAlnum ? e.key.toLowerCase() : e.code,
|
||||
isCode: !isAlnum,
|
||||
ctrl: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
meta: e.metaKey,
|
||||
};
|
||||
setRecordedCombo(combo);
|
||||
setRecording(false);
|
||||
inputEl.removeEventListener("keydown", handler, true);
|
||||
inputEl.blur();
|
||||
};
|
||||
inputEl.addEventListener("keydown", handler, true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!recordedCombo) {
|
||||
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
|
||||
return;
|
||||
}
|
||||
if (!draftDef) {
|
||||
toast.error(t("newUi.sidebar.keybindings.actionRequiredError"));
|
||||
return;
|
||||
}
|
||||
const action = withoutEmpty(draft);
|
||||
const problem = draftDef.validate?.(action);
|
||||
if (problem) {
|
||||
toast.error(t(problem));
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existing = editingId
|
||||
? bindings.find((kb) => kb.id === editingId)
|
||||
: undefined;
|
||||
|
||||
const newBinding: CustomKeybinding = {
|
||||
id: editingId ?? generateId(),
|
||||
combo: recordedCombo,
|
||||
action,
|
||||
enabled: true,
|
||||
overridesDefaultId,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const updated = editingId
|
||||
? bindings.map((kb) => (kb.id === editingId ? newBinding : kb))
|
||||
: [...bindings, newBinding];
|
||||
|
||||
persist(updated);
|
||||
setFormOpen(false);
|
||||
resetForm();
|
||||
}
|
||||
|
||||
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
|
||||
const Editor = draftDef?.editor;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.keybindings.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.keybindings.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1 max-h-[60vh] overflow-y-auto">
|
||||
{defaults.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.keybindings.defaultsHeading")}
|
||||
</span>
|
||||
{defaults.map((def) => {
|
||||
const override = bindings.find(
|
||||
(kb) => kb.overridesDefaultId === def.id,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={def.id}
|
||||
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-mono">
|
||||
{override
|
||||
? formatCombo(override.combo)
|
||||
: formatCombo(def.combo)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{t(def.descriptionKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{override ? (
|
||||
<>
|
||||
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
|
||||
{t("newUi.sidebar.keybindings.customizedBadge")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => openEditForm(override)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(override.id)}
|
||||
title={t(
|
||||
"newUi.sidebar.keybindings.resetToDefault",
|
||||
)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground border border-border px-1">
|
||||
{t("newUi.sidebar.keybindings.defaultBadge")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() =>
|
||||
openOverrideDefaultForm(def.id, def.combo)
|
||||
}
|
||||
title={t("newUi.sidebar.keybindings.customize")}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.keybindings.customHeading")}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={openAddForm}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("newUi.sidebar.keybindings.addBinding")}
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.keybindings.loading")}
|
||||
</span>
|
||||
) : customOnlyBindings.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{t("newUi.sidebar.keybindings.noCustomBindings")}
|
||||
</span>
|
||||
) : (
|
||||
customOnlyBindings.map((kb) => {
|
||||
const Summary = actionById.get(kb.action.type)?.summary;
|
||||
return (
|
||||
<div
|
||||
key={kb.id}
|
||||
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-mono">
|
||||
{formatCombo(kb.combo)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{actionLabel(kb.action.type)}
|
||||
{Summary && (
|
||||
<>
|
||||
{" "}
|
||||
<Summary action={kb.action} onChange={() => {}} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => openEditForm(kb)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(kb.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<FixedShortcutsList />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleResetAll}
|
||||
disabled={bindings.length === 0}
|
||||
>
|
||||
{t("newUi.sidebar.keybindings.resetAllToDefaults")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("newUi.sidebar.keybindings.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{editingId || overridesDefaultId
|
||||
? t("newUi.sidebar.keybindings.editBindingTitle")
|
||||
: t("newUi.sidebar.keybindings.addBindingTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.keybindings.pressKeysToRecord")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
data-keybinding-recorder
|
||||
readOnly
|
||||
value={
|
||||
recording
|
||||
? t("newUi.sidebar.keybindings.recording")
|
||||
: recordedCombo
|
||||
? formatCombo(recordedCombo)
|
||||
: ""
|
||||
}
|
||||
placeholder={t(
|
||||
"newUi.sidebar.keybindings.pressKeysPlaceholder",
|
||||
)}
|
||||
onFocus={(e) => startRecording(e.currentTarget)}
|
||||
className="font-mono cursor-pointer"
|
||||
/>
|
||||
{conflictWarning && (
|
||||
<span className="text-xs text-yellow-500">
|
||||
{conflictWarning}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.keybindings.actionLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={draft.type}
|
||||
onChange={(e) => setDraft({ type: e.target.value })}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{!draftDef && draft.type && (
|
||||
<option value={draft.type}>{actionLabel(draft.type)}</option>
|
||||
)}
|
||||
{actions.map((action) => (
|
||||
<option key={action.id} value={action.id}>
|
||||
{t(action.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{Editor && <Editor action={draft} onChange={setDraft} />}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => setFormOpen(false)}>
|
||||
{t("newUi.sidebar.keybindings.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("newUi.sidebar.keybindings.saveBinding")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PanelRight, SquareArrowOutUpRight, X } from "lucide-react";
|
||||
|
||||
const STORAGE_KEY = "termix_multiPanelHintDismissed";
|
||||
|
||||
function multiPanelHintDismissed(): boolean {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time nudge telling users a panel can leave the sidebar. Both actions are
|
||||
* otherwise only reachable through modifier clicks and the rail context menu,
|
||||
* which nobody finds on their own.
|
||||
*/
|
||||
export function MultiPanelHint({
|
||||
canPromote,
|
||||
canRightDock,
|
||||
onOpenAsTab,
|
||||
onOpenInRightDock,
|
||||
}: {
|
||||
canPromote: boolean;
|
||||
canRightDock: boolean;
|
||||
onOpenAsTab: () => void;
|
||||
onOpenInRightDock: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(multiPanelHintDismissed);
|
||||
|
||||
if (dismissed || (!canPromote && !canRightDock)) return null;
|
||||
|
||||
function dismiss() {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setDismissed(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 border-b border-border bg-accent-brand/5 px-3 py-2.5">
|
||||
<button
|
||||
onClick={dismiss}
|
||||
title={t("common.dismiss")}
|
||||
aria-label={t("common.dismiss")}
|
||||
className="absolute right-1.5 top-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
<p className="pr-5 text-xs font-semibold text-foreground">
|
||||
{t("nav.multiPanelHintTitle")}
|
||||
</p>
|
||||
<p className="mt-0.5 pr-5 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("nav.multiPanelHintBody")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
{canPromote && (
|
||||
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
<SquareArrowOutUpRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
|
||||
<span>
|
||||
<button
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
onOpenAsTab();
|
||||
}}
|
||||
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
|
||||
>
|
||||
{t("nav.openAsTab")}
|
||||
</button>
|
||||
{": "}
|
||||
{t("nav.multiPanelHintTabHow")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{canRightDock && (
|
||||
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
<PanelRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
|
||||
<span>
|
||||
<button
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
onOpenInRightDock();
|
||||
}}
|
||||
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
|
||||
>
|
||||
{t("nav.openInRightDock")}
|
||||
</button>
|
||||
{": "}
|
||||
{t("nav.multiPanelHintDockHow")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+182
-177
@@ -1,3 +1,4 @@
|
||||
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -26,14 +27,6 @@ import { shell } from "@/plugin-host/shell-bridge";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { VersionBadge } from "@/components/version-badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
@@ -56,8 +49,8 @@ import {
|
||||
import { SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import { NavigationVisibilityToggles } from "./NavigationVisibilityToggles";
|
||||
import { InterfacePresetSettings } from "./InterfacePresetSettings";
|
||||
import { useSettingsEmbedded } from "@/settings/settings-embed";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { KeybindingsDialog } from "./KeybindingsDialog";
|
||||
import {
|
||||
ACCENT_PRESET_COLORS,
|
||||
applyAccentColor,
|
||||
@@ -157,7 +150,19 @@ export function AccordionSection({
|
||||
hidden?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const embedded = useSettingsEmbedded();
|
||||
if (hidden) return null;
|
||||
if (embedded) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
id={`${id}-content`}
|
||||
className="flex flex-col border border-border bg-card px-3 pb-3"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-card overflow-hidden">
|
||||
@@ -253,88 +258,14 @@ export function NewApiKeyDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen || !createdToken) onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md rounded-none border-border bg-card p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="size-8 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
<Network className="size-3.5 text-accent-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-base font-bold leading-none">
|
||||
{t("newUi.sidebar.userProfile.createApiKeyTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{createdToken ? (
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<span className="text-xs font-semibold text-accent-brand">
|
||||
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
|
||||
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
|
||||
{createdToken}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
|
||||
onClick={() => {
|
||||
copyToClipboard(createdToken);
|
||||
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 px-5 py-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t(
|
||||
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
|
||||
)}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
|
||||
<span className="text-muted-foreground/50 normal-case font-medium">
|
||||
({t("newUi.sidebar.userProfile.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.target.value)}
|
||||
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t border-border bg-muted/20">
|
||||
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
|
||||
footer={
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
{createdToken ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -363,9 +294,68 @@ export function NewApiKeyDialog({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
|
||||
</p>
|
||||
{createdToken ? (
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<span className="text-xs font-semibold text-accent-brand">
|
||||
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
|
||||
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
|
||||
{createdToken}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
|
||||
onClick={() => {
|
||||
copyToClipboard(createdToken);
|
||||
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 px-5 py-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("newUi.sidebar.userProfile.apiKeyNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
|
||||
<span className="text-muted-foreground/50 normal-case font-medium">
|
||||
({t("newUi.sidebar.userProfile.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.target.value)}
|
||||
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -485,7 +475,13 @@ export function UserProfilePanel({
|
||||
onLogout,
|
||||
userPrefs,
|
||||
onPrefsChange,
|
||||
section,
|
||||
onOpenSettingsPage,
|
||||
}: {
|
||||
/** Shows just this section, flat, as a Settings page. */
|
||||
section?: string;
|
||||
/** Moves the Settings tab to another page. */
|
||||
onOpenSettingsPage?: (id: string) => void;
|
||||
username?: string;
|
||||
onLogout?: () => void;
|
||||
userPrefs?: {
|
||||
@@ -509,6 +505,7 @@ export function UserProfilePanel({
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const themeLabel: Record<ThemeId, string> = {
|
||||
system: t("newUi.sidebar.userProfile.themeSystem"),
|
||||
light: t("newUi.sidebar.userProfile.themeLight"),
|
||||
@@ -603,7 +600,6 @@ export function UserProfilePanel({
|
||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
|
||||
// Sidebar display customization (density, tags, tray trigger, status
|
||||
// colors) now lives in the dedicated Customize Sidebar panel opened from
|
||||
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
|
||||
@@ -1182,9 +1178,18 @@ export function UserProfilePanel({
|
||||
}
|
||||
|
||||
const canChangePasword = !isExternal || isDualAuth;
|
||||
const isOpen = (id: string) =>
|
||||
section ? section === id : openSections.has(id as UserProfileSection);
|
||||
const shows = (id: string) => !section || section === id;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3">
|
||||
<div
|
||||
className={
|
||||
section
|
||||
? "flex w-full flex-col gap-2"
|
||||
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3"
|
||||
}
|
||||
>
|
||||
<NewApiKeyDialog
|
||||
open={newKeyOpen}
|
||||
onOpenChange={setNewKeyOpen}
|
||||
@@ -1193,30 +1198,32 @@ export function UserProfilePanel({
|
||||
/>
|
||||
|
||||
{/* Donate banner */}
|
||||
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
|
||||
{t("newUi.sidebar.userProfile.donateTitle")}
|
||||
{shows("account") && (
|
||||
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
|
||||
{t("newUi.sidebar.userProfile.donateTitle")}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.donateDescription")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.donateMilestones")}
|
||||
</p>
|
||||
<a
|
||||
href="https://donate.termix.site/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.donateButton")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.donateDescription")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.donateMilestones")}
|
||||
</p>
|
||||
<a
|
||||
href="https://donate.termix.site/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.donateButton")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage mode toggle — only meaningful once a remote server is
|
||||
connected; with no sync there's nowhere for "cloud" to sync to,
|
||||
so this stays forced to local storage and hidden. */}
|
||||
{(!isElectron() || isLinked) && (
|
||||
{(!isElectron() || isLinked) && shows("data") && (
|
||||
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.storageModeSwitch")}
|
||||
@@ -1261,7 +1268,7 @@ export function UserProfilePanel({
|
||||
id="account"
|
||||
label={t("newUi.sidebar.userProfile.sectionAccount")}
|
||||
icon={<User className="size-3.5" />}
|
||||
open={openSections.has("account")}
|
||||
open={isOpen("account")}
|
||||
onToggle={() => toggle("account")}
|
||||
>
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
@@ -1447,7 +1454,7 @@ export function UserProfilePanel({
|
||||
id="interface"
|
||||
label={t("newUi.sidebar.userProfile.sectionInterface")}
|
||||
icon={<LayoutTemplate size={13} />}
|
||||
open={openSections.has("interface")}
|
||||
open={isOpen("interface")}
|
||||
onToggle={() => toggle("interface")}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
@@ -1464,7 +1471,7 @@ export function UserProfilePanel({
|
||||
id="appearance"
|
||||
label={t("newUi.sidebar.userProfile.sectionAppearance")}
|
||||
icon={<Palette className="size-3.5" />}
|
||||
open={openSections.has("appearance")}
|
||||
open={isOpen("appearance")}
|
||||
onToggle={() => toggle("appearance")}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
@@ -1641,7 +1648,7 @@ export function UserProfilePanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setKeybindingsDialogOpen(true)}
|
||||
onClick={() => onOpenSettingsPage?.("keybindings")}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.manageShortcuts")}
|
||||
</Button>
|
||||
@@ -1812,7 +1819,7 @@ export function UserProfilePanel({
|
||||
id="security"
|
||||
label={t("newUi.sidebar.userProfile.sectionSecurity")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
open={openSections.has("security")}
|
||||
open={isOpen("security")}
|
||||
onToggle={() => toggle("security")}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
@@ -1833,7 +1840,7 @@ export function UserProfilePanel({
|
||||
id="api-keys"
|
||||
label={t("newUi.sidebar.userProfile.sectionApiKeys")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
open={openSections.has("api-keys")}
|
||||
open={isOpen("api-keys")}
|
||||
onToggle={() => toggle("api-keys")}
|
||||
>
|
||||
<div className="flex flex-col gap-2 pt-3">
|
||||
@@ -1896,6 +1903,13 @@ export function UserProfilePanel({
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: t("admin.revokeKeyConfirm", {
|
||||
name: key.name,
|
||||
}),
|
||||
confirmLabel: t("admin.revokeKey"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteApiKey(key.id);
|
||||
setApiKeys((prev) =>
|
||||
@@ -1938,7 +1952,7 @@ export function UserProfilePanel({
|
||||
id="data"
|
||||
label={t("newUi.sidebar.userProfile.sectionData")}
|
||||
icon={<Database className="size-3.5" />}
|
||||
open={openSections.has("data")}
|
||||
open={isOpen("data")}
|
||||
onToggle={() => toggle("data")}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
@@ -2013,78 +2027,69 @@ export function UserProfilePanel({
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
scope="user"
|
||||
open={openSections.has(featureSectionId(plugin.id))}
|
||||
open={isOpen(featureSectionId(plugin.id))}
|
||||
onToggle={() => toggle(featureSectionId(plugin.id))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Delete account dialog */}
|
||||
<Dialog
|
||||
|
||||
<InlineView
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={(open) => {
|
||||
setShowDeleteConfirm(open);
|
||||
if (!open) setDeletePassword("");
|
||||
}}
|
||||
title={t("newUi.sidebar.userProfile.deleteAccount")}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold text-destructive">
|
||||
{t("newUi.sidebar.userProfile.deleteAccount")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 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" />
|
||||
<span className="text-xs text-destructive">
|
||||
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
|
||||
)}
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 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" />
|
||||
<span className="text-xs text-destructive">
|
||||
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeletePassword("");
|
||||
}}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteLoading || !deletePassword.trim()}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{deleteLoading
|
||||
? t("newUi.sidebar.userProfile.deleting")
|
||||
: t("newUi.sidebar.userProfile.deleteAccount")}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
|
||||
)}
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<KeybindingsDialog
|
||||
open={keybindingsDialogOpen}
|
||||
onOpenChange={setKeybindingsDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeletePassword("");
|
||||
}}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteLoading || !deletePassword.trim()}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{deleteLoading
|
||||
? t("newUi.sidebar.userProfile.deleting")
|
||||
: t("newUi.sidebar.userProfile.deleteAccount")}
|
||||
</Button>
|
||||
</div>
|
||||
</InlineView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/main-axios";
|
||||
|
||||
export const HIDDEN_RAIL_TABS_EVENT = "hiddenRailTabsChanged";
|
||||
|
||||
export function readHiddenRailTabs(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem("hiddenRailTabs");
|
||||
@@ -8,3 +13,47 @@ export function readHiddenRailTabs(): Set<string> {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one writer for hidden rail items: the rail's restore list, the
|
||||
* Navigation toggles in settings and presets all go through here, so they
|
||||
* never disagree. Saved to the account when settings sync to the cloud.
|
||||
*/
|
||||
export async function writeHiddenRailTabs(
|
||||
next: Iterable<string>,
|
||||
): Promise<void> {
|
||||
const serialized = JSON.stringify([...next]);
|
||||
try {
|
||||
localStorage.setItem("hiddenRailTabs", serialized);
|
||||
} catch {
|
||||
// A private window refuses the write; the change still applies this session.
|
||||
}
|
||||
window.dispatchEvent(new Event(HIDDEN_RAIL_TABS_EVENT));
|
||||
try {
|
||||
const prefs = await getUserPreferences();
|
||||
if (prefs?.storageMode === "cloud") {
|
||||
await saveUserPreferences({ hiddenRailTabs: serialized });
|
||||
}
|
||||
} catch {
|
||||
// Offline; the local copy is already updated.
|
||||
}
|
||||
}
|
||||
|
||||
/** Hides one item if it is shown, shows it if hidden. */
|
||||
export function toggleHiddenRailTab(id: string): Promise<void> {
|
||||
const hidden = readHiddenRailTabs();
|
||||
if (hidden.has(id)) hidden.delete(id);
|
||||
else hidden.add(id);
|
||||
return writeHiddenRailTabs(hidden);
|
||||
}
|
||||
|
||||
/** The hidden rail items, kept current as anything changes them. */
|
||||
export function useHiddenRailTabs(): Set<string> {
|
||||
const [hidden, setHidden] = useState(readHiddenRailTabs);
|
||||
useEffect(() => {
|
||||
const handler = () => setHidden(readHiddenRailTabs());
|
||||
window.addEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
|
||||
return () => window.removeEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
|
||||
}, []);
|
||||
return hidden;
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
|
||||
import { HostEditor } from "../HostEditor";
|
||||
import {
|
||||
isSshGroupTab,
|
||||
makeHostSshSubTabs,
|
||||
makeHostTabs,
|
||||
TabStrip,
|
||||
useHostEditorSections,
|
||||
} from "../HostManagerTabs";
|
||||
import { useHostEditorSections } from "../HostManagerTabs";
|
||||
import { listHostProtocols, type HostProtocols } from "../host-protocols";
|
||||
|
||||
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
|
||||
@@ -18,8 +12,18 @@ export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
|
||||
folderName?: string;
|
||||
}
|
||||
|
||||
/** The title a level of defaults goes by. */
|
||||
export function hostDefaultsTitle(
|
||||
target: HostDefaultsEditorTarget,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (target.level === "admin") return t("hostDefaults.titleAdmin");
|
||||
if (target.level === "user") return t("hostDefaults.titleUser");
|
||||
return t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
|
||||
}
|
||||
|
||||
/**
|
||||
* The host editor as a form for one level of host defaults: the same tabs
|
||||
* The host editor as a form for one level of host defaults: the same sections
|
||||
* and fields as a new host, minus what only one host can have (its name,
|
||||
* address, secrets, folder).
|
||||
*/
|
||||
@@ -38,25 +42,14 @@ export function HostDefaultsEditorView({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useHostEditorSections();
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
|
||||
// Every protocol counts as on, so every tab that has defaults shows.
|
||||
// Every protocol counts as on, so every section that has defaults shows.
|
||||
const protocols = useMemo<HostProtocols>(() => {
|
||||
const all: HostProtocols = { enableSsh: true };
|
||||
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
|
||||
return all;
|
||||
}, []);
|
||||
const flags = protocols as unknown as Record<string, boolean>;
|
||||
const tabs = makeHostTabs(t, flags, true);
|
||||
const sshSubTabs = makeHostSshSubTabs(t, flags, true);
|
||||
const inSshGroup = isSshGroupTab(activeTab);
|
||||
|
||||
const title =
|
||||
target.level === "admin"
|
||||
? t("hostDefaults.titleAdmin")
|
||||
: target.level === "user"
|
||||
? t("hostDefaults.titleUser")
|
||||
: t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
|
||||
const description =
|
||||
target.level === "admin"
|
||||
? t("hostDefaults.descriptionAdmin")
|
||||
@@ -65,61 +58,22 @@ export function HostDefaultsEditorView({
|
||||
: t("hostDefaults.descriptionFolder");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex flex-col shrink-0 border-b border-border">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span>{t("hosts.backToHosts")}</span>
|
||||
<span
|
||||
className="ml-auto font-semibold text-foreground truncate max-w-[220px]"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
<TabStrip
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => {
|
||||
if (id === "ssh") {
|
||||
if (!inSshGroup) setActiveTab("ssh");
|
||||
} else {
|
||||
setActiveTab(id);
|
||||
}
|
||||
}}
|
||||
isActive={(id) => (id === "ssh" ? inSshGroup : activeTab === id)}
|
||||
/>
|
||||
{inSshGroup && (
|
||||
<TabStrip
|
||||
tabs={sshSubTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground border border-border bg-muted/20 px-3 py-2">
|
||||
<HostEditor
|
||||
key={`${target.level}:${target.folderId ?? ""}`}
|
||||
host={null}
|
||||
banner={
|
||||
<p className="border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
<HostEditor
|
||||
key={`${target.level}:${target.folderId ?? ""}`}
|
||||
host={null}
|
||||
activeTab={activeTab}
|
||||
onBack={onClose}
|
||||
onSave={onClose}
|
||||
protocols={protocols}
|
||||
onProtocolChange={() => {}}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onTabChange={setActiveTab}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
defaultsTarget={target}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onBack={onClose}
|
||||
onSave={onClose}
|
||||
protocols={protocols}
|
||||
onProtocolChange={() => {}}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
defaultsTarget={target}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
KeyRound,
|
||||
Plug,
|
||||
Server,
|
||||
LibraryBig,
|
||||
Settings,
|
||||
User,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@@ -21,7 +21,7 @@ import { getTabType } from "@/shell/tab-registry";
|
||||
*
|
||||
* This used to be duplicated in four places -- AppRail's button array, the
|
||||
* visibility toggles in UserProfilePanel, AppShell's sidebar title map, and
|
||||
* MobileBottomBar's own primary/more lists -- which drifted: half the sidebar
|
||||
* the mobile bar's own primary/more lists -- which drifted: half the sidebar
|
||||
* titles were hardcoded English, the alerts entry had no visibility toggle,
|
||||
* and the mobile bar ignored hidden tabs entirely. Everything now derives from
|
||||
* here, so adding a destination is a single edit.
|
||||
@@ -38,6 +38,8 @@ export interface RailItemDef {
|
||||
alwaysVisible?: boolean;
|
||||
/** Renders a separator after this item in the rail. */
|
||||
separatorAfter?: boolean;
|
||||
/** The rail band it sits in. Defaults to "tools". */
|
||||
group?: RailGroup;
|
||||
/** Shown on the mobile bottom bar's primary row rather than its More menu. */
|
||||
mobilePrimary?: boolean;
|
||||
/**
|
||||
@@ -72,13 +74,24 @@ export interface RailItemDef {
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
|
||||
/** Things you keep, things you do, and the instance itself. */
|
||||
export type RailGroup = "objects" | "tools" | "system";
|
||||
export const RAIL_GROUP_ORDER: RailGroup[] = ["objects", "tools", "system"];
|
||||
|
||||
export const RAIL_ITEMS: RailItemDef[] = [
|
||||
{ id: "hosts", icon: Server, labelKey: "nav.hosts", mobilePrimary: true },
|
||||
{
|
||||
id: "hosts",
|
||||
icon: Server,
|
||||
labelKey: "nav.hosts",
|
||||
mobilePrimary: true,
|
||||
group: "objects",
|
||||
},
|
||||
{
|
||||
id: "credentials",
|
||||
icon: KeyRound,
|
||||
labelKey: "nav.credentials",
|
||||
separatorAfter: true,
|
||||
group: "objects",
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
@@ -86,6 +99,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
labelKey: "nav.connections",
|
||||
separatorAfter: true,
|
||||
rightDockable: true,
|
||||
group: "tools",
|
||||
},
|
||||
{
|
||||
id: "quick-connect",
|
||||
@@ -93,6 +107,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
labelKey: "nav.quickConnect",
|
||||
separatorAfter: true,
|
||||
mobilePrimary: true,
|
||||
group: "tools",
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
@@ -100,10 +115,21 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
labelKey: "nav.sync",
|
||||
electronOnly: true,
|
||||
placement: "footer",
|
||||
group: "system",
|
||||
useBadge: useSyncAttentionCount,
|
||||
},
|
||||
];
|
||||
|
||||
/** Main rail items split into their bands, in band order, empty bands left out. */
|
||||
export function groupRailItems(
|
||||
items: RailItemDef[],
|
||||
): { group: RailGroup; items: RailItemDef[] }[] {
|
||||
return RAIL_GROUP_ORDER.map((group) => ({
|
||||
group,
|
||||
items: items.filter((item) => (item.group ?? "tools") === group),
|
||||
})).filter((band) => band.items.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rail items registered by plugins at runtime. Reactive, because a plugin can
|
||||
* be enabled or disabled while the app is open and the rail, the mobile bar
|
||||
@@ -203,13 +229,10 @@ export function useRailItems(): RailItemDef[] {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destinations that live outside the rail's hideable list but still need a
|
||||
* title and a mobile entry.
|
||||
*/
|
||||
/** Places outside the rail that still need a title, like the Settings tab. */
|
||||
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
|
||||
{ id: "user-profile", icon: User, labelKey: "nav.userProfile" },
|
||||
{ id: "admin-settings", icon: Settings, labelKey: "nav.admin" },
|
||||
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
|
||||
{ id: "host-manager", icon: LibraryBig, labelKey: "nav.manage" },
|
||||
];
|
||||
|
||||
/** Ids that may be opened in the right dock. */
|
||||
|
||||
@@ -1267,7 +1267,7 @@ export function HostItem({
|
||||
<Cpu className="size-2.5 shrink-0 text-muted-foreground/40" />
|
||||
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
|
||||
className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-warning" : "bg-accent-brand"}`}
|
||||
style={{ width: `${host.cpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -1281,7 +1281,7 @@ export function HostItem({
|
||||
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/40" />
|
||||
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
|
||||
className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-warning" : "bg-accent-brand/60"}`}
|
||||
style={{ width: `${host.ram}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -179,10 +180,15 @@ export function SidebarTree({
|
||||
useEffect(() => {
|
||||
if (!selectionMode) setSelectedHostIds(new Set());
|
||||
}, [selectionMode, setSelectedHostIds]);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
message: string;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
const confirm = useConfirm();
|
||||
const setConfirmDialog = (
|
||||
request: { message: string; onConfirm: () => Promise<void> | void } | null,
|
||||
) => {
|
||||
if (!request) return;
|
||||
void confirm({ title: request.message }).then((ok) => {
|
||||
if (ok) void request.onConfirm();
|
||||
});
|
||||
};
|
||||
const { draggedHostIds, setDraggedHostIds, rootDragOver, setRootDragOver } =
|
||||
useSidebarDragState();
|
||||
const [folderDialog, setFolderDialog] = useState<{
|
||||
@@ -1437,32 +1443,6 @@ export function SidebarTree({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirmDialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
|
||||
<p className="text-sm text-foreground">{confirmDialog.message}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmDialog(null)}
|
||||
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
confirmDialog.onConfirm();
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
|
||||
>
|
||||
{t("hosts.deleteConfirmBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FolderMetadataDialog
|
||||
open={folderDialog !== null}
|
||||
mode={folderDialog?.mode ?? "create"}
|
||||
|
||||
Reference in new issue
Block a user