mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move the sync screens and wizard out of core into the plugin (#1580)
This commit is contained in:
1 parent
ee0d1a5d8e
commit
b0f2213b15
30 files changed
+9
-1788
No files matched your search
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Server and desktop sync moved out of the core app into a plugin. Existing links and offline copies keep working
|
||||
- Server and desktop sync moved out of the core app into a plugin, including the Sync screen, the link wizard and the first run step. Existing links and offline copies keep working
|
||||
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
|
||||
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
|
||||
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
|
||||
|
||||
@@ -8,6 +8,10 @@ Plugin API 1.8.0.
|
||||
|
||||
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
|
||||
|
||||
### Removed
|
||||
|
||||
- The sync screens and calls in `@termix-ssh/plugin-sdk/ui`: `LinkServerDialog`, `ProxySettingsFields`, `useSyncStatus`, `refreshSyncStatus`, `setSyncStatus`, `getSyncConflicts`, `getSyncErrors`, `retrySyncErrors`, `settleSyncConflict`, `syncNow`, `unlinkServer`, `updateSyncSettings` and their types. They moved into the sync plugin
|
||||
|
||||
## 1.0.15
|
||||
|
||||
Plugin API 1.7.0.
|
||||
|
||||
@@ -176,7 +176,6 @@ LANGUAGES (value): readonly { code: string; label: string; }[]
|
||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
LinkServerDialog (value): ({ open, onOpenChange, onLinked, relogin, }: LinkServerDialogProps) => Element
|
||||
LinkedAccountInfo (type): LinkedAccountInfo
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
@@ -227,7 +226,6 @@ PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDiv
|
||||
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ProxyNode (type): ProxyNode
|
||||
ProxySettingsFields (value): ({ customHeaders, basicAuth, onChange, }: ProxySettingsFieldsProps) => Element
|
||||
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||
RadialGaugeProps (type): RadialGaugeProps
|
||||
RailGroup (type): RailGroup
|
||||
@@ -288,12 +286,6 @@ SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
SyncBasicAuth (type): SyncBasicAuth
|
||||
SyncConflictItem (type): SyncConflictItem
|
||||
SyncEntityInfo (type): SyncEntityInfo
|
||||
SyncErrorItem (type): SyncErrorItem
|
||||
SyncProxyHeader (type): SyncProxyHeader
|
||||
SyncStatus (type): SyncStatus
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
@@ -443,8 +435,6 @@ getSessionTimeoutMinutes (value): () => Promise<number>
|
||||
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
|
||||
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
|
||||
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
|
||||
getSyncConflicts (value): () => Promise<SyncConflictItem[]>
|
||||
getSyncErrors (value): () => Promise<SyncErrorItem[]>
|
||||
getTabType (value): (id: string) => TabTypeDef
|
||||
getTlsStatus (value): () => Promise<TlsStatus>
|
||||
getUpdateChannel (value): () => Promise<UpdateChannelState>
|
||||
@@ -509,7 +499,6 @@ quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef
|
||||
railItemLabel (value): (id: string, t: (key: string) => string) => string
|
||||
readFromClipboard (value): () => Promise<string>
|
||||
recordHostActionPreference (value): (hostId: string, action: TabType) => void
|
||||
refreshSyncStatus (value): () => void
|
||||
registerLayout (value): (item: RegisteredLayout) => () => void
|
||||
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
|
||||
releaseUrlFrom (value): (info: VersionInfo) => string
|
||||
@@ -532,7 +521,6 @@ resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaul
|
||||
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
|
||||
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
retrySyncErrors (value): () => Promise<SyncStatus>
|
||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
||||
@@ -556,21 +544,17 @@ setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||
setSyncStatus (value): (next: SyncStatus) => void
|
||||
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
|
||||
settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise<void>
|
||||
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
|
||||
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
|
||||
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
|
||||
shell (value): TabShellCallbacks
|
||||
splitDefaultKey (value): (fullKey: string) => [string, string]
|
||||
sshHostToHost (value): (h: SSHHostWithStatus) => Host
|
||||
syncNow (value): () => Promise<SyncStatus>
|
||||
tabIcon (value): (type: TabType) => Element
|
||||
tabJumpHotkeyKeys (value): () => string[]
|
||||
takePendingManageRequest (value): () => ManageRequest
|
||||
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
unlinkServer (value): (keepData: boolean) => Promise<SyncStatus>
|
||||
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
|
||||
@@ -588,7 +572,6 @@ updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
|
||||
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
|
||||
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
|
||||
updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise<SyncStatus>
|
||||
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
|
||||
usageBarColor (value): (percent: number) => string
|
||||
usageColor (value): (percent: number) => string
|
||||
@@ -635,7 +618,6 @@ useSshAuthEditors (value): () => SshAuthEditorDef[]
|
||||
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
|
||||
useStatusColorScheme (value): () => StatusColorScheme
|
||||
useSurfaceKind (value): () => SurfaceKind
|
||||
useSyncStatus (value): () => any
|
||||
useTabHandlesVersion (value): () => number
|
||||
useTabs (value): () => TabContextType
|
||||
useTabsSafe (value): () => TabContextType
|
||||
|
||||
@@ -197,7 +197,6 @@ const ALLOWED = [
|
||||
"Label",
|
||||
"LineChart",
|
||||
"LineChartSeries",
|
||||
"LinkServerDialog",
|
||||
"LinkedAccountInfo",
|
||||
"ListBadge",
|
||||
"ListRow",
|
||||
@@ -248,7 +247,6 @@ const ALLOWED = [
|
||||
"PopoverContent",
|
||||
"PopoverTrigger",
|
||||
"ProxyNode",
|
||||
"ProxySettingsFields",
|
||||
"RadialGauge",
|
||||
"RadialGaugeProps",
|
||||
"RailGroup",
|
||||
@@ -309,12 +307,6 @@ const ALLOWED = [
|
||||
"SurfaceScope",
|
||||
"Switch",
|
||||
"SwitchRow",
|
||||
"SyncBasicAuth",
|
||||
"SyncConflictItem",
|
||||
"SyncEntityInfo",
|
||||
"SyncErrorItem",
|
||||
"SyncProxyHeader",
|
||||
"SyncStatus",
|
||||
"TOTPDialog",
|
||||
"Tab",
|
||||
"TabShellCallbacks",
|
||||
@@ -464,8 +456,6 @@ const ALLOWED = [
|
||||
"getSessions",
|
||||
"getStatusCheckSettings",
|
||||
"getStatusClasses",
|
||||
"getSyncConflicts",
|
||||
"getSyncErrors",
|
||||
"getTabType",
|
||||
"getTlsStatus",
|
||||
"getUpdateChannel",
|
||||
@@ -530,7 +520,6 @@ const ALLOWED = [
|
||||
"railItemLabel",
|
||||
"readFromClipboard",
|
||||
"recordHostActionPreference",
|
||||
"refreshSyncStatus",
|
||||
"registerLayout",
|
||||
"registerTabCloseGuard",
|
||||
"releaseUrlFrom",
|
||||
@@ -553,7 +542,6 @@ const ALLOWED = [
|
||||
"resolveHostDefaults",
|
||||
"resolveHostTabType",
|
||||
"resolveRemoteHostId",
|
||||
"retrySyncErrors",
|
||||
"revokeAllUserSessions",
|
||||
"revokeCredentialAccess",
|
||||
"revokeFolderAccess",
|
||||
@@ -577,21 +565,17 @@ const ALLOWED = [
|
||||
"setHostAuthOverride",
|
||||
"setNotificationPrivateEndpoints",
|
||||
"setPluginOptions",
|
||||
"setSyncStatus",
|
||||
"setUpdateChannel",
|
||||
"settleSyncConflict",
|
||||
"shareCredential",
|
||||
"shareFolder",
|
||||
"shareHost",
|
||||
"shell",
|
||||
"splitDefaultKey",
|
||||
"sshHostToHost",
|
||||
"syncNow",
|
||||
"tabIcon",
|
||||
"tabJumpHotkeyKeys",
|
||||
"takePendingManageRequest",
|
||||
"unlinkExternalFromPasswordAccount",
|
||||
"unlinkServer",
|
||||
"updateCredential",
|
||||
"updateExternalAutoProvision",
|
||||
"updateFolderMetadata",
|
||||
@@ -609,7 +593,6 @@ const ALLOWED = [
|
||||
"updateSessionTimeout",
|
||||
"updateHttpsSettings",
|
||||
"updateStatusCheckSettings",
|
||||
"updateSyncSettings",
|
||||
"uploadTlsCertificate",
|
||||
"usageBarColor",
|
||||
"usageColor",
|
||||
@@ -656,7 +639,6 @@ const ALLOWED = [
|
||||
"useSshAuthProviders",
|
||||
"useStatusColorScheme",
|
||||
"useSurfaceKind",
|
||||
"useSyncStatus",
|
||||
"useTabHandlesVersion",
|
||||
"useTabs",
|
||||
"useTabsSafe",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({}));
|
||||
|
||||
import { registerCoreSyncEntities } from "../../sync/entities.js";
|
||||
import {
|
||||
listEntityTypes,
|
||||
resetSyncRegistry,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
|
||||
afterEach(() => resetSyncRegistry());
|
||||
|
||||
describe("core sync entities", () => {
|
||||
it("each has a label in the Sync panel", () => {
|
||||
registerCoreSyncEntities();
|
||||
const en = JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL("../../../ui/locales/en.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const missing = listEntityTypes().filter((type) => !en.sync.entities[type]);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
export const CORE_ONBOARDING_STEPS = {
|
||||
plugins: 1,
|
||||
"desktop-sync": 1,
|
||||
"desktop-updates": 1,
|
||||
security: 1,
|
||||
} as const;
|
||||
@@ -24,7 +23,6 @@ export type CoreOnboardingStepKey = keyof typeof CORE_ONBOARDING_STEPS;
|
||||
*/
|
||||
export const LEGACY_SEEN: Readonly<Record<string, number>> = Object.freeze({
|
||||
plugins: 1,
|
||||
"desktop-sync": 1,
|
||||
preset: 1,
|
||||
appearance: 1,
|
||||
security: 1,
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
|
||||
export type SyncState = "idle" | "syncing" | "offline" | "error" | "signed_out";
|
||||
|
||||
export interface SyncProxyHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
/** A saved header whose value is not sent back. */
|
||||
set?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncBasicAuth {
|
||||
username: string;
|
||||
password: string;
|
||||
set?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncEntityInfo {
|
||||
type: string;
|
||||
owner: string;
|
||||
ownerName: string | null;
|
||||
readOnly: boolean;
|
||||
onServer: boolean;
|
||||
onDevice: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
linked: boolean;
|
||||
serverUrl?: string;
|
||||
serverName?: string | null;
|
||||
serverVersion?: string | null;
|
||||
account?: {
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
};
|
||||
status?: SyncState;
|
||||
lastError?: string | null;
|
||||
linkedAt?: string;
|
||||
lastSyncAt?: string | null;
|
||||
pending?: number;
|
||||
conflicts?: number;
|
||||
errors?: number;
|
||||
firstSyncDone?: boolean;
|
||||
allowInvalidCertificate?: boolean;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
entities: SyncEntityInfo[];
|
||||
}
|
||||
|
||||
export type ProbeProblem =
|
||||
| "invalid_url"
|
||||
| "unreachable"
|
||||
| "tls_untrusted"
|
||||
| "basic_auth_required"
|
||||
| "proxy_login"
|
||||
| "not_termix"
|
||||
| "version_mismatch"
|
||||
| "different_account";
|
||||
|
||||
export interface ProbeResult {
|
||||
ok: boolean;
|
||||
serverUrl?: string;
|
||||
name?: string;
|
||||
version?: string | null;
|
||||
problem?: ProbeProblem;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ServerTarget {
|
||||
serverUrl: string;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncConflictItem {
|
||||
id: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SyncErrorItem {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export async function getSyncStatus(): Promise<SyncStatus> {
|
||||
return (await authApi.get("/sync/link/status")).data;
|
||||
}
|
||||
|
||||
export async function probeServer(target: ServerTarget): Promise<ProbeResult> {
|
||||
return (await authApi.post("/sync/link/probe", target)).data;
|
||||
}
|
||||
|
||||
export async function getLinkPreview(): Promise<Record<string, number>> {
|
||||
return (await authApi.get("/sync/link/preview")).data?.counts ?? {};
|
||||
}
|
||||
|
||||
export async function completeLink(
|
||||
input: ServerTarget & {
|
||||
token: string;
|
||||
mode: "merge" | "replace";
|
||||
serverName?: string;
|
||||
},
|
||||
): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/complete", input)).data;
|
||||
}
|
||||
|
||||
export async function reloginLink(token: string): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/relogin", { token })).data;
|
||||
}
|
||||
|
||||
export async function unlinkServer(keepData: boolean): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/unlink", { keepData })).data;
|
||||
}
|
||||
|
||||
export async function syncNow(): Promise<SyncStatus> {
|
||||
return (await authApi.post("/sync/link/sync", {}, { timeout: 300_000 })).data;
|
||||
}
|
||||
|
||||
export async function updateSyncSettings(settings: {
|
||||
disabledTypes?: string[];
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
}): Promise<SyncStatus> {
|
||||
return (await authApi.put("/sync/link/settings", settings)).data;
|
||||
}
|
||||
|
||||
export async function getSyncConflicts(): Promise<SyncConflictItem[]> {
|
||||
return (await authApi.get("/sync/link/conflicts")).data?.conflicts ?? [];
|
||||
}
|
||||
|
||||
export async function settleSyncConflict(
|
||||
id: number,
|
||||
keep: "mine" | "server",
|
||||
): Promise<void> {
|
||||
await authApi.post(`/sync/link/conflicts/${id}`, { keep });
|
||||
}
|
||||
|
||||
export async function getSyncErrors(): Promise<SyncErrorItem[]> {
|
||||
return (await authApi.get("/sync/link/errors")).data?.errors ?? [];
|
||||
}
|
||||
|
||||
export async function retrySyncErrors(): Promise<SyncStatus> {
|
||||
return (
|
||||
await authApi.post("/sync/link/errors/retry", {}, { timeout: 300_000 })
|
||||
).data;
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
|
||||
interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
onAuthSuccess: (token: string | null) => void | Promise<void>;
|
||||
onChangeServer: () => void;
|
||||
// "local" (default): the app's own login, the token goes to localStorage
|
||||
// like every other client. "link": signing in to a server to link this
|
||||
// desktop to it; the token is only handed to onAuthSuccess, which trades
|
||||
// it for the desktop's own session.
|
||||
targetPurpose?: "local" | "link";
|
||||
}
|
||||
|
||||
const AUTH_MESSAGE_SOURCES = new Set([
|
||||
"auth_component",
|
||||
"second_factor_auth_component",
|
||||
"method_auth_component",
|
||||
"oidc_callback",
|
||||
]);
|
||||
|
||||
export function ElectronLoginForm({
|
||||
serverUrl,
|
||||
onAuthSuccess,
|
||||
onChangeServer,
|
||||
targetPurpose = "local",
|
||||
}: ElectronLoginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const isAuthenticatingRef = useRef(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const hasAuthenticatedRef = useRef(false);
|
||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const onAuthSuccessRef = useRef(onAuthSuccess);
|
||||
useEffect(() => {
|
||||
onAuthSuccessRef.current = onAuthSuccess;
|
||||
}, [onAuthSuccess]);
|
||||
|
||||
const handleAuthSuccess = useCallback(
|
||||
async (token: string | null) => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
hasAuthenticatedRef.current = true;
|
||||
isAuthenticatingRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
if (targetPurpose === "link") {
|
||||
if (!token) {
|
||||
throw new Error(t("errors.authTokenMissing"));
|
||||
}
|
||||
} else if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
await onAuthSuccessRef.current(token);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("errors.authTokenSaveFailed"),
|
||||
);
|
||||
isAuthenticatingRef.current = false;
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
},
|
||||
[t, targetPurpose],
|
||||
);
|
||||
|
||||
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
||||
// Only the server's own origin is trusted: the iframe can navigate away
|
||||
// (identity provider, redirects), and those pages must not hand us a token,
|
||||
// start a system browser login, or receive the replies.
|
||||
useEffect(() => {
|
||||
let serverOrigin: string;
|
||||
try {
|
||||
serverOrigin = new URL(serverUrl).origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
try {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (event.origin !== serverOrigin) return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
const {
|
||||
type,
|
||||
platform,
|
||||
source,
|
||||
token,
|
||||
authUrl,
|
||||
callbackPort,
|
||||
providerId,
|
||||
} = event.data;
|
||||
|
||||
if (
|
||||
type === "AUTH_SUCCESS" &&
|
||||
platform === "desktop" &&
|
||||
AUTH_MESSAGE_SOURCES.has(source)
|
||||
) {
|
||||
await handleAuthSuccess(token ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
// OIDC login requested from inside the iframe, open the system browser
|
||||
// so captcha stages (e.g. Cloudflare Turnstile) render correctly.
|
||||
if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) {
|
||||
const sendResultToIframe = (result: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
secondFactor?: boolean;
|
||||
tempToken?: string;
|
||||
factors?: string;
|
||||
}) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
|
||||
source: "oidc_system_browser_auth",
|
||||
providerId,
|
||||
...result,
|
||||
},
|
||||
serverOrigin,
|
||||
);
|
||||
};
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
externalBrowserLogin?: (
|
||||
url: string,
|
||||
port: number,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
secondFactor?: boolean;
|
||||
tempToken?: string;
|
||||
factors?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).electronAPI;
|
||||
if (!electronAPI?.externalBrowserLogin) {
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: t("errors.failedExternalLogin"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await electronAPI.externalBrowserLogin(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
if (result.success && result.token) {
|
||||
await handleAuthSuccess(result.token);
|
||||
return;
|
||||
}
|
||||
// The login stopped for a second factor; the iframe asks for it.
|
||||
if (result.secondFactor) {
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
secondFactor: true,
|
||||
tempToken: result.tempToken,
|
||||
factors: result.factors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendResultToIframe({
|
||||
success: false,
|
||||
error: result.error || t("errors.failedExternalLogin"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const providerId =
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
typeof event.data.providerId === "number"
|
||||
? event.data.providerId
|
||||
: undefined;
|
||||
const error = getErrorMessage(err, t("errors.failedExternalLogin"));
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
|
||||
source: "oidc_system_browser_auth",
|
||||
providerId,
|
||||
success: false,
|
||||
error,
|
||||
},
|
||||
serverOrigin,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [handleAuthSuccess, serverUrl, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoading(false);
|
||||
hasLoadedOnce.current = true;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (iframe.contentWindow) {
|
||||
setCurrentUrl(iframe.contentWindow.location.href);
|
||||
}
|
||||
} catch {
|
||||
setCurrentUrl(serverUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setLoading(false);
|
||||
if (hasLoadedOnce.current) {
|
||||
setError(t("errors.failedToLoadServer"));
|
||||
}
|
||||
};
|
||||
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
iframe.addEventListener("error", handleError);
|
||||
return () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
iframe.removeEventListener("error", handleError);
|
||||
};
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = serverUrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-background flex flex-col">
|
||||
{isAuthenticating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthenticating && (
|
||||
<div className="flex items-center justify-between p-4 pr-12 bg-background border-b border-border">
|
||||
<button
|
||||
onClick={onChangeServer}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-base font-medium">
|
||||
{t("sync.wizard.back")}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isAuthenticating && (
|
||||
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !isAuthenticating && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-background z-40"
|
||||
style={{ marginTop: "60px" }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">
|
||||
{t("auth.loadingServer")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ visibility: isAuthenticating ? "hidden" : "visible" }}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={serverUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={t("auth.serverAuthentication")}
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-modals allow-downloads"
|
||||
allow="clipboard-read; clipboard-write; publickey-credentials-get; publickey-credentials-create"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { getSyncStatusSnapshot, subscribeSyncStatus } from "@/lib/sync-status";
|
||||
|
||||
/** The desktop's sync status, kept current while the component is mounted. */
|
||||
export function useSyncStatus() {
|
||||
return useSyncExternalStore(
|
||||
subscribeSyncStatus,
|
||||
getSyncStatusSnapshot,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
/** What needs the user's attention in Sync, for the rail badge. */
|
||||
export function useSyncAttentionCount(): number | null {
|
||||
const status = useSyncStatus();
|
||||
if (!status?.linked) return null;
|
||||
const count =
|
||||
(status.conflicts ?? 0) +
|
||||
(status.errors ?? 0) +
|
||||
(status.status === "signed_out" ? 1 : 0);
|
||||
return count || null;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getSyncStatus, type SyncStatus } from "@/api/sync-api";
|
||||
import { notifySyncChanged } from "@/lib/linked-server";
|
||||
|
||||
/**
|
||||
* The desktop's sync status, polled from the embedded backend while anything
|
||||
* shows it. When a pass finishes, the views that show synced data are told
|
||||
* to reload.
|
||||
*/
|
||||
|
||||
const IDLE_MS = 15_000;
|
||||
const BUSY_MS = 2_000;
|
||||
|
||||
let snapshot: SyncStatus | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastSyncAt: string | null | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function announce(): void {
|
||||
notifySyncChanged();
|
||||
window.dispatchEvent(new CustomEvent("hosts:refresh"));
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
window.dispatchEvent(new CustomEvent("termix:plugins-changed"));
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
timer = null;
|
||||
try {
|
||||
const next = await getSyncStatus();
|
||||
const syncedAt = next.lastSyncAt ?? null;
|
||||
if (lastSyncAt !== undefined && syncedAt !== lastSyncAt) announce();
|
||||
if (snapshot && snapshot.linked !== next.linked) notifySyncChanged();
|
||||
lastSyncAt = syncedAt;
|
||||
snapshot = next;
|
||||
emit();
|
||||
} catch {
|
||||
// The backend may still be starting; the next poll tries again.
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
function schedule(delay?: number): void {
|
||||
if (listeners.size === 0 || timer) return;
|
||||
const busy = snapshot?.status === "syncing" || !!snapshot?.pending;
|
||||
timer = setTimeout(() => void poll(), delay ?? (busy ? BUSY_MS : IDLE_MS));
|
||||
}
|
||||
|
||||
export function subscribeSyncStatus(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (isElectron() && listeners.size === 1 && !timer) void poll();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0 && timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getSyncStatusSnapshot(): SyncStatus | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Takes a status a request already returned, and checks again soon. */
|
||||
export function setSyncStatus(next: SyncStatus): void {
|
||||
snapshot = next;
|
||||
if (lastSyncAt !== undefined && (next.lastSyncAt ?? null) !== lastSyncAt) {
|
||||
announce();
|
||||
}
|
||||
lastSyncAt = next.lastSyncAt ?? null;
|
||||
emit();
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
schedule(BUSY_MS);
|
||||
}
|
||||
|
||||
export function refreshSyncStatus(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
void poll();
|
||||
}
|
||||
@@ -215,13 +215,6 @@
|
||||
"pluginsLoadFailed": "The plugin list could not be loaded. You can choose plugins later from the Plugins tab.",
|
||||
"securityTitle": "Secure your account",
|
||||
"securityIntro": "Add a passkey or an authenticator app to your account. You can also do this later in Settings, under Security.",
|
||||
"desktopTitle": "How do you want to use Termix?",
|
||||
"desktopBody": "You can change this any time from Sync in the sidebar.",
|
||||
"desktopLocalTitle": "On this device only",
|
||||
"desktopLocalBody": "Everything is stored here. Nothing to set up.",
|
||||
"desktopLinkTitle": "With my Termix server",
|
||||
"desktopLinkBody": "Sign in to your server to use the same hosts and settings on every device.",
|
||||
"desktopLinked": "Linked to {{server}}",
|
||||
"pluginsDocsLink": "Read about each one"
|
||||
},
|
||||
"desktopUpdates": {
|
||||
@@ -252,7 +245,6 @@
|
||||
"hosts": "Hosts",
|
||||
"credentials": "Credentials",
|
||||
"connections": "Connections",
|
||||
"sync": "Sync",
|
||||
"reconnectDisconnectedTerminals": "Reconnect all disconnected terminals",
|
||||
"reconnectingTerminals_one": "Reconnecting {{count}} terminal",
|
||||
"reconnectingTerminals_other": "Reconnecting {{count}} terminals",
|
||||
@@ -1623,87 +1615,6 @@
|
||||
"noData": "No data",
|
||||
"filter": "Filter..."
|
||||
},
|
||||
"sync": {
|
||||
"minutesAgo_one": "{{count}} minute ago",
|
||||
"minutesAgo_other": "{{count}} minutes ago",
|
||||
"hoursAgo_one": "{{count}} hour ago",
|
||||
"hoursAgo_other": "{{count}} hours ago",
|
||||
"pending_one": "{{count}} change waiting to sync",
|
||||
"pending_other": "{{count}} changes waiting to sync",
|
||||
"proxy": {
|
||||
"headersTitle": "Extra headers",
|
||||
"headersHint": "Sent with every request to the server, for example a Cloudflare Access service token.",
|
||||
"headerName": "Header name",
|
||||
"headerValue": "Value",
|
||||
"savedValue": "Saved, type to change",
|
||||
"addHeader": "Add header",
|
||||
"removeHeader": "Remove header",
|
||||
"basicAuthTitle": "Username and password",
|
||||
"basicAuthHint": "For a proxy that asks for a username and password before Termix.",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"allowInvalidCertificate": "Trust a self-signed certificate",
|
||||
"allowInvalidCertificateHint": "Only for your own server, when its certificate is not from a known authority."
|
||||
},
|
||||
"problems": {
|
||||
"invalidUrl": "That does not look like a web address.",
|
||||
"unreachable": "Could not reach the server. Check the address and that the server is running.",
|
||||
"unreachableWithReason": "Could not reach the server: {{reason}}",
|
||||
"tlsUntrusted": "The server certificate is not trusted. If this is your own server with a self-signed certificate, turn on the option below.",
|
||||
"basicAuthRequired": "Something in front of the server asks for a username and password. Enter them below.",
|
||||
"proxyLogin": "A login page or proxy answered instead of Termix. Add the headers it needs below, or check the address.",
|
||||
"notTermix": "There is no Termix server at that address. If it runs under a path, include it, like https://example.com/termix.",
|
||||
"versionMismatch": "That server runs an older Termix. Update it to 2.9 or newer to link.",
|
||||
"differentAccount": "You signed in to a different account. Sign in to the account this device is linked to, or unlink first."
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Link to a Termix server",
|
||||
"reloginTitle": "Sign in again",
|
||||
"savedServers": "Recent servers",
|
||||
"removeServer": "Remove from the list",
|
||||
"serverDescription": "Enter the address you use to open Termix in your browser.",
|
||||
"signinDescription": "Sign in to {{url}}",
|
||||
"chooseDescription": "This device already has data. What should happen to it?",
|
||||
"finishingDescription": "Linking this device.",
|
||||
"serverUrl": "Server address",
|
||||
"serverUrlHint": "For example https://termix.example.com",
|
||||
"showProxySettings": "Reverse proxy settings",
|
||||
"hideProxySettings": "Hide reverse proxy settings",
|
||||
"continue": "Continue",
|
||||
"back": "Back",
|
||||
"mergeTitle": "Add it to my account",
|
||||
"mergeBody_one": "The item on this device is added to your account. Items your account already has stay as they are on the server.",
|
||||
"mergeBody_other": "The {{count}} items on this device are added to your account. Items your account already has stay as they are on the server.",
|
||||
"replaceTitle": "Replace it with my account",
|
||||
"replaceBody_one": "The item on this device is removed, and this device takes everything from your account. Hosts kept on this device only stay.",
|
||||
"replaceBody_other": "The {{count}} items on this device are removed, and this device takes everything from your account. Hosts kept on this device only stay.",
|
||||
"link": "Link",
|
||||
"linking": "Linking and running the first sync...",
|
||||
"linked": "Linked. Your data is syncing now.",
|
||||
"signedInAgain": "Signed in. Sync is running again."
|
||||
},
|
||||
"entities": {
|
||||
"hosts": "Hosts",
|
||||
"sshCredentials": "Credentials",
|
||||
"sshFolders": "Folders",
|
||||
"userPreferences": "Preferences",
|
||||
"pluginUserSettings": "Plugin settings",
|
||||
"sharedHosts": "Hosts shared with you",
|
||||
"sharedCredentials": "Credentials shared with you",
|
||||
"vaultProfiles": "Vault profiles",
|
||||
"snippetFolders": "Snippet folders",
|
||||
"snippets": "Snippets",
|
||||
"dashboardServiceLinks": "Service links",
|
||||
"homepageItems": "Homepage items",
|
||||
"networkTopology": "Network map",
|
||||
"workspaces": "Workspaces",
|
||||
"hostDefaults": "Host defaults",
|
||||
"hostDefaultsAdmin": "Server host defaults",
|
||||
"aiProviders": "AI providers",
|
||||
"homepageLayouts": "Homepage layouts",
|
||||
"accountProfile": "Account profile"
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Folder {{name}}",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Monitor, MonitorDown, Puzzle, Shield } from "lucide-react";
|
||||
import { MonitorDown, Puzzle, Shield } from "lucide-react";
|
||||
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
|
||||
import { PluginsStep } from "./steps/PluginsStep";
|
||||
import { SecurityStep } from "./steps/SecurityStep";
|
||||
import { DesktopSyncStep } from "./steps/DesktopSyncStep";
|
||||
import { DesktopUpdatesStep } from "./steps/DesktopUpdatesStep";
|
||||
import { desktopUpdates } from "@/lib/desktop-updates";
|
||||
import type { OnboardingStepDef } from "./onboarding-registry";
|
||||
@@ -28,16 +27,6 @@ export const CORE_STEPS: OnboardingStepDef[] = [
|
||||
isRelevant: (ctx) => !ctx.pluginsManagedElsewhere && ctx.pluginSetupPending,
|
||||
forcePending: (ctx) => ctx.pluginSetupPending,
|
||||
},
|
||||
{
|
||||
id: "desktop-sync",
|
||||
version: CORE_ONBOARDING_STEPS["desktop-sync"],
|
||||
titleKey: "onboarding.desktopTitle",
|
||||
icon: Monitor,
|
||||
Component: DesktopSyncStep,
|
||||
audience: "all",
|
||||
position: 20,
|
||||
isRelevant: (ctx) => ctx.isDesktop,
|
||||
},
|
||||
{
|
||||
id: "desktop-updates",
|
||||
version: CORE_ONBOARDING_STEPS["desktop-updates"],
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cloud, Laptop } from "lucide-react";
|
||||
import { LinkServerDialog } from "@/settings/sync/LinkServerDialog";
|
||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import { setSyncStatus } from "@/lib/sync-status";
|
||||
import { notifySyncChanged } from "@/lib/linked-server";
|
||||
|
||||
/**
|
||||
* Desktop only: use Termix on this device alone, or link it to a server.
|
||||
* Either choice can be changed later from Sync.
|
||||
*/
|
||||
export function DesktopSyncStep() {
|
||||
const { t } = useTranslation();
|
||||
const status = useSyncStatus();
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const linked = !!status?.linked;
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: "local",
|
||||
icon: Laptop,
|
||||
title: t("onboarding.desktopLocalTitle"),
|
||||
body: t("onboarding.desktopLocalBody"),
|
||||
selected: !linked,
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: "link",
|
||||
icon: Cloud,
|
||||
title: t("onboarding.desktopLinkTitle"),
|
||||
body: linked
|
||||
? t("onboarding.desktopLinked", {
|
||||
server: status?.serverName || status?.serverUrl,
|
||||
})
|
||||
: t("onboarding.desktopLinkBody"),
|
||||
selected: linked,
|
||||
onClick: () => !linked && setLinkOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.desktopBody")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{options.map(({ id, icon: Icon, title, body, selected, onClick }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-start gap-2.5 border p-2.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border bg-card hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">{title}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{body}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<LinkServerDialog
|
||||
open={linkOpen}
|
||||
onOpenChange={setLinkOpen}
|
||||
onLinked={(next) => {
|
||||
setSyncStatus(next);
|
||||
notifySyncChanged();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -491,21 +491,6 @@ export {
|
||||
type ShareTarget,
|
||||
} from "@/api/rbac-api";
|
||||
export { type BrandingSettings, getBranding } from "@/api/settings-api";
|
||||
export {
|
||||
getSyncConflicts,
|
||||
getSyncErrors,
|
||||
retrySyncErrors,
|
||||
settleSyncConflict,
|
||||
type SyncBasicAuth,
|
||||
type SyncConflictItem,
|
||||
type SyncEntityInfo,
|
||||
type SyncErrorItem,
|
||||
syncNow,
|
||||
type SyncProxyHeader,
|
||||
type SyncStatus,
|
||||
unlinkServer,
|
||||
updateSyncSettings,
|
||||
} from "@/api/sync-api";
|
||||
export {
|
||||
getChannelReleases,
|
||||
getUpdateChannel,
|
||||
@@ -553,7 +538,6 @@ export {
|
||||
getStatusClasses,
|
||||
useStatusColorScheme,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
export { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
export { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
export { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
export { LANGUAGES } from "@/i18n/languages";
|
||||
@@ -603,7 +587,6 @@ export { getConnectedRemoteApi } from "@/lib/remote-server-api";
|
||||
export { roleLabel } from "@/lib/role-label";
|
||||
export type { StatusValue } from "@/lib/server-status-store";
|
||||
export { parseServerTime } from "@/lib/server-time";
|
||||
export { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
|
||||
export { tabJumpHotkeyKeys } from "@/lib/tab-jump-hotkey";
|
||||
export {
|
||||
ACCENT_PRESET_COLORS,
|
||||
@@ -740,8 +723,6 @@ export {
|
||||
hasVisibleFields,
|
||||
isFieldShown,
|
||||
} from "@/settings/settings-fields-util";
|
||||
export { LinkServerDialog } from "@/settings/sync/LinkServerDialog";
|
||||
export { ProxySettingsFields } from "@/settings/sync/ProxySettingsFields";
|
||||
export { useShellController } from "@/shell/controller/ShellControllerProvider";
|
||||
export {
|
||||
type KeybindingActionDef,
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { toast } from "sonner";
|
||||
import { AlertCircle, Loader2, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||
import {
|
||||
completeLink,
|
||||
getLinkPreview,
|
||||
probeServer,
|
||||
reloginLink,
|
||||
type ProbeProblem,
|
||||
type ServerTarget,
|
||||
type SyncStatus,
|
||||
} from "@/api/sync-api";
|
||||
import { ProxySettingsFields } from "./ProxySettingsFields";
|
||||
import {
|
||||
forgetServerUrl,
|
||||
getSavedServerUrls,
|
||||
rememberServerUrl,
|
||||
} from "./saved-servers";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
type Step = "server" | "signin" | "choose" | "finishing";
|
||||
|
||||
interface LinkServerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLinked: (status: SyncStatus) => void;
|
||||
/** Sign back in to the server this desktop is already linked to. */
|
||||
relogin?: { serverUrl: string };
|
||||
}
|
||||
|
||||
function problemText(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
problem: ProbeProblem | undefined,
|
||||
message: string | undefined,
|
||||
): string {
|
||||
switch (problem) {
|
||||
case "invalid_url":
|
||||
return t("sync.problems.invalidUrl");
|
||||
case "tls_untrusted":
|
||||
return t("sync.problems.tlsUntrusted");
|
||||
case "basic_auth_required":
|
||||
return t("sync.problems.basicAuthRequired");
|
||||
case "proxy_login":
|
||||
return t("sync.problems.proxyLogin");
|
||||
case "not_termix":
|
||||
return t("sync.problems.notTermix");
|
||||
case "version_mismatch":
|
||||
return t("sync.problems.versionMismatch");
|
||||
case "different_account":
|
||||
return t("sync.problems.differentAccount");
|
||||
default:
|
||||
return message
|
||||
? t("sync.problems.unreachableWithReason", { reason: message })
|
||||
: t("sync.problems.unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkServerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onLinked,
|
||||
relogin,
|
||||
}: LinkServerDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>(relogin ? "signin" : "server");
|
||||
const [savedUrls, setSavedUrls] = useState<string[]>(getSavedServerUrls);
|
||||
const [target, setTarget] = useState<ServerTarget>({
|
||||
serverUrl: relogin?.serverUrl ?? "",
|
||||
customHeaders: [],
|
||||
basicAuth: null,
|
||||
allowInvalidCertificate: false,
|
||||
});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
const [problemKind, setProblemKind] = useState<ProbeProblem | null>(null);
|
||||
const [serverName, setServerName] = useState<string | undefined>();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [mode, setMode] = useState<"merge" | "replace">("merge");
|
||||
const [finishError, setFinishError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStep(relogin ? "signin" : "server");
|
||||
setProblem(null);
|
||||
setProblemKind(null);
|
||||
setToken(null);
|
||||
setFinishError(null);
|
||||
setMode("merge");
|
||||
if (relogin) {
|
||||
setTarget((current) => ({ ...current, serverUrl: relogin.serverUrl }));
|
||||
return;
|
||||
}
|
||||
// An older version of the app may have saved a server address.
|
||||
window.electronAPI
|
||||
?.invoke?.("get-previous-server-url")
|
||||
.then((result) => {
|
||||
const url = (result as { serverUrl?: string | null } | null)?.serverUrl;
|
||||
if (url) {
|
||||
setTarget((current) =>
|
||||
current.serverUrl ? current : { ...current, serverUrl: url },
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [open, relogin]);
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true);
|
||||
setProblem(null);
|
||||
setProblemKind(null);
|
||||
try {
|
||||
const result = await probeServer(target);
|
||||
if (!result.ok) {
|
||||
setProblemKind(result.problem ?? null);
|
||||
setProblem(problemText(t, result.problem, result.message));
|
||||
if (
|
||||
result.problem === "basic_auth_required" ||
|
||||
result.problem === "proxy_login"
|
||||
) {
|
||||
setShowAdvanced(true);
|
||||
}
|
||||
if (result.problem === "basic_auth_required" && !target.basicAuth) {
|
||||
setTarget((current) => ({
|
||||
...current,
|
||||
basicAuth: { username: "", password: "" },
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setTarget((current) => ({
|
||||
...current,
|
||||
serverUrl: result.serverUrl ?? current.serverUrl,
|
||||
}));
|
||||
setServerName(result.name);
|
||||
setStep("signin");
|
||||
} catch {
|
||||
setProblem(problemText(t, "unreachable", undefined));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const finish = async (sessionToken: string, chosen: "merge" | "replace") => {
|
||||
setStep("finishing");
|
||||
setFinishError(null);
|
||||
try {
|
||||
const status = await completeLink({
|
||||
...target,
|
||||
token: sessionToken,
|
||||
mode: chosen,
|
||||
serverName,
|
||||
});
|
||||
rememberServerUrl(target.serverUrl);
|
||||
toast.success(t("sync.wizard.linked"));
|
||||
onLinked(status);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
const data = (
|
||||
error as {
|
||||
response?: { data?: { problem?: ProbeProblem; message?: string } };
|
||||
}
|
||||
)?.response?.data;
|
||||
setFinishError(problemText(t, data?.problem, data?.message));
|
||||
setStep("choose");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignedIn = async (sessionToken: string | null) => {
|
||||
if (!sessionToken) return;
|
||||
if (relogin) {
|
||||
try {
|
||||
const status = await reloginLink(sessionToken);
|
||||
toast.success(t("sync.wizard.signedInAgain"));
|
||||
onLinked(status);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
const data = (
|
||||
error as {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
problem?: ProbeProblem;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
)?.response?.data;
|
||||
throw new Error(
|
||||
data?.error === "different_account"
|
||||
? t("sync.problems.differentAccount")
|
||||
: problemText(t, data?.problem, data?.message),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setToken(sessionToken);
|
||||
const preview = await getLinkPreview().catch(() => ({}));
|
||||
setCounts(preview);
|
||||
const total = Object.values(preview).reduce((sum, count) => sum + count, 0);
|
||||
if (total === 0) {
|
||||
await finish(sessionToken, "merge");
|
||||
return;
|
||||
}
|
||||
setStep("choose");
|
||||
};
|
||||
|
||||
const localTotal = Object.values(counts).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
const wide = step === "signin";
|
||||
|
||||
const title = relogin
|
||||
? t("sync.wizard.reloginTitle")
|
||||
: t("sync.wizard.title");
|
||||
const description =
|
||||
step === "server"
|
||||
? t("sync.wizard.serverDescription")
|
||||
: step === "signin"
|
||||
? t("sync.wizard.signinDescription", { url: target.serverUrl })
|
||||
: step === "choose"
|
||||
? t("sync.wizard.chooseDescription")
|
||||
: t("sync.wizard.finishingDescription");
|
||||
const content = (
|
||||
<>
|
||||
{step === "server" && (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleCheck();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold" htmlFor="sync-server-url">
|
||||
{t("sync.wizard.serverUrl")}
|
||||
</label>
|
||||
<Input
|
||||
id="sync-server-url"
|
||||
autoFocus
|
||||
className="rounded-none"
|
||||
placeholder="https://termix.example.com"
|
||||
value={target.serverUrl}
|
||||
onChange={(event) =>
|
||||
setTarget({ ...target, serverUrl: event.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("sync.wizard.serverUrlHint")}
|
||||
</p>
|
||||
{!relogin && savedUrls.length > 0 && (
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("sync.wizard.savedServers")}
|
||||
</span>
|
||||
{savedUrls.map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center border border-border hover:bg-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 truncate px-2 py-1.5 text-left font-mono text-xs"
|
||||
onClick={() => setTarget({ ...target, serverUrl: url })}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("sync.wizard.removeServer")}
|
||||
className="px-2 py-1.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setSavedUrls(forgetServerUrl(url))}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{problem && (
|
||||
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
|
||||
<AlertCircle className="size-4 shrink-0 text-destructive" />
|
||||
<span>{problem}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(problemKind === "tls_untrusted" ||
|
||||
target.allowInvalidCertificate) && (
|
||||
<div className="flex items-center justify-between gap-3 border border-border p-2.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("sync.proxy.allowInvalidCertificate")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sync.proxy.allowInvalidCertificateHint")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!target.allowInvalidCertificate}
|
||||
onCheckedChange={(checked) =>
|
||||
setTarget({ ...target, allowInvalidCertificate: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-start text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAdvanced((value) => !value)}
|
||||
>
|
||||
{showAdvanced
|
||||
? t("sync.wizard.hideProxySettings")
|
||||
: t("sync.wizard.showProxySettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<ProxySettingsFields
|
||||
customHeaders={target.customHeaders ?? []}
|
||||
basicAuth={target.basicAuth ?? null}
|
||||
onChange={(next) => setTarget({ ...target, ...next })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="submit"
|
||||
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={checking || !target.serverUrl.trim()}
|
||||
>
|
||||
{checking && <Loader2 className="size-4 animate-spin" />}
|
||||
{t("sync.wizard.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "signin" && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<ElectronLoginForm
|
||||
serverUrl={target.serverUrl}
|
||||
targetPurpose="link"
|
||||
onAuthSuccess={handleSignedIn}
|
||||
onChangeServer={() =>
|
||||
relogin ? onOpenChange(false) : setStep("server")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "choose" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{finishError && (
|
||||
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
|
||||
<AlertCircle className="size-4 shrink-0 text-destructive" />
|
||||
<span>{finishError}</span>
|
||||
</div>
|
||||
)}
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: "merge",
|
||||
title: t("sync.wizard.mergeTitle"),
|
||||
body: t("sync.wizard.mergeBody", { count: localTotal }),
|
||||
},
|
||||
{
|
||||
value: "replace",
|
||||
title: t("sync.wizard.replaceTitle"),
|
||||
body: t("sync.wizard.replaceBody", { count: localTotal }),
|
||||
},
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setMode(option.value)}
|
||||
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
|
||||
mode === option.value
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-semibold">{option.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.body}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={!token}
|
||||
onClick={() => token && void finish(token, mode)}
|
||||
>
|
||||
{t("sync.wizard.link")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "finishing" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin text-accent-brand" />
|
||||
{t("sync.wizard.linking")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// The wizard takes over the panel, tab or onboarding step it opens from.
|
||||
return (
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
width="wide"
|
||||
scroll={!wide}
|
||||
>
|
||||
{!wide && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{description} {step === "server" && <DocsLink core="desktopSync" />}
|
||||
</p>
|
||||
)}
|
||||
{content}
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type { SyncBasicAuth, SyncProxyHeader } from "@/api/sync-api";
|
||||
|
||||
interface ProxySettingsFieldsProps {
|
||||
customHeaders: SyncProxyHeader[];
|
||||
basicAuth: SyncBasicAuth | null;
|
||||
onChange: (next: {
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a reverse proxy in front of the server wants: extra headers (a
|
||||
* Cloudflare Access service token, a gateway key) and basic auth.
|
||||
*/
|
||||
export function ProxySettingsFields({
|
||||
customHeaders,
|
||||
basicAuth,
|
||||
onChange,
|
||||
}: ProxySettingsFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setHeader = (index: number, patch: Partial<SyncProxyHeader>) => {
|
||||
onChange({
|
||||
customHeaders: customHeaders.map((header, i) =>
|
||||
i === index ? { ...header, ...patch } : header,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border border-border p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold">
|
||||
{t("sync.proxy.headersTitle")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sync.proxy.headersHint")}
|
||||
</span>
|
||||
</div>
|
||||
{customHeaders.map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="rounded-none h-8 text-xs"
|
||||
placeholder={t("sync.proxy.headerName")}
|
||||
value={header.name}
|
||||
onChange={(event) =>
|
||||
setHeader(index, { name: event.target.value })
|
||||
}
|
||||
/>
|
||||
<PasswordInput
|
||||
className="rounded-none h-8 text-xs"
|
||||
placeholder={
|
||||
header.set
|
||||
? t("sync.proxy.savedValue")
|
||||
: t("sync.proxy.headerValue")
|
||||
}
|
||||
value={header.value}
|
||||
onChange={(event) =>
|
||||
setHeader(index, { value: event.target.value })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("sync.proxy.removeHeader")}
|
||||
className="p-1.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
customHeaders: customHeaders.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
customHeaders: [...customHeaders, { name: "", value: "" }],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("sync.proxy.addHeader")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold">
|
||||
{t("sync.proxy.basicAuthTitle")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("sync.proxy.basicAuthHint")}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!basicAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
basicAuth: checked ? { username: "", password: "" } : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{basicAuth && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="rounded-none h-8 text-xs"
|
||||
placeholder={t("sync.proxy.username")}
|
||||
value={basicAuth.username}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
basicAuth: { ...basicAuth, username: event.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PasswordInput
|
||||
className="rounded-none h-8 text-xs"
|
||||
placeholder={
|
||||
basicAuth.set
|
||||
? t("sync.proxy.savedValue")
|
||||
: t("sync.proxy.password")
|
||||
}
|
||||
value={basicAuth.password}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
basicAuth: { ...basicAuth, password: event.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Same key as 2.8, so a desktop keeps the servers it already used.
|
||||
const SAVED_URLS_KEY = "termix_saved_server_urls";
|
||||
const MAX_SAVED_URLS = 5;
|
||||
|
||||
export function getSavedServerUrls(): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(SAVED_URLS_KEY) ?? "[]");
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((url): url is string => typeof url === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(urls: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
|
||||
} catch {
|
||||
// Storage can be unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberServerUrl(url: string): string[] {
|
||||
const trimmed = url.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) return getSavedServerUrls();
|
||||
const urls = [
|
||||
trimmed,
|
||||
...getSavedServerUrls().filter((saved) => saved !== trimmed),
|
||||
].slice(0, MAX_SAVED_URLS);
|
||||
write(urls);
|
||||
return urls;
|
||||
}
|
||||
|
||||
export function forgetServerUrl(url: string): string[] {
|
||||
const urls = getSavedServerUrls().filter((saved) => saved !== url);
|
||||
write(urls);
|
||||
return urls;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import {
|
||||
useConfirm,
|
||||
@@ -128,10 +127,6 @@ export function ShellControllerProvider({
|
||||
openSingletonRef.current = tabsState.openSingletonTab;
|
||||
const { tabs, activeTabId } = tabsState;
|
||||
|
||||
// Keeps the desktop's sync status polled while the app is open; a pass
|
||||
// that changed data tells the panels to reload.
|
||||
useSyncStatus();
|
||||
|
||||
const palette: ShellPalette = {
|
||||
isOpen: paletteOpen,
|
||||
open: () => setPaletteOpen(true),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Cloud,
|
||||
KeyRound,
|
||||
Plug,
|
||||
Server,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
import { getPanel } from "@/shell/panel-registry";
|
||||
@@ -111,15 +109,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
mobilePrimary: true,
|
||||
group: "tools",
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
icon: Cloud,
|
||||
labelKey: "nav.sync",
|
||||
electronOnly: true,
|
||||
placement: "footer",
|
||||
group: "system",
|
||||
useBadge: useSyncAttentionCount,
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
icon: Puzzle,
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ElectronLoginForm } from "../../auth/ElectronLoginForm";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
describe("ElectronLoginForm", () => {
|
||||
it("delegates WebAuthn permissions to the embedded login page", () => {
|
||||
render(
|
||||
<ElectronLoginForm
|
||||
serverUrl="https://termix.example.com"
|
||||
onAuthSuccess={vi.fn()}
|
||||
onChangeServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const permissions = screen
|
||||
.getByTitle("auth.serverAuthentication")
|
||||
.getAttribute("allow");
|
||||
|
||||
expect(permissions).toContain("publickey-credentials-get");
|
||||
expect(permissions).toContain("publickey-credentials-create");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"auth_component",
|
||||
"second_factor_auth_component",
|
||||
"method_auth_component",
|
||||
])("accepts a login hand-off from %s", async (source) => {
|
||||
const onAuthSuccess = vi.fn();
|
||||
render(
|
||||
<ElectronLoginForm
|
||||
serverUrl="https://termix.example.com"
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
onChangeServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const frame = screen.getByTitle(
|
||||
"auth.serverAuthentication",
|
||||
) as HTMLIFrameElement;
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://termix.example.com",
|
||||
data: {
|
||||
type: "AUTH_SUCCESS",
|
||||
platform: "desktop",
|
||||
source,
|
||||
token: "jwt-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(onAuthSuccess).toHaveBeenCalledWith("jwt-1"));
|
||||
});
|
||||
|
||||
it("ignores messages once the frame has left the server's origin", async () => {
|
||||
const onAuthSuccess = vi.fn();
|
||||
const externalBrowserLogin = vi.fn();
|
||||
(window as unknown as { electronAPI?: unknown }).electronAPI = {
|
||||
externalBrowserLogin,
|
||||
};
|
||||
render(
|
||||
<ElectronLoginForm
|
||||
serverUrl="https://termix.example.com/"
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
onChangeServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const frame = screen.getByTitle(
|
||||
"auth.serverAuthentication",
|
||||
) as HTMLIFrameElement;
|
||||
for (const data of [
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
platform: "desktop",
|
||||
source: "auth_component",
|
||||
token: "attacker-jwt",
|
||||
},
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH",
|
||||
authUrl: "https://evil.example/",
|
||||
callbackPort: 17832,
|
||||
},
|
||||
]) {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://evil.example",
|
||||
data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(onAuthSuccess).not.toHaveBeenCalled();
|
||||
expect(externalBrowserLogin).not.toHaveBeenCalled();
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
it("replies to the server's origin only", async () => {
|
||||
(window as unknown as { electronAPI?: unknown }).electronAPI = {
|
||||
externalBrowserLogin: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
secondFactor: true,
|
||||
tempToken: "temp-1",
|
||||
}),
|
||||
};
|
||||
render(
|
||||
<ElectronLoginForm
|
||||
serverUrl="https://termix.example.com/"
|
||||
onAuthSuccess={vi.fn()}
|
||||
onChangeServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const frame = screen.getByTitle(
|
||||
"auth.serverAuthentication",
|
||||
) as HTMLIFrameElement;
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://termix.example.com",
|
||||
data: {
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH",
|
||||
authUrl: "https://idp.example.com/authorize",
|
||||
callbackPort: 17832,
|
||||
providerId: -1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tempToken: "temp-1" }),
|
||||
"https://termix.example.com",
|
||||
),
|
||||
);
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getSyncStatus = vi.hoisted(() => vi.fn());
|
||||
const notifySyncChanged = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
|
||||
vi.mock("@/api/sync-api", () => ({ getSyncStatus }));
|
||||
vi.mock("@/lib/linked-server", () => ({ notifySyncChanged }));
|
||||
|
||||
const store = await import("@/lib/sync-status");
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("sync status store", () => {
|
||||
let unsubscribe: () => void = () => {};
|
||||
const events: string[] = [];
|
||||
const record = (event: Event) => events.push(event.type);
|
||||
|
||||
beforeEach(() => {
|
||||
events.length = 0;
|
||||
for (const type of ["hosts:refresh", "termix:hosts-changed"]) {
|
||||
window.addEventListener(type, record);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unsubscribe();
|
||||
for (const type of ["hosts:refresh", "termix:hosts-changed"]) {
|
||||
window.removeEventListener(type, record);
|
||||
}
|
||||
});
|
||||
|
||||
it("polls while subscribed and tells views to reload after a pass", async () => {
|
||||
getSyncStatus.mockResolvedValue({
|
||||
linked: true,
|
||||
lastSyncAt: "2026-09-25T10:00:00Z",
|
||||
entities: [],
|
||||
});
|
||||
const listener = vi.fn();
|
||||
unsubscribe = store.subscribeSyncStatus(listener);
|
||||
await flush();
|
||||
expect(store.getSyncStatusSnapshot()?.linked).toBe(true);
|
||||
expect(listener).toHaveBeenCalled();
|
||||
// The first read only sets the baseline.
|
||||
expect(events).toEqual([]);
|
||||
|
||||
store.setSyncStatus({
|
||||
linked: true,
|
||||
lastSyncAt: "2026-09-25T10:05:00Z",
|
||||
entities: [],
|
||||
});
|
||||
expect(events).toEqual(["hosts:refresh", "termix:hosts-changed"]);
|
||||
expect(notifySyncChanged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -83,18 +83,11 @@ describe("CORE_STEPS", () => {
|
||||
expect(CORE_STEPS.find((s) => s.id === "plugins")?.required).toBe(true);
|
||||
});
|
||||
|
||||
it("asks how the desktop app is used only on the desktop", () => {
|
||||
expect(ids(ctx({ isDesktop: true }))).toEqual(["desktop-sync"]);
|
||||
});
|
||||
|
||||
it("asks how the desktop app updates when it has an updater", () => {
|
||||
const win = window as unknown as { electronAPI?: unknown };
|
||||
win.electronAPI = { isElectron: true, updates: {} };
|
||||
try {
|
||||
expect(ids(ctx({ isDesktop: true }))).toEqual([
|
||||
"desktop-sync",
|
||||
"desktop-updates",
|
||||
]);
|
||||
expect(ids(ctx({ isDesktop: true }))).toEqual(["desktop-updates"]);
|
||||
expect(ids(ctx())).not.toContain("desktop-updates");
|
||||
} finally {
|
||||
delete win.electronAPI;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/api/sync-api", () => ({
|
||||
probeServer: vi.fn(),
|
||||
getLinkPreview: vi.fn(),
|
||||
completeLink: vi.fn(),
|
||||
reloginLink: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/auth/ElectronLoginForm", () => ({ ElectronLoginForm: () => null }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const { SurfaceScope } = await import("@/components/surface/surface-scope");
|
||||
const { LinkServerDialog } = await import("@/settings/sync/LinkServerDialog");
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("LinkServerDialog", () => {
|
||||
it("opens inline in the surface instead of a popup", () => {
|
||||
render(
|
||||
<SurfaceScope kind="tab">
|
||||
<LinkServerDialog open onOpenChange={() => {}} onLinked={() => {}} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
expect(screen.getByText("sync.wizard.title")).toBeTruthy();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
forgetServerUrl,
|
||||
getSavedServerUrls,
|
||||
rememberServerUrl,
|
||||
} from "@/settings/sync/saved-servers";
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe("saved server urls", () => {
|
||||
it("keeps the newest five, without duplicates or trailing slashes", () => {
|
||||
for (let i = 1; i <= 6; i++) rememberServerUrl(`https://s${i}.example/`);
|
||||
rememberServerUrl("https://s4.example");
|
||||
expect(getSavedServerUrls()).toEqual([
|
||||
"https://s4.example",
|
||||
"https://s6.example",
|
||||
"https://s5.example",
|
||||
"https://s3.example",
|
||||
"https://s2.example",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forgets a server and survives bad storage", () => {
|
||||
rememberServerUrl("https://a.example");
|
||||
expect(forgetServerUrl("https://a.example")).toEqual([]);
|
||||
localStorage.setItem("termix_saved_server_urls", "{broken");
|
||||
expect(getSavedServerUrls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -15,10 +15,6 @@ const api = vi.hoisted(() => ({
|
||||
createSSHHost: vi.fn(async () => ({})),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => api);
|
||||
vi.mock("@/hooks/use-sync-status", () => ({
|
||||
useSyncStatus: () => undefined,
|
||||
useSyncAttentionCount: () => 0,
|
||||
}));
|
||||
|
||||
import {
|
||||
ShellControllerProvider,
|
||||
|
||||
@@ -15,10 +15,6 @@ vi.mock("@/main-axios", () => ({
|
||||
patchOpenTab: vi.fn(async () => {}),
|
||||
createSSHHost: vi.fn(async () => ({})),
|
||||
}));
|
||||
vi.mock("@/hooks/use-sync-status", () => ({
|
||||
useSyncStatus: () => undefined,
|
||||
useSyncAttentionCount: () => 0,
|
||||
}));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({
|
||||
permissions: ["hosts.view"],
|
||||
|
||||
@@ -14,10 +14,6 @@ vi.mock("@/main-axios", () => ({
|
||||
patchOpenTab: vi.fn(async () => {}),
|
||||
createSSHHost: vi.fn(async () => ({})),
|
||||
}));
|
||||
vi.mock("@/hooks/use-sync-status", () => ({
|
||||
useSyncStatus: () => undefined,
|
||||
useSyncAttentionCount: () => 0,
|
||||
}));
|
||||
|
||||
const lookApi = vi.hoisted(() => ({
|
||||
getLook: vi.fn(),
|
||||
|
||||
@@ -15,10 +15,6 @@ vi.mock("@/main-axios", () => ({
|
||||
patchOpenTab: vi.fn(async () => {}),
|
||||
createSSHHost: vi.fn(async () => ({})),
|
||||
}));
|
||||
vi.mock("@/hooks/use-sync-status", () => ({
|
||||
useSyncStatus: () => undefined,
|
||||
useSyncAttentionCount: () => 0,
|
||||
}));
|
||||
const lookApi = vi.hoisted(() => ({
|
||||
getLook: vi.fn(),
|
||||
saveUserLook: vi.fn(async () => {}),
|
||||
|
||||
@@ -15,10 +15,6 @@ vi.mock("@/main-axios", () => ({
|
||||
patchOpenTab: vi.fn(async () => {}),
|
||||
createSSHHost: vi.fn(async () => ({})),
|
||||
}));
|
||||
vi.mock("@/hooks/use-sync-status", () => ({
|
||||
useSyncStatus: () => undefined,
|
||||
useSyncAttentionCount: () => 0,
|
||||
}));
|
||||
|
||||
import type { TabRenderProps } from "@/shell/tab-registry";
|
||||
import { registerTabType } from "@/shell/tab-registry";
|
||||
|
||||
@@ -58,13 +58,13 @@ describe("RAIL_ITEMS", () => {
|
||||
// "port-forwarding" to the tunnels plugin's, "serial" to the serial
|
||||
// plugin's, "collab" to the session-sharing plugin's, and "session-logs"
|
||||
// to the session-recording plugin's, "termix-id" to the termix-identity
|
||||
// plugin's, "ssh-tools" and "macros" to the ssh-terminal plugin's.
|
||||
// plugin's, "ssh-tools" and "macros" to the ssh-terminal plugin's, and "sync" to
|
||||
// the sync plugin's.
|
||||
expect(RAIL_ITEMS.map((item) => item.id)).toEqual([
|
||||
"hosts",
|
||||
"credentials",
|
||||
"connections",
|
||||
"quick-connect",
|
||||
"sync",
|
||||
"plugins",
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user