mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
+16
-11
@@ -15,6 +15,7 @@ import { Skeleton } from "@/components/skeleton";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useRailItems, type RailItemDef } from "./rail-items";
|
||||
import { RailBadge } from "./RailBadge";
|
||||
import { rem } from "@/lib/rem";
|
||||
|
||||
/** Core rail views; plugins add their own ids at runtime. */
|
||||
export type CoreRailView =
|
||||
@@ -102,7 +103,7 @@ function buildRailButtons(
|
||||
|
||||
const btnBase =
|
||||
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
|
||||
const btnStyle = { margin: "0 4px", padding: "0 8px" };
|
||||
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
|
||||
|
||||
export function AppRail({
|
||||
railView,
|
||||
@@ -240,7 +241,7 @@ export function AppRail({
|
||||
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: railExpanded ? 160 : 40 }}
|
||||
style={{ width: rem(railExpanded ? 160 : 40) }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onContextMenu={(e) => {
|
||||
@@ -273,7 +274,9 @@ export function AppRail({
|
||||
<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% - 16px)" : 20 }}
|
||||
style={{
|
||||
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
|
||||
}}
|
||||
/>
|
||||
) : "tabType" in item ? (
|
||||
<button
|
||||
@@ -284,7 +287,7 @@ export function AppRail({
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
@@ -343,7 +346,7 @@ export function AppRail({
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
@@ -384,7 +387,7 @@ export function AppRail({
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
<Pin size={16} />
|
||||
</span>
|
||||
@@ -400,7 +403,9 @@ export function AppRail({
|
||||
{showPinButton && (
|
||||
<div
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
|
||||
style={{
|
||||
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{footerItems.map((item) => {
|
||||
@@ -449,7 +454,7 @@ export function AppRail({
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.useBadge && (
|
||||
@@ -528,7 +533,7 @@ export function AppRail({
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
@@ -549,7 +554,7 @@ export function AppRail({
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</span>
|
||||
@@ -571,7 +576,7 @@ export function AppRail({
|
||||
>
|
||||
<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"
|
||||
style={{ width: 24, height: 24, fontSize: 11 }}
|
||||
style={{ width: rem(24), height: rem(24), fontSize: rem(11) }}
|
||||
>
|
||||
{username.charAt(0).toUpperCase() || "U"}
|
||||
</div>
|
||||
|
||||
@@ -19,12 +19,15 @@ import {
|
||||
} from "@/components/folder-style";
|
||||
import { normalizePath, splitPath } from "./FolderPathPicker";
|
||||
import { getCredentials } from "@/main-axios";
|
||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import { FakeSwitch, SettingRow } from "@/components/section-card";
|
||||
|
||||
export type FolderMetadataValue = {
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
credentialId: number | null;
|
||||
localOnly: boolean;
|
||||
};
|
||||
|
||||
type CredentialOption = { id: string; name: string; username?: string };
|
||||
@@ -45,6 +48,7 @@ export function FolderMetadataDialog({
|
||||
color?: string;
|
||||
icon?: string;
|
||||
credentialId?: number | null;
|
||||
localOnly?: boolean;
|
||||
};
|
||||
/** Every folder path that already exists, for duplicate-name validation. */
|
||||
existingPaths?: string[];
|
||||
@@ -58,6 +62,8 @@ export function FolderMetadataDialog({
|
||||
const [color, setColor] = useState(DEFAULT_FOLDER_COLOR);
|
||||
const [icon, setIcon] = useState(DEFAULT_FOLDER_ICON);
|
||||
const [credentialId, setCredentialId] = useState<string>("");
|
||||
const [localOnly, setLocalOnly] = useState(false);
|
||||
const syncLinked = !!useSyncStatus()?.linked;
|
||||
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
|
||||
|
||||
const parentPath =
|
||||
@@ -84,6 +90,7 @@ export function FolderMetadataDialog({
|
||||
setCredentialId(
|
||||
initial?.credentialId ? String(initial.credentialId) : "",
|
||||
);
|
||||
setLocalOnly(!!initial?.localOnly);
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
@@ -111,6 +118,7 @@ export function FolderMetadataDialog({
|
||||
color,
|
||||
icon,
|
||||
credentialId: credentialId ? Number(credentialId) : null,
|
||||
localOnly,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
@@ -186,6 +194,14 @@ export function FolderMetadataDialog({
|
||||
{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")}
|
||||
|
||||
@@ -45,7 +45,9 @@ export function HostAuthOverrideModal({
|
||||
const ownerAuthShared =
|
||||
overrideState?.ownerAuthShared ??
|
||||
(protocol === "ssh" ? !!host.shareSshAuth : false);
|
||||
const remoteShared = !!host.isShared && Number(host.id) < 0;
|
||||
// A shared host's copy on a linked desktop: its override lives on the
|
||||
// server, against the server's own credentials.
|
||||
const sharedCopySyncId = host.sharedCopy ? (host.syncId ?? null) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -53,18 +55,16 @@ export function HostAuthOverrideModal({
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
|
||||
const credentialsRequest = remoteShared
|
||||
const credentialsRequest = sharedCopySyncId
|
||||
? getConnectedRemoteApi().then((api) => {
|
||||
if (!api) throw new Error("Remote server is not connected");
|
||||
if (!api) throw new Error("The linked server is not reachable");
|
||||
return api.get("/credentials").then((response) => response.data);
|
||||
})
|
||||
: getCredentials();
|
||||
|
||||
Promise.all([
|
||||
credentialsRequest,
|
||||
remoteShared
|
||||
? getHostAuthOverride(Number(host.id), protocol, true)
|
||||
: getHostAuthOverride(Number(host.id), protocol),
|
||||
getHostAuthOverride(Number(host.id), protocol, sharedCopySyncId),
|
||||
])
|
||||
.then(([credentialResult, overrideResult]) => {
|
||||
if (cancelled) return;
|
||||
@@ -87,22 +87,18 @@ export function HostAuthOverrideModal({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [host.id, open, protocol, remoteShared]);
|
||||
}, [host.id, open, protocol, sharedCopySyncId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const credentialId = selectedId ? Number(selectedId) : null;
|
||||
if (remoteShared) {
|
||||
await setHostAuthOverride(
|
||||
Number(host.id),
|
||||
protocol,
|
||||
credentialId,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
await setHostAuthOverride(Number(host.id), protocol, credentialId);
|
||||
}
|
||||
await setHostAuthOverride(
|
||||
Number(host.id),
|
||||
protocol,
|
||||
credentialId,
|
||||
sharedCopySyncId,
|
||||
);
|
||||
toast.success(
|
||||
credentialId === null
|
||||
? t(
|
||||
|
||||
@@ -167,6 +167,7 @@ export function createHostEditorForm(
|
||||
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
|
||||
connectionOrigin: (host?.connectionOrigin ?? null) as
|
||||
"local" | "remote" | null,
|
||||
localOnly: host?.localOnly ?? false,
|
||||
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
|
||||
inheritTerminalAppearance:
|
||||
!host ||
|
||||
@@ -415,6 +416,7 @@ export function buildHostEditorPayload(
|
||||
socks5ProxyChain:
|
||||
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
|
||||
connectionOrigin: form.connectionOrigin,
|
||||
localOnly: form.localOnly,
|
||||
enableSsh: protocols.enableSsh,
|
||||
sshPort: Number(form.sshPort),
|
||||
forceKeyboardInteractive: form.forceKeyboardInteractive,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FolderPathPicker } from "./FolderPathPicker";
|
||||
import { HostParentPicker } from "./HostParentPicker";
|
||||
import { getSSHFolders, isElectron } from "@/main-axios";
|
||||
import { connectionOriginAppliesTo } from "./HostEditorData";
|
||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useHostProtocols } from "./host-protocols";
|
||||
@@ -39,6 +40,7 @@ export function HostEditorGeneralTab({
|
||||
simpleMode?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const syncLinked = !!useSyncStatus()?.linked;
|
||||
const pluginProtocols = useHostProtocols();
|
||||
|
||||
// Tracks which picker is shown, independent of whether a value is set yet
|
||||
@@ -311,6 +313,17 @@ export function HostEditorGeneralTab({
|
||||
onChange={(v) => setField("pin", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{syncLinked && (
|
||||
<SettingRow
|
||||
label={t("hosts.localOnly")}
|
||||
description={t("hosts.localOnlyDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.localOnly}
|
||||
onChange={(v) => setField("localOnly", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-4 pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -78,6 +78,8 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
pin: h.pin ?? false,
|
||||
sortOrder: h.sortOrder ?? null,
|
||||
connectionOrigin: h.connectionOrigin ?? null,
|
||||
localOnly: !!h.localOnly,
|
||||
sharedCopy: !!h.sharedCopy,
|
||||
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
|
||||
sshPort:
|
||||
h.sshPort ??
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import {
|
||||
getUserInfo,
|
||||
getRemoteSyncUserInfo,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
@@ -20,11 +19,10 @@ import {
|
||||
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useHostSidebarPreferences } from "./tree/hooks/useHostSidebarPreferences";
|
||||
import type { UserRole } from "@/main-axios";
|
||||
import type { LinkedAccountInfo, UserRole } from "@/main-axios";
|
||||
import type React from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
|
||||
import { shouldForceLocalPreferenceStorage } from "@/settings/remote-sync-state";
|
||||
import { shell } from "@/plugin-host/shell-bridge";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { VersionBadge } from "@/components/version-badge";
|
||||
@@ -522,15 +520,9 @@ export function UserProfilePanel({
|
||||
onLogout,
|
||||
userPrefs,
|
||||
onPrefsChange,
|
||||
remoteSyncInitialServerUrl,
|
||||
remoteSyncReconnectRequested,
|
||||
onRemoteSyncReconnectHandled,
|
||||
}: {
|
||||
username?: string;
|
||||
onLogout?: () => void;
|
||||
remoteSyncInitialServerUrl?: string;
|
||||
remoteSyncReconnectRequested?: boolean;
|
||||
onRemoteSyncReconnectHandled?: () => void;
|
||||
userPrefs?: {
|
||||
reopenTabsOnLogin: boolean;
|
||||
storageMode?: string | null;
|
||||
@@ -629,51 +621,19 @@ export function UserProfilePanel({
|
||||
}
|
||||
}, [userPrefs?.storageMode]);
|
||||
|
||||
// Remote sync is not connected by default on the desktop app (it's an
|
||||
// opt-in feature configured from this same panel). "cloud" storage mode
|
||||
// and Termix ID both assume a real multi-device server account, so they
|
||||
// stay hidden/forced-off until the user actually connects one.
|
||||
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState<
|
||||
boolean | null
|
||||
>(() => (isElectron() ? null : true));
|
||||
// On the desktop app, the account it is linked to on a server, if any.
|
||||
// "Cloud" storage only has somewhere to go once it is linked.
|
||||
const [linkedAccount, setLinkedAccount] = useState<
|
||||
LinkedAccountInfo | null | undefined
|
||||
>(() => (isElectron() ? undefined : null));
|
||||
const isLinked = !!linkedAccount;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
let cancelled = false;
|
||||
const refreshSyncStatus = () => {
|
||||
window.electronAPI
|
||||
?.invoke?.("get-remote-sync-config")
|
||||
.then((config) => {
|
||||
if (!cancelled) {
|
||||
setIsRemoteSyncConnected(
|
||||
!!(config as { serverUrl?: string } | null)?.serverUrl,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
refreshSyncStatus();
|
||||
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
|
||||
refreshSyncStatus(),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldForceLocalPreferenceStorage(
|
||||
isElectron(),
|
||||
isRemoteSyncConnected,
|
||||
storageMode,
|
||||
)
|
||||
) {
|
||||
if (isElectron() && linkedAccount === null && storageMode === "cloud") {
|
||||
setStorageMode("local");
|
||||
onPrefsChange?.({ storageMode: "local" });
|
||||
}
|
||||
}, [isRemoteSyncConnected, storageMode, onPrefsChange]);
|
||||
}, [linkedAccount, storageMode, onPrefsChange]);
|
||||
|
||||
// Settings toggles — all backed by localStorage
|
||||
const [commandAutocomplete, setCommandAutocomplete] = useState(
|
||||
@@ -738,16 +698,17 @@ export function UserProfilePanel({
|
||||
|
||||
useEffect(() => {
|
||||
getUserInfo()
|
||||
.then(async (localInfo) => {
|
||||
setUserId(localInfo.userId);
|
||||
setIsOidc(localInfo.is_oidc ?? false);
|
||||
setIsDualAuth(localInfo.is_dual_auth ?? false);
|
||||
const remoteInfo = await getRemoteSyncUserInfo();
|
||||
const info = remoteInfo ?? localInfo;
|
||||
setAccountUsername(info.username);
|
||||
.then((info) => {
|
||||
setUserId(info.userId);
|
||||
setIsOidc(info.is_oidc ?? false);
|
||||
setIsDualAuth(info.is_dual_auth ?? false);
|
||||
const linked = info.linked ?? null;
|
||||
setLinkedAccount(linked);
|
||||
// A linked desktop is the account it is signed in to on the server.
|
||||
setAccountUsername(linked?.username || info.username);
|
||||
setAccountTotpEnabled(info.totp_enabled ?? false);
|
||||
setUserRole(
|
||||
info.is_admin
|
||||
(linked ? linked.isAdmin : info.is_admin)
|
||||
? t("newUi.sidebar.userProfile.roleAdministrator")
|
||||
: t("newUi.sidebar.userProfile.roleUser"),
|
||||
);
|
||||
@@ -758,10 +719,17 @@ export function UserProfilePanel({
|
||||
} else {
|
||||
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
|
||||
}
|
||||
if (remoteInfo) {
|
||||
setUserRoles(remoteInfo.roles ?? []);
|
||||
if (linked) {
|
||||
setUserRoles(
|
||||
linked.roles.map((name, index) => ({
|
||||
id: index,
|
||||
roleId: index,
|
||||
roleName: name,
|
||||
roleDisplayName: name,
|
||||
})) as unknown as UserRole[],
|
||||
);
|
||||
} else {
|
||||
getUserRoles(localInfo.userId)
|
||||
getUserRoles(info.userId)
|
||||
.then(({ roles }) => setUserRoles(roles ?? []))
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -1340,7 +1308,7 @@ export function UserProfilePanel({
|
||||
{/* 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() || isRemoteSyncConnected === true) && (
|
||||
{(!isElectron() || isLinked) && (
|
||||
<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")}
|
||||
@@ -1389,17 +1357,35 @@ export function UserProfilePanel({
|
||||
onToggle={() => toggle("account")}
|
||||
>
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
{isElectron() ? (
|
||||
{isElectron() && (
|
||||
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 mb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-accent-brand">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
{t("newUi.sidebar.userProfile.desktopProfileTitle")}
|
||||
{isLinked
|
||||
? t("newUi.sidebar.userProfile.linkedProfileTitle", {
|
||||
server:
|
||||
linkedAccount?.serverName || linkedAccount?.serverUrl,
|
||||
})
|
||||
: t("newUi.sidebar.userProfile.desktopProfileTitle")}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed mt-1">
|
||||
{t("newUi.sidebar.userProfile.desktopProfileDescription")}
|
||||
{isLinked
|
||||
? t("newUi.sidebar.userProfile.linkedProfileDescription")
|
||||
: t("newUi.sidebar.userProfile.desktopProfileDescription")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 h-7 text-[10px] rounded-none"
|
||||
onClick={() => shell.openRailView("sync")}
|
||||
>
|
||||
{isLinked
|
||||
? t("newUi.sidebar.userProfile.manageSync")
|
||||
: t("newUi.sidebar.userProfile.setUpSync")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{(!isElectron() || isLinked) && (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-0">
|
||||
<div className="flex flex-col py-2">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
@@ -1427,33 +1413,37 @@ export function UserProfilePanel({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col py-2">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("newUi.sidebar.userProfile.authMethodLabel")}
|
||||
</span>
|
||||
<span className="text-sm font-semibold mt-0.5">
|
||||
{authMethod || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col py-2">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("newUi.sidebar.userProfile.twoFaLabel")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 mt-0.5">
|
||||
{accountTotpEnabled ? (
|
||||
<>
|
||||
<ShieldCheck className="size-3.5 text-accent-brand" />
|
||||
<span className="text-sm font-semibold text-accent-brand">
|
||||
{t("newUi.sidebar.userProfile.twoFaOn")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.twoFaOff")}
|
||||
{!isElectron() && (
|
||||
<>
|
||||
<div className="flex flex-col py-2">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("newUi.sidebar.userProfile.authMethodLabel")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold mt-0.5">
|
||||
{authMethod || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col py-2">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("newUi.sidebar.userProfile.twoFaLabel")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 mt-0.5">
|
||||
{accountTotpEnabled ? (
|
||||
<>
|
||||
<ShieldCheck className="size-3.5 text-accent-brand" />
|
||||
<span className="text-sm font-semibold text-accent-brand">
|
||||
{t("newUi.sidebar.userProfile.twoFaOn")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.twoFaOff")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1519,16 +1509,6 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isElectron() && (
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<RemoteSyncPanel
|
||||
initialServerUrl={remoteSyncInitialServerUrl}
|
||||
reconnectRequested={remoteSyncReconnectRequested}
|
||||
onReconnectRequestHandled={onRemoteSyncReconnectHandled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isElectron() && (
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -2011,7 +1991,7 @@ export function UserProfilePanel({
|
||||
|
||||
{/* The embedded desktop backend auto-authenticates its machine-local
|
||||
profile, so server login controls would imply protection they do
|
||||
not provide. Remote Sync owns its separate account UI above. */}
|
||||
not provide. A linked account's sign-in is managed on its server. */}
|
||||
<AccordionSection
|
||||
hidden={isElectron()}
|
||||
id="security"
|
||||
|
||||
@@ -27,6 +27,7 @@ export function buildHostTree(
|
||||
icon?: string;
|
||||
credentialId?: number | null;
|
||||
sortOrder?: number | null;
|
||||
localOnly?: boolean;
|
||||
}
|
||||
>,
|
||||
): HostFolder {
|
||||
@@ -48,6 +49,7 @@ export function buildHostTree(
|
||||
icon: meta?.icon,
|
||||
credentialId: meta?.credentialId ?? null,
|
||||
sortOrder: meta?.sortOrder ?? null,
|
||||
localOnly: !!meta?.localOnly,
|
||||
children: [],
|
||||
};
|
||||
folderMap.set(accumulated, folder);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rem } from "@/lib/rem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -114,7 +115,7 @@ export function CredentialItem({
|
||||
const canDrag = arrangeMode && !isTouchOnly;
|
||||
|
||||
const depthStyle =
|
||||
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
|
||||
depth > 0 ? ({ paddingLeft: rem(depth * 12) } as const) : undefined;
|
||||
|
||||
const trayButtonClass =
|
||||
"flex items-center justify-center size-6.5 text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rem } from "@/lib/rem";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -64,7 +65,7 @@ export function CredentialFolderItem({
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
|
||||
style={depth > 0 ? { paddingLeft: rem(depth * 12) } : undefined}
|
||||
>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Braces,
|
||||
Cloud,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
@@ -116,6 +118,14 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
labelKey: "nav.splitScreen",
|
||||
separatorAfter: true,
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
icon: Cloud,
|
||||
labelKey: "nav.sync",
|
||||
electronOnly: true,
|
||||
placement: "footer",
|
||||
useBadge: useSyncAttentionCount,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { rem } from "@/lib/rem";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, Check, GripVertical } from "lucide-react";
|
||||
@@ -177,7 +178,7 @@ export function FolderItem({
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
|
||||
style={depth > 0 ? { paddingLeft: rem(depth * 12) } : undefined}
|
||||
>
|
||||
<div className="relative">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { rem } from "@/lib/rem";
|
||||
import { enabledHostProtocols } from "@/sidebar/host-protocols";
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -368,7 +369,7 @@ export function HostItem({
|
||||
if (query && !hostMatchesQuery(host, query)) return null;
|
||||
|
||||
const depthStyle =
|
||||
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
|
||||
depth > 0 ? ({ paddingLeft: rem(depth * 12) } as const) : undefined;
|
||||
|
||||
const trayButtonClass =
|
||||
"flex items-center justify-center size-[22.75px] text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
|
||||
|
||||
@@ -459,6 +459,7 @@ export function SidebarTree({
|
||||
color: string;
|
||||
icon: string;
|
||||
credentialId: number | null;
|
||||
localOnly: boolean;
|
||||
}) {
|
||||
const existing = folderDialog?.folder;
|
||||
try {
|
||||
@@ -476,6 +477,7 @@ export function SidebarTree({
|
||||
value.color,
|
||||
value.icon,
|
||||
value.credentialId,
|
||||
value.localOnly,
|
||||
);
|
||||
} else {
|
||||
await updateFolderMetadata(
|
||||
@@ -483,6 +485,7 @@ export function SidebarTree({
|
||||
value.color,
|
||||
value.icon,
|
||||
value.credentialId,
|
||||
value.localOnly,
|
||||
);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
@@ -1346,6 +1349,7 @@ export function SidebarTree({
|
||||
color: folderDialog.folder.color,
|
||||
icon: folderDialog.folder.icon,
|
||||
credentialId: folderDialog.folder.credentialId,
|
||||
localOnly: folderDialog.folder.localOnly,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user