From e4bb4f288c0c4e69e508f38df5a544956f84d0f2 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 28 Feb 2025 13:48:52 +0100 Subject: [PATCH] feat(cloud): Add support for custom cloud app URL configuration (#207) * feat(cloud): Add support for custom cloud app URL configuration - Extend CloudState and Config to include CloudAppURL - Update RPC methods to handle both API and app URLs - Modify cloud adoption and settings routes to support custom app URLs - Remove hardcoded cloud app URL environment file - Simplify cloud URL configuration in UI * fix(cloud): Improve cloud URL configuration and adoption flow - Update error handling in cloud URL configuration RPC method - Modify cloud adoption route to support dynamic cloud URLs - Remove hardcoded default cloud URLs in device access settings - Refactor cloud adoption click handler to be more flexible * refactor(cloud): Simplify cloud URL configuration RPC method - Update rpcSetCloudUrl to return only an error - Remove unnecessary boolean return value - Improve error handling consistency * refactor(ui): Simplify cloud provider configuration and URL handling --- cloud.go | 2 + config.go | 2 + jsonrpc.go | 33 +--- ui/.env.device | 2 - ui/src/components/USBConfigDialog.tsx | 2 +- ui/src/main.tsx | 1 - ui/src/routes/adopt.tsx | 29 +++- .../devices.$id.settings.access._index.tsx | 147 +++++++++--------- ui/src/ui.config.ts | 1 - web.go | 11 ++ 10 files changed, 114 insertions(+), 116 deletions(-) delete mode 100644 ui/.env.device diff --git a/cloud.go b/cloud.go index 5cf00c2..628837b 100644 --- a/cloud.go +++ b/cloud.go @@ -264,12 +264,14 @@ func RunWebsocketClient() { type CloudState struct { Connected bool `json:"connected"` URL string `json:"url,omitempty"` + AppURL string `json:"appUrl,omitempty"` } func rpcGetCloudState() CloudState { return CloudState{ Connected: config.CloudToken != "" && config.CloudURL != "", URL: config.CloudURL, + AppURL: config.CloudAppURL, } } diff --git a/config.go b/config.go index 38a2b5b..f12ab45 100644 --- a/config.go +++ b/config.go @@ -22,6 +22,7 @@ type UsbConfig struct { type Config struct { CloudURL string `json:"cloud_url"` + CloudAppURL string `json:"cloud_app_url"` CloudToken string `json:"cloud_token"` GoogleIdentity string `json:"google_identity"` JigglerEnabled bool `json:"jiggler_enabled"` @@ -43,6 +44,7 @@ const configPath = "/userdata/kvm_config.json" var defaultConfig = &Config{ CloudURL: "https://api.jetkvm.com", + CloudAppURL: "https://app.jetkvm.com", AutoUpdateEnabled: true, // Set a default value ActiveExtension: "", DisplayMaxBrightness: 64, diff --git a/jsonrpc.go b/jsonrpc.go index 9d10cb4..1b87465 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -753,37 +753,17 @@ func rpcSetSerialSettings(settings SerialSettings) error { return nil } -func rpcSetCloudUrl(url string) error { - if url == "" { - // Reset to default by removing from config - config.CloudURL = defaultConfig.CloudURL - } else { - config.CloudURL = url - } +func rpcSetCloudUrl(apiUrl string, appUrl string) error { + config.CloudURL = apiUrl + config.CloudAppURL = appUrl if err := SaveConfig(); err != nil { return fmt.Errorf("failed to save config: %w", err) } + return nil } -func rpcGetCloudUrl() (string, error) { - return config.CloudURL, nil -} - -func rpcResetCloudUrl() error { - // Reset to default by removing from config - config.CloudURL = defaultConfig.CloudURL - if err := SaveConfig(); err != nil { - return fmt.Errorf("failed to reset cloud URL: %w", err) - } - return nil -} - -func rpcGetDefaultCloudUrl() (string, error) { - return defaultConfig.CloudURL, nil -} - var rpcHandlers = map[string]RPCHandler{ "ping": {Func: rpcPing}, "getDeviceID": {Func: rpcGetDeviceID}, @@ -842,8 +822,5 @@ var rpcHandlers = map[string]RPCHandler{ "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, "getSerialSettings": {Func: rpcGetSerialSettings}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, - "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"url"}}, - "getCloudUrl": {Func: rpcGetCloudUrl}, - "resetCloudUrl": {Func: rpcResetCloudUrl}, - "getDefaultCloudUrl": {Func: rpcGetDefaultCloudUrl}, + "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, } diff --git a/ui/.env.device b/ui/.env.device deleted file mode 100644 index c8d7c4f..0000000 --- a/ui/.env.device +++ /dev/null @@ -1,2 +0,0 @@ -# Used in settings page to know where to link to when user wants to adopt a device to the cloud -VITE_CLOUD_APP=http://app.jetkvm.com diff --git a/ui/src/components/USBConfigDialog.tsx b/ui/src/components/USBConfigDialog.tsx index 5ecbb8d..db8b677 100644 --- a/ui/src/components/USBConfigDialog.tsx +++ b/ui/src/components/USBConfigDialog.tsx @@ -3,7 +3,7 @@ import { InputFieldWithLabel } from "./InputField"; import { UsbConfigState } from "@/hooks/stores"; import { useEffect, useCallback, useState } from "react"; import { useJsonRpc } from "../hooks/useJsonRpc"; -import { USBConfig } from "../routes/devices.$id.settings.hardware"; +import { USBConfig } from "./UsbConfigSetting"; export default function UpdateUsbConfigModal({ onSetUsbConfig, diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 0754a48..d20f018 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -177,7 +177,6 @@ if (isOnDevice) { }, ], }, - { path: "/adopt", element: , diff --git a/ui/src/routes/adopt.tsx b/ui/src/routes/adopt.tsx index 2aacf44..8aa7555 100644 --- a/ui/src/routes/adopt.tsx +++ b/ui/src/routes/adopt.tsx @@ -1,6 +1,12 @@ import { LoaderFunctionArgs, redirect } from "react-router-dom"; import api from "../api"; -import { CLOUD_APP, DEVICE_API } from "@/ui.config"; +import { DEVICE_API } from "@/ui.config"; + +export interface CloudState { + connected: boolean; + url: string; + appUrl: string; +} const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); @@ -11,14 +17,21 @@ const loader = async ({ request }: LoaderFunctionArgs) => { const oidcGoogle = searchParams.get("oidcGoogle"); const clientId = searchParams.get("clientId"); - const res = await api.POST(`${DEVICE_API}/cloud/register`, { - token: tempToken, - oidcGoogle, - clientId, - }); + const [cloudStateResponse, registerResponse] = await Promise.all([ + api.GET(`${DEVICE_API}/cloud/state`), + api.POST(`${DEVICE_API}/cloud/register`, { + token: tempToken, + oidcGoogle, + clientId, + }), + ]); - if (!res.ok) throw new Error("Failed to register device"); - return redirect(CLOUD_APP + `/devices/${deviceId}/setup`); + if (!cloudStateResponse.ok) throw new Error("Failed to get cloud state"); + const cloudState = (await cloudStateResponse.json()) as CloudState; + + if (!registerResponse.ok) throw new Error("Failed to register device"); + + return redirect(cloudState.appUrl + `/devices/${deviceId}/setup`); }; export default function AdoptRoute() { diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index 4beae1c..dd5502b 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -2,7 +2,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsItem } from "./devices.$id.settings"; import { useLoaderData, useNavigate } from "react-router-dom"; import { Button, LinkButton } from "../components/Button"; -import { CLOUD_APP, DEVICE_API } from "../ui.config"; +import { DEVICE_API } from "../ui.config"; import api from "../api"; import { LocalDevice } from "./devices.$id"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; @@ -15,6 +15,7 @@ import { InputFieldWithLabel } from "../components/InputField"; import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SettingsSectionHeader } from "../components/SettingsSectionHeader"; import { isOnDevice } from "../main"; +import { CloudState } from "./adopt"; export const loader = async () => { if (isOnDevice) { @@ -36,38 +37,30 @@ export default function SettingsAccessIndexRoute() { const [isAdopted, setAdopted] = useState(false); const [deviceId, setDeviceId] = useState(null); - const [cloudUrl, setCloudUrl] = useState(""); - const [cloudProviders, setCloudProviders] = useState< - { value: string; label: string }[] | null - >([{ value: "https://api.jetkvm.com", label: "JetKVM Cloud" }]); + const [cloudApiUrl, setCloudApiUrl] = useState(""); + const [cloudAppUrl, setCloudAppUrl] = useState(""); - // The default value is just there so it doesn't flicker while we fetch the default Cloud URL and available providers - const [selectedUrlOption, setSelectedUrlOption] = useState( - "https://api.jetkvm.com", - ); - - const [defaultCloudUrl, setDefaultCloudUrl] = useState(""); - - const syncCloudUrl = useCallback(() => { - send("getCloudUrl", {}, resp => { - if ("error" in resp) return; - const url = resp.result as string; - setCloudUrl(url); - // Check if the URL matches any predefined option - if (cloudProviders?.some(provider => provider.value === url)) { - setSelectedUrlOption(url); - } else { - setSelectedUrlOption("custom"); - // setCustomCloudUrl(url); - } - }); - }, [cloudProviders, send]); + // Use a simple string identifier for the selected provider + const [selectedProvider, setSelectedProvider] = useState("jetkvm"); const getCloudState = useCallback(() => { send("getCloudState", {}, resp => { if ("error" in resp) return console.error(resp.error); - const cloudState = resp.result as { connected: boolean }; + const cloudState = resp.result as CloudState; setAdopted(cloudState.connected); + setCloudApiUrl(cloudState.url); + + if (cloudState.appUrl) setCloudAppUrl(cloudState.appUrl); + + // Find if the API URL matches any of our predefined providers + const isAPIJetKVMProd = cloudState.url === "https://api.jetkvm.com"; + const isAppJetKVMProd = cloudState.appUrl === "https://app.jetkvm.com"; + + if (isAPIJetKVMProd && isAppJetKVMProd) { + setSelectedProvider("jetkvm"); + } else { + setSelectedProvider("custom"); + } }); }, [send]); @@ -88,42 +81,50 @@ export default function SettingsAccessIndexRoute() { }; const onCloudAdoptClick = useCallback( - (url: string) => { + (cloudApiUrl: string, cloudAppUrl: string) => { if (!deviceId) { notifications.error("No device ID available"); return; } - send("setCloudUrl", { url }, resp => { + send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => { if ("error" in resp) { notifications.error( `Failed to update cloud URL: ${resp.error.data || "Unknown error"}`, ); return; } - syncCloudUrl(); - notifications.success("Cloud URL updated successfully"); const returnTo = new URL(window.location.href); returnTo.pathname = "/adopt"; returnTo.search = ""; returnTo.hash = ""; window.location.href = - CLOUD_APP + "/signup?deviceId=" + deviceId + `&returnTo=${returnTo.toString()}`; + cloudAppUrl + + "/signup?deviceId=" + + deviceId + + `&returnTo=${returnTo.toString()}`; }); }, - [deviceId, syncCloudUrl, send], + [deviceId, send], ); - useEffect(() => { - if (!defaultCloudUrl) return; - setSelectedUrlOption(defaultCloudUrl); - setCloudProviders([ - { value: defaultCloudUrl, label: "JetKVM Cloud" }, - { value: "custom", label: "Custom" }, - ]); - }, [defaultCloudUrl]); + // Handle provider selection change + const handleProviderChange = (value: string) => { + setSelectedProvider(value); + // If selecting a predefined provider, update both URLs + if (value === "jetkvm") { + setCloudApiUrl("https://api.jetkvm.com"); + setCloudAppUrl("https://app.jetkvm.com"); + } else { + if (cloudApiUrl || cloudAppUrl) return; + setCloudApiUrl(""); + setCloudAppUrl(""); + } + }; + + // Fetch device ID and cloud state on component mount useEffect(() => { getCloudState(); @@ -133,18 +134,6 @@ export default function SettingsAccessIndexRoute() { }); }, [send, getCloudState]); - useEffect(() => { - send("getDefaultCloudUrl", {}, resp => { - if ("error" in resp) return console.error(resp.error); - setDefaultCloudUrl(resp.result as string); - }); - }, [cloudProviders, syncCloudUrl, send]); - - useEffect(() => { - if (!cloudProviders?.length) return; - syncCloudUrl(); - }, [cloudProviders, syncCloudUrl]); - return (
{ - const value = e.target.value; - setSelectedUrlOption(value); - }} - options={cloudProviders ?? []} + value={selectedProvider} + onChange={e => handleProviderChange(e.target.value)} + options={[ + { value: "jetkvm", label: "JetKVM Cloud" }, + { value: "custom", label: "Custom" }, + ]} /> - {selectedUrlOption === "custom" && ( -
- setCloudUrl(e.target.value)} - placeholder="https://api.example.com" - /> + {selectedProvider === "custom" && ( +
+
+ setCloudApiUrl(e.target.value)} + placeholder="https://api.example.com" + /> +
+
+ setCloudAppUrl(e.target.value)} + placeholder="https://app.example.com" + /> +
)} )} - {/* - We do the harcoding here to avoid flickering when the default Cloud URL being fetched. - I've tried to avoid harcoding api.jetkvm.com, but it's the only reasonable way I could think of to avoid flickering for now. - */} - {selectedUrlOption === (defaultCloudUrl || "https://api.jetkvm.com") && ( + {/* Show security info for JetKVM Cloud */} + {selectedProvider === "jetkvm" && (
@@ -295,7 +292,7 @@ export default function SettingsAccessIndexRoute() { {!isAdopted ? (