diff --git a/CHANGELOG.md b/CHANGELOG.md index 85233ba73..3c3b37c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 64f49bc36..af2fbae0f 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -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. diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index bb2f9414b..501cb2113 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -176,7 +176,6 @@ LANGUAGES (value): readonly { code: string; label: string; }[] Label (value): ({ className, ...props }: LabelProps & RefAttributes) => 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, "title" | "children" | "onClick">) => Element @@ -227,7 +226,6 @@ PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes) => Element PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes) => 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) => 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 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 -getSyncErrors (value): () => Promise getTabType (value): (id: string) => TabTypeDef getTlsStatus (value): () => Promise getUpdateChannel (value): () => Promise @@ -509,7 +499,6 @@ quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef railItemLabel (value): (id: string, t: (key: string) => string) => string readFromClipboard (value): () => Promise 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> resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType resolveRemoteHostId (value): (syncId: string) => Promise -retrySyncErrors (value): () => Promise revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }> revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise revokeFolderAccess (value): (ruleId: number) => Promise @@ -556,21 +544,17 @@ setDeveloperMode (value): (enabled: boolean) => Promise setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }> setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise 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 -settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise 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 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 updateCredential (value): (credentialId: number, credentialData: Record) => Promise> updateExternalAutoProvision (value): (enabled: boolean) => Promise> updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise @@ -588,7 +572,6 @@ updateSSHHost (value): (hostId: number, hostData: HostData) => Promise updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise> updateSessionTimeout (value): (timeoutHours: number) => Promise updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise -updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise 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 diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index a58372a33..c7ecb49c4 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -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", diff --git a/src/backend/tests/sync/entity-labels.test.ts b/src/backend/tests/sync/entity-labels.test.ts deleted file mode 100644 index b59a1f491..000000000 --- a/src/backend/tests/sync/entity-labels.test.ts +++ /dev/null @@ -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([]); - }); -}); diff --git a/src/types/onboarding.ts b/src/types/onboarding.ts index 4c962196d..b7ed985fc 100644 --- a/src/types/onboarding.ts +++ b/src/types/onboarding.ts @@ -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> = Object.freeze({ plugins: 1, - "desktop-sync": 1, preset: 1, appearance: 1, security: 1, diff --git a/src/ui/api/sync-api.ts b/src/ui/api/sync-api.ts deleted file mode 100644 index 66f331433..000000000 --- a/src/ui/api/sync-api.ts +++ /dev/null @@ -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 { - return (await authApi.get("/sync/link/status")).data; -} - -export async function probeServer(target: ServerTarget): Promise { - return (await authApi.post("/sync/link/probe", target)).data; -} - -export async function getLinkPreview(): Promise> { - return (await authApi.get("/sync/link/preview")).data?.counts ?? {}; -} - -export async function completeLink( - input: ServerTarget & { - token: string; - mode: "merge" | "replace"; - serverName?: string; - }, -): Promise { - return (await authApi.post("/sync/link/complete", input)).data; -} - -export async function reloginLink(token: string): Promise { - return (await authApi.post("/sync/link/relogin", { token })).data; -} - -export async function unlinkServer(keepData: boolean): Promise { - return (await authApi.post("/sync/link/unlink", { keepData })).data; -} - -export async function syncNow(): Promise { - 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 { - return (await authApi.put("/sync/link/settings", settings)).data; -} - -export async function getSyncConflicts(): Promise { - return (await authApi.get("/sync/link/conflicts")).data?.conflicts ?? []; -} - -export async function settleSyncConflict( - id: number, - keep: "mine" | "server", -): Promise { - await authApi.post(`/sync/link/conflicts/${id}`, { keep }); -} - -export async function getSyncErrors(): Promise { - return (await authApi.get("/sync/link/errors")).data?.errors ?? []; -} - -export async function retrySyncErrors(): Promise { - return ( - await authApi.post("/sync/link/errors/retry", {}, { timeout: 300_000 }) - ).data; -} diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx deleted file mode 100644 index e53be08a0..000000000 --- a/src/ui/auth/ElectronLoginForm.tsx +++ /dev/null @@ -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; - 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(null); - const [isAuthenticating, setIsAuthenticating] = useState(false); - const isAuthenticatingRef = useRef(false); - const iframeRef = useRef(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 ( -
- {isAuthenticating && ( -
- -
- )} - - {!isAuthenticating && ( -
- -
- - {displayUrl} - -
- -
- )} - - {error && !isAuthenticating && ( -
- - - {t("common.error")} - {error} - -
- )} - - {loading && !isAuthenticating && ( -
-
- - - {t("auth.loadingServer")} - -
-
- )} - -
-