mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: general qol additions
This commit is contained in:
+10
-2
@@ -141,7 +141,10 @@ import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
|
||||
|
||||
function buildHostTree(
|
||||
hosts: SSHHostWithStatus[],
|
||||
folderMeta?: Map<string, { color?: string; icon?: string }>,
|
||||
folderMeta?: Map<
|
||||
string,
|
||||
{ color?: string; icon?: string; credentialId?: number | null }
|
||||
>,
|
||||
): HostFolder {
|
||||
const root: HostFolder = { name: "root", children: [] };
|
||||
const folderMap = new Map<string, HostFolder>();
|
||||
@@ -159,6 +162,7 @@ function buildHostTree(
|
||||
path: accumulated,
|
||||
color: meta?.color,
|
||||
icon: meta?.icon,
|
||||
credentialId: meta?.credentialId ?? null,
|
||||
children: [],
|
||||
};
|
||||
folderMap.set(accumulated, folder);
|
||||
@@ -798,11 +802,15 @@ export function AppShell({
|
||||
]);
|
||||
const converted = raw.map(sshHostToHost);
|
||||
setAllHosts(converted);
|
||||
const folderMeta = new Map<string, { color?: string; icon?: string }>();
|
||||
const folderMeta = new Map<
|
||||
string,
|
||||
{ color?: string; icon?: string; credentialId?: number | null }
|
||||
>();
|
||||
for (const f of folders) {
|
||||
folderMeta.set(f.name, {
|
||||
color: f.color ?? undefined,
|
||||
icon: f.icon ?? undefined,
|
||||
credentialId: f.credentialId ?? null,
|
||||
});
|
||||
}
|
||||
setRealHostTree(buildHostTree(raw, folderMeta));
|
||||
|
||||
@@ -200,6 +200,7 @@ export async function updateFolderMetadata(
|
||||
name: string,
|
||||
color?: string,
|
||||
icon?: string,
|
||||
credentialId?: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
sshLogger.info("Updating folder metadata", {
|
||||
@@ -207,12 +208,14 @@ export async function updateFolderMetadata(
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
credentialId,
|
||||
});
|
||||
|
||||
await authApi.put("/host/folders/metadata", {
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
credentialId,
|
||||
});
|
||||
|
||||
sshLogger.success("Folder metadata updated successfully", {
|
||||
|
||||
@@ -124,6 +124,31 @@ export async function shareHost(
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareFolder(
|
||||
folder: string,
|
||||
shareData: {
|
||||
targets: ShareTarget[];
|
||||
permissionLevel: SharePermissionLevel;
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
expiresAt: string | null;
|
||||
hostsShared: number;
|
||||
hostsTotal: number;
|
||||
hostResults: Array<{ hostId: number; shared: boolean; reason?: string }>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.post("/rbac/folder/share", {
|
||||
folder,
|
||||
...shareData,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHostAccess(
|
||||
hostId: number,
|
||||
accessId: number,
|
||||
|
||||
@@ -1002,6 +1002,9 @@
|
||||
"folderNestingHint": "Use / to separate levels and create nested folders.",
|
||||
"folderColor": "Color",
|
||||
"folderIcon": "Icon",
|
||||
"folderCredential": "Credential",
|
||||
"folderCredentialNone": "No credential assigned",
|
||||
"folderCredentialHint": "Hosts in this folder that use \"Stored credential\" auth without their own credential selected will inherit this one.",
|
||||
"folderPreview": "Preview",
|
||||
"folderNameFallback": "Untitled folder",
|
||||
"createFolderButton": "Create folder",
|
||||
@@ -1183,6 +1186,10 @@
|
||||
"filterTagsGroup": "Tags",
|
||||
"shareHost": "Share Host",
|
||||
"shareHostTitle": "Share: {{name}}",
|
||||
"shareFolder": "Share Folder",
|
||||
"shareFolderTitle": "Share folder: {{name}}",
|
||||
"folderSharedSuccessfully": "Shared {{count}} host(s) in folder",
|
||||
"failedToShareFolder": "Failed to share folder",
|
||||
"sharing": {
|
||||
"loadError": "Failed to load sharing data. Please try again.",
|
||||
"shareWithSection": "Share with",
|
||||
@@ -1223,6 +1230,7 @@
|
||||
"shareWithCount": "Share ({{count}})",
|
||||
"currentAccess": "Current access",
|
||||
"noAccessEntries": "This host has not been shared yet",
|
||||
"folderShareSummary": "Shared {{shared}} of {{total}} host(s) in this folder",
|
||||
"grantedBy": "Granted by",
|
||||
"expires": "Expires",
|
||||
"expired": "Expired",
|
||||
|
||||
@@ -2120,6 +2120,7 @@ export {
|
||||
assignRoleToUser,
|
||||
removeRoleFromUser,
|
||||
shareHost,
|
||||
shareFolder,
|
||||
updateHostAccess,
|
||||
getHostAccess,
|
||||
revokeHostAccess,
|
||||
|
||||
@@ -110,8 +110,8 @@ export function AdminSettingsPanel({
|
||||
onOpenHostTab?: (host: Host) => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [openSection, setOpenSection] = useState<AdminSection | null>(
|
||||
"general",
|
||||
const [openSections, setOpenSections] = useState<Set<AdminSection>>(
|
||||
() => new Set(["general"]),
|
||||
);
|
||||
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
@@ -347,7 +347,12 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
|
||||
function toggle(id: AdminSection) {
|
||||
setOpenSection((prev) => (prev === id ? null : id));
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveHostDefaults() {
|
||||
@@ -854,7 +859,7 @@ export function AdminSettingsPanel({
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSection === "general"}
|
||||
open={openSections.has("general")}
|
||||
onToggle={() => toggle("general")}
|
||||
allowRegistration={allowRegistration}
|
||||
handleToggleRegistration={handleToggleRegistration}
|
||||
@@ -891,7 +896,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminSSOSection
|
||||
open={openSection === "sso"}
|
||||
open={openSections.has("sso")}
|
||||
onToggle={() => toggle("sso")}
|
||||
providers={ssoProviders}
|
||||
onAddProvider={handleAddProvider}
|
||||
@@ -908,7 +913,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminUsersSection
|
||||
open={openSection === "users"}
|
||||
open={openSections.has("users")}
|
||||
onToggle={() => toggle("users")}
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
@@ -924,7 +929,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminSessionsSection
|
||||
open={openSection === "sessions"}
|
||||
open={openSections.has("sessions")}
|
||||
onToggle={() => toggle("sessions")}
|
||||
sessions={sessions}
|
||||
setSessions={setSessions}
|
||||
@@ -932,7 +937,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminRolesSection
|
||||
open={openSection === "roles"}
|
||||
open={openSections.has("roles")}
|
||||
onToggle={() => toggle("roles")}
|
||||
roles={roles}
|
||||
setRoles={setRoles}
|
||||
@@ -949,7 +954,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminHostDefaultsSection
|
||||
open={openSection === "host-defaults"}
|
||||
open={openSections.has("host-defaults")}
|
||||
onToggle={() => toggle("host-defaults")}
|
||||
defaults={hostDefaults}
|
||||
setDefaults={setHostDefaults}
|
||||
@@ -957,7 +962,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminDatabaseSection
|
||||
open={openSection === "database"}
|
||||
open={openSections.has("database")}
|
||||
onToggle={() => toggle("database")}
|
||||
importFile={importFile}
|
||||
setImportFile={setImportFile}
|
||||
@@ -968,7 +973,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminSSLSection
|
||||
open={openSection === "ssl"}
|
||||
open={openSections.has("ssl")}
|
||||
onToggle={() => toggle("ssl")}
|
||||
settings={acmeSettings}
|
||||
setSettings={setAcmeSettings}
|
||||
@@ -980,7 +985,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminApiKeysSection
|
||||
open={openSection === "api-keys"}
|
||||
open={openSections.has("api-keys")}
|
||||
onToggle={() => toggle("api-keys")}
|
||||
apiKeys={apiKeys}
|
||||
setApiKeys={setApiKeys}
|
||||
@@ -1001,7 +1006,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminAuditLogSection
|
||||
open={openSection === "audit-log"}
|
||||
open={openSections.has("audit-log")}
|
||||
onToggle={() => toggle("audit-log")}
|
||||
users={users}
|
||||
/>
|
||||
|
||||
@@ -18,13 +18,17 @@ import {
|
||||
IconPicker,
|
||||
} from "@/components/folder-style";
|
||||
import { normalizePath, splitPath } from "./FolderPathPicker";
|
||||
import { getCredentials } from "@/main-axios";
|
||||
|
||||
export type FolderMetadataValue = {
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
credentialId: number | null;
|
||||
};
|
||||
|
||||
type CredentialOption = { id: string; name: string; username?: string };
|
||||
|
||||
export function FolderMetadataDialog({
|
||||
open,
|
||||
mode,
|
||||
@@ -34,7 +38,12 @@ export function FolderMetadataDialog({
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: "create" | "edit";
|
||||
initial?: { name: string; color?: string; icon?: string };
|
||||
initial?: {
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
credentialId?: number | null;
|
||||
};
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onSubmit: (value: FolderMetadataValue) => void;
|
||||
}) {
|
||||
@@ -42,19 +51,45 @@ export function FolderMetadataDialog({
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState(DEFAULT_FOLDER_COLOR);
|
||||
const [icon, setIcon] = useState(DEFAULT_FOLDER_ICON);
|
||||
const [credentialId, setCredentialId] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initial?.name ?? "");
|
||||
setColor(initial?.color ?? DEFAULT_FOLDER_COLOR);
|
||||
setIcon(initial?.icon ?? DEFAULT_FOLDER_ICON);
|
||||
setCredentialId(
|
||||
initial?.credentialId ? String(initial.credentialId) : "",
|
||||
);
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
getCredentials()
|
||||
.then((data) => {
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setCredentials(
|
||||
list.map((c) => ({
|
||||
id: String(c.id),
|
||||
name: String(c.name ?? ""),
|
||||
username: c.username ? String(c.username) : undefined,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setCredentials([]));
|
||||
}, [open]);
|
||||
|
||||
function handleSubmit() {
|
||||
const normalized = normalizePath(name);
|
||||
if (!normalized) return;
|
||||
onSubmit({ name: normalized, color, icon });
|
||||
onSubmit({
|
||||
name: normalized,
|
||||
color,
|
||||
icon,
|
||||
credentialId: credentialId ? Number(credentialId) : null,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
@@ -101,6 +136,26 @@ export function FolderMetadataDialog({
|
||||
</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>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderPreview")}
|
||||
|
||||
+178
-129
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
getHostAccess,
|
||||
shareHost,
|
||||
shareFolder,
|
||||
updateHostAccess,
|
||||
revokeHostAccess,
|
||||
getUserList,
|
||||
@@ -55,12 +56,15 @@ export function HostShareModal({
|
||||
open,
|
||||
onClose,
|
||||
host,
|
||||
folder,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
host: Host | null;
|
||||
folder?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isFolderShare = !host && !!folder;
|
||||
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
||||
@@ -83,13 +87,19 @@ export function HostShareModal({
|
||||
const [sharingLoaded, setSharingLoaded] = useState(false);
|
||||
const [sharingLoadError, setSharingLoadError] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [folderShareSummary, setFolderShareSummary] = useState<{
|
||||
hostsShared: number;
|
||||
hostsTotal: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !host) return;
|
||||
if (!open || (!host && !folder)) return;
|
||||
if (sharingLoaded) return;
|
||||
setSharingLoaded(true);
|
||||
Promise.all([
|
||||
getHostAccess(Number(host.id)).catch(() => ({ accessList: [] })),
|
||||
host
|
||||
? getHostAccess(Number(host.id)).catch(() => ({ accessList: [] }))
|
||||
: Promise.resolve({ accessList: [] }),
|
||||
getUserList().catch(() => ({ users: [] })),
|
||||
getRoles().catch(() => ({ roles: [] })),
|
||||
])
|
||||
@@ -112,7 +122,7 @@ export function HostShareModal({
|
||||
);
|
||||
})
|
||||
.catch(() => setSharingLoadError(true));
|
||||
}, [open, host, sharingLoaded]);
|
||||
}, [open, host, folder, sharingLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
setSharingLoaded(false);
|
||||
@@ -125,7 +135,8 @@ export function HostShareModal({
|
||||
setExpiryPreset("never");
|
||||
setCustomHours("");
|
||||
setTargetTab("user");
|
||||
}, [host?.id]);
|
||||
setFolderShareSummary(null);
|
||||
}, [host?.id, folder]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -165,7 +176,7 @@ export function HostShareModal({
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
if (!host || selectedCount === 0) return;
|
||||
if ((!host && !folder) || selectedCount === 0) return;
|
||||
const targets: ShareTarget[] = [
|
||||
...[...selectedUserIds].map(
|
||||
(id) => ({ type: "user", id }) as ShareTarget,
|
||||
@@ -177,17 +188,40 @@ export function HostShareModal({
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await shareHost(Number(host.id), {
|
||||
targets,
|
||||
permissionLevel,
|
||||
...(durationHours ? { durationHours } : {}),
|
||||
});
|
||||
await refreshAccessList();
|
||||
setSelectedUserIds(new Set());
|
||||
setSelectedRoleIds(new Set());
|
||||
toast.success(t("hosts.hostSharedSuccessfully"));
|
||||
if (isFolderShare && folder) {
|
||||
const result = await shareFolder(folder, {
|
||||
targets,
|
||||
permissionLevel,
|
||||
...(durationHours ? { durationHours } : {}),
|
||||
});
|
||||
setFolderShareSummary({
|
||||
hostsShared: result.hostsShared,
|
||||
hostsTotal: result.hostsTotal,
|
||||
});
|
||||
setSelectedUserIds(new Set());
|
||||
setSelectedRoleIds(new Set());
|
||||
toast.success(
|
||||
t("hosts.folderSharedSuccessfully", {
|
||||
count: result.hostsShared,
|
||||
}),
|
||||
);
|
||||
} else if (host) {
|
||||
await shareHost(Number(host.id), {
|
||||
targets,
|
||||
permissionLevel,
|
||||
...(durationHours ? { durationHours } : {}),
|
||||
});
|
||||
await refreshAccessList();
|
||||
setSelectedUserIds(new Set());
|
||||
setSelectedRoleIds(new Set());
|
||||
toast.success(t("hosts.hostSharedSuccessfully"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToShareHost"));
|
||||
toast.error(
|
||||
isFolderShare
|
||||
? t("hosts.failedToShareFolder")
|
||||
: t("hosts.failedToShareHost"),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -224,7 +258,9 @@ export function HostShareModal({
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("hosts.shareHostTitle", { name: host?.name ?? "" })}
|
||||
{isFolderShare
|
||||
? t("hosts.shareFolderTitle", { name: folder ?? "" })
|
||||
: t("hosts.shareHostTitle", { name: host?.name ?? "" })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -441,121 +477,134 @@ export function HostShareModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current access: takes remaining space, scrolls independently */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<ListChecks className="size-3.5" />
|
||||
{t("hosts.sharing.currentAccess")}
|
||||
{accessList.length > 0 && (
|
||||
<span className="text-muted-foreground/40">
|
||||
({accessList.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{accessList.length === 0 && (
|
||||
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.sharing.noAccessEntries")}
|
||||
</div>
|
||||
)}
|
||||
{accessList.map((record) => {
|
||||
const expired =
|
||||
record.expiresAt && new Date(record.expiresAt) < new Date();
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{record.targetType === "user" ? (
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold truncate">
|
||||
{record.username ??
|
||||
record.roleDisplayName ??
|
||||
record.roleName ??
|
||||
record.userId ??
|
||||
record.roleId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
|
||||
{t(
|
||||
`hosts.sharing.levels.${record.permissionLevel ?? "connect"}.label`,
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
{PERMISSION_LEVELS.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onClick={() => handleLevelChange(record, level)}
|
||||
>
|
||||
{record.permissionLevel === level ? (
|
||||
<Check className="size-3 mr-1.5" />
|
||||
) : (
|
||||
<span className="size-3 mr-1.5" />
|
||||
)}
|
||||
{t(`hosts.sharing.levels.${level}.label`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await revokeHostAccess(Number(host!.id), record.id);
|
||||
setAccessList((prev) =>
|
||||
prev.filter((entry) => entry.id !== record.id),
|
||||
);
|
||||
toast.success(t("hosts.accessRevoked"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToRevokeAccess"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("hosts.sharing.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
|
||||
<span>
|
||||
{t("hosts.sharing.grantedBy")}:{" "}
|
||||
<span className="text-foreground/70">
|
||||
{record.grantedByUsername ?? "?"}
|
||||
</span>
|
||||
</span>
|
||||
<span className={expired ? "text-destructive" : ""}>
|
||||
{t("hosts.sharing.expires")}:{" "}
|
||||
{expired ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||
<X className="size-3" />
|
||||
{t("hosts.sharing.expired")}
|
||||
</span>
|
||||
) : record.expiresAt ? (
|
||||
<span className="text-foreground/70">
|
||||
{new Date(record.expiresAt).toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground/70">
|
||||
{t("hosts.sharing.never")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Folder share summary */}
|
||||
{isFolderShare && folderShareSummary && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 text-xs text-muted-foreground">
|
||||
<ListChecks className="size-3.5 shrink-0" />
|
||||
{t("hosts.sharing.folderShareSummary", {
|
||||
shared: folderShareSummary.hostsShared,
|
||||
total: folderShareSummary.hostsTotal,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current access: takes remaining space, scrolls independently */}
|
||||
{!isFolderShare && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<ListChecks className="size-3.5" />
|
||||
{t("hosts.sharing.currentAccess")}
|
||||
{accessList.length > 0 && (
|
||||
<span className="text-muted-foreground/40">
|
||||
({accessList.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{accessList.length === 0 && (
|
||||
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.sharing.noAccessEntries")}
|
||||
</div>
|
||||
)}
|
||||
{accessList.map((record) => {
|
||||
const expired =
|
||||
record.expiresAt && new Date(record.expiresAt) < new Date();
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{record.targetType === "user" ? (
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold truncate">
|
||||
{record.username ??
|
||||
record.roleDisplayName ??
|
||||
record.roleName ??
|
||||
record.userId ??
|
||||
record.roleId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
|
||||
{t(
|
||||
`hosts.sharing.levels.${record.permissionLevel ?? "connect"}.label`,
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
{PERMISSION_LEVELS.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onClick={() => handleLevelChange(record, level)}
|
||||
>
|
||||
{record.permissionLevel === level ? (
|
||||
<Check className="size-3 mr-1.5" />
|
||||
) : (
|
||||
<span className="size-3 mr-1.5" />
|
||||
)}
|
||||
{t(`hosts.sharing.levels.${level}.label`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await revokeHostAccess(Number(host!.id), record.id);
|
||||
setAccessList((prev) =>
|
||||
prev.filter((entry) => entry.id !== record.id),
|
||||
);
|
||||
toast.success(t("hosts.accessRevoked"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToRevokeAccess"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("hosts.sharing.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
|
||||
<span>
|
||||
{t("hosts.sharing.grantedBy")}:{" "}
|
||||
<span className="text-foreground/70">
|
||||
{record.grantedByUsername ?? "?"}
|
||||
</span>
|
||||
</span>
|
||||
<span className={expired ? "text-destructive" : ""}>
|
||||
{t("hosts.sharing.expires")}:{" "}
|
||||
{expired ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||
<X className="size-3" />
|
||||
{t("hosts.sharing.expired")}
|
||||
</span>
|
||||
) : record.expiresAt ? (
|
||||
<span className="text-foreground/70">
|
||||
{new Date(record.expiresAt).toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground/70">
|
||||
{t("hosts.sharing.never")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
canShareHost,
|
||||
} from "@/sidebar/host-permissions";
|
||||
import { FolderMetadataDialog } from "./FolderMetadataDialog";
|
||||
import { HostShareModal } from "@/sidebar/HostShareModal";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
@@ -319,7 +320,7 @@ export function HostItem({
|
||||
const metricsEnabled =
|
||||
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
|
||||
const [trayOnClick, setTrayOnClick] = useState(
|
||||
() => localStorage.getItem("hostTrayOnClick") === "true",
|
||||
() => localStorage.getItem("hostTrayOnClick") !== "false",
|
||||
);
|
||||
const [showHostTags, setShowHostTags] = useState<boolean>(() => {
|
||||
const v = localStorage.getItem("showHostTags");
|
||||
@@ -362,7 +363,7 @@ export function HostItem({
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () =>
|
||||
setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true");
|
||||
setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false");
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener("hostTrayOnClickChanged", handler);
|
||||
return () => {
|
||||
@@ -1561,6 +1562,7 @@ export function FolderItem({
|
||||
onManageFolder,
|
||||
onDeleteFolder,
|
||||
onOpenAllSessions,
|
||||
onShareFolder,
|
||||
onMoveHostsToFolder,
|
||||
draggedHostIds,
|
||||
onDragHostStart,
|
||||
@@ -1591,6 +1593,7 @@ export function FolderItem({
|
||||
onManageFolder: (folder: HostFolder) => void;
|
||||
onDeleteFolder: (folder: HostFolder) => void;
|
||||
onOpenAllSessions: (folder: HostFolder) => void;
|
||||
onShareFolder?: (folder: HostFolder) => void;
|
||||
onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void;
|
||||
draggedHostIds: string[] | null;
|
||||
onDragHostStart: (hostId: string) => void;
|
||||
@@ -1674,6 +1677,18 @@ export function FolderItem({
|
||||
>
|
||||
<FolderOpen className="size-2.5" />
|
||||
</span>
|
||||
{onShareFolder && (
|
||||
<span
|
||||
title={t("hosts.shareFolder")}
|
||||
className="text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShareFolder(folder);
|
||||
}}
|
||||
>
|
||||
<Share2 className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
title={t("hosts.editFolder")}
|
||||
className="text-muted-foreground/50 hover:text-foreground"
|
||||
@@ -1727,6 +1742,7 @@ export function FolderItem({
|
||||
onManageFolder={onManageFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenAllSessions={onOpenAllSessions}
|
||||
onShareFolder={onShareFolder}
|
||||
onMoveHostsToFolder={onMoveHostsToFolder}
|
||||
draggedHostIds={draggedHostIds}
|
||||
onDragHostStart={onDragHostStart}
|
||||
@@ -1813,11 +1829,14 @@ export function SidebarTree({
|
||||
mode: "create" | "edit";
|
||||
folder?: HostFolder;
|
||||
} | null>(null);
|
||||
const [shareFolderTarget, setShareFolderTarget] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [compactHostView, setCompactHostView] = useState(
|
||||
() => localStorage.getItem("compactHostView") === "true",
|
||||
);
|
||||
const [trayOnClick, setTrayOnClick] = useState(
|
||||
() => localStorage.getItem("hostTrayOnClick") === "true",
|
||||
() => localStorage.getItem("hostTrayOnClick") !== "false",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1833,7 +1852,7 @@ export function SidebarTree({
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () =>
|
||||
setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true");
|
||||
setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false");
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener("hostTrayOnClickChanged", handler);
|
||||
return () => {
|
||||
@@ -1886,6 +1905,7 @@ export function SidebarTree({
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
credentialId: number | null;
|
||||
}) {
|
||||
const existing = folderDialog?.folder;
|
||||
try {
|
||||
@@ -1898,9 +1918,19 @@ export function SidebarTree({
|
||||
if (newPath !== oldPath) {
|
||||
await renameFolder(oldPath, newPath);
|
||||
}
|
||||
await updateFolderMetadata(newPath, value.color, value.icon);
|
||||
await updateFolderMetadata(
|
||||
newPath,
|
||||
value.color,
|
||||
value.icon,
|
||||
value.credentialId,
|
||||
);
|
||||
} else {
|
||||
await updateFolderMetadata(value.name, value.color, value.icon);
|
||||
await updateFolderMetadata(
|
||||
value.name,
|
||||
value.color,
|
||||
value.icon,
|
||||
value.credentialId,
|
||||
);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
toast.success(t("hosts.folderSaved"));
|
||||
@@ -2224,6 +2254,9 @@ export function SidebarTree({
|
||||
onManageFolder={handleManageFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onOpenAllSessions={handleOpenAllSessions}
|
||||
onShareFolder={(folder) =>
|
||||
setShareFolderTarget(folder.path ?? folder.name)
|
||||
}
|
||||
onMoveHostsToFolder={handleMoveHostsToFolder}
|
||||
draggedHostIds={draggedHostIds}
|
||||
onDragHostStart={handleDragHostStart}
|
||||
@@ -2541,12 +2574,20 @@ export function SidebarTree({
|
||||
name: folderDialog.folder.name,
|
||||
color: folderDialog.folder.color,
|
||||
icon: folderDialog.folder.icon,
|
||||
credentialId: folderDialog.folder.credentialId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onOpenChange={(v) => !v && setFolderDialog(null)}
|
||||
onSubmit={handleSaveFolderMetadata}
|
||||
/>
|
||||
|
||||
<HostShareModal
|
||||
open={shareFolderTarget !== null}
|
||||
onClose={() => setShareFolderTarget(null)}
|
||||
host={null}
|
||||
folder={shareFolderTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -464,8 +464,8 @@ export function UserProfilePanel({
|
||||
"one-dark": t("newUi.sidebar.userProfile.themeOneDark"),
|
||||
gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"),
|
||||
};
|
||||
const [openSection, setOpenSection] = useState<UserProfileSection | null>(
|
||||
"account",
|
||||
const [openSections, setOpenSections] = useState<Set<UserProfileSection>>(
|
||||
() => new Set(["account"]),
|
||||
);
|
||||
|
||||
// User info
|
||||
@@ -548,7 +548,7 @@ export function UserProfilePanel({
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const [hostTrayOnClick, setHostTrayOnClick] = useState(
|
||||
() => localStorage.getItem("hostTrayOnClick") === "true",
|
||||
() => localStorage.getItem("hostTrayOnClick") !== "false",
|
||||
);
|
||||
const [compactHostView, setCompactHostView] = useState(
|
||||
() => localStorage.getItem("compactHostView") === "true",
|
||||
@@ -790,8 +790,8 @@ export function UserProfilePanel({
|
||||
setShowHostTags(true);
|
||||
localStorage.setItem("showHostTags", "true");
|
||||
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||
setHostTrayOnClick(false);
|
||||
localStorage.setItem("hostTrayOnClick", "false");
|
||||
setHostTrayOnClick(true);
|
||||
localStorage.setItem("hostTrayOnClick", "true");
|
||||
setCompactHostView(false);
|
||||
localStorage.setItem("compactHostView", "false");
|
||||
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
|
||||
@@ -824,7 +824,7 @@ export function UserProfilePanel({
|
||||
commandAutocomplete: false,
|
||||
commandPaletteEnabled: true,
|
||||
showHostTags: true,
|
||||
hostTrayOnClick: false,
|
||||
hostTrayOnClick: true,
|
||||
compactHostView: false,
|
||||
pinAppRail: false,
|
||||
expandAppRailOnHover: true,
|
||||
@@ -893,7 +893,7 @@ export function UserProfilePanel({
|
||||
localStorage.setItem("showHostTags", String(restoredHostTags));
|
||||
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||
|
||||
const restoredTrayOnClick = restore("hostTrayOnClick", "false") === "true";
|
||||
const restoredTrayOnClick = restore("hostTrayOnClick", "true") !== "false";
|
||||
setHostTrayOnClick(restoredTrayOnClick);
|
||||
localStorage.setItem("hostTrayOnClick", String(restoredTrayOnClick));
|
||||
|
||||
@@ -1001,7 +1001,12 @@ export function UserProfilePanel({
|
||||
}
|
||||
|
||||
function toggle(id: UserProfileSection) {
|
||||
setOpenSection((prev) => (prev === id ? null : id));
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStartTotpSetup() {
|
||||
@@ -1200,7 +1205,7 @@ export function UserProfilePanel({
|
||||
id="account"
|
||||
label={t("newUi.sidebar.userProfile.sectionAccount")}
|
||||
icon={<User className="size-3.5" />}
|
||||
open={openSection === "account"}
|
||||
open={openSections.has("account")}
|
||||
onToggle={() => toggle("account")}
|
||||
>
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
@@ -1367,7 +1372,7 @@ export function UserProfilePanel({
|
||||
id="appearance"
|
||||
label={t("newUi.sidebar.userProfile.sectionAppearance")}
|
||||
icon={<Palette className="size-3.5" />}
|
||||
open={openSection === "appearance"}
|
||||
open={openSections.has("appearance")}
|
||||
onToggle={() => toggle("appearance")}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
@@ -1873,7 +1878,7 @@ export function UserProfilePanel({
|
||||
id="security"
|
||||
label={t("newUi.sidebar.userProfile.sectionSecurity")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
open={openSection === "security"}
|
||||
open={openSections.has("security")}
|
||||
onToggle={() => toggle("security")}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
@@ -2204,7 +2209,7 @@ export function UserProfilePanel({
|
||||
id="api-keys"
|
||||
label={t("newUi.sidebar.userProfile.sectionApiKeys")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
open={openSection === "api-keys"}
|
||||
open={openSections.has("api-keys")}
|
||||
onToggle={() => toggle("api-keys")}
|
||||
>
|
||||
<div className="flex flex-col gap-2 pt-3">
|
||||
@@ -2310,7 +2315,7 @@ export function UserProfilePanel({
|
||||
id="c2s-tunnels"
|
||||
label={t("newUi.sidebar.userProfile.sectionC2sTunnels")}
|
||||
icon={<Activity className="size-3.5" />}
|
||||
open={openSection === "c2s-tunnels"}
|
||||
open={openSections.has("c2s-tunnels")}
|
||||
onToggle={() => toggle("c2s-tunnels")}
|
||||
>
|
||||
<C2STunnelPresetManager />
|
||||
|
||||
Reference in New Issue
Block a user