From 1af21432d4516d903796ea9e64905756854016ae Mon Sep 17 00:00:00 2001 From: Kevin Yue Date: Wed, 7 Jun 2023 09:20:44 +0800 Subject: [PATCH] refactor: refactor UI using jotai --- .editorconfig | 6 + .vscode/settings.json | 3 + gpgui/package.json | 5 + gpgui/pnpm-lock.yaml | 63 +++- gpgui/src-tauri/src/auth.rs | 5 + gpgui/src/App.tsx | 273 +----------------- gpgui/src/atoms/gateway.ts | 56 ++++ gpgui/src/atoms/notification.ts | 36 +++ gpgui/src/atoms/portal.ts | 225 +++++++++++++++ gpgui/src/atoms/status.ts | 44 +++ .../components/ConnectForm/PasswordAuth.tsx | 78 +++++ .../src/components/ConnectForm/PortalForm.tsx | 70 +++++ gpgui/src/components/ConnectForm/index.tsx | 11 + gpgui/src/components/ConnectionStatus.tsx | 107 ------- .../ConnectionStatus/StatusIcon.tsx | 93 ++++++ .../ConnectionStatus/StatusText.tsx | 13 + .../src/components/ConnectionStatus/index.tsx | 12 + gpgui/src/components/Feedback/index.tsx | 3 + gpgui/src/components/Notification.tsx | 68 ----- gpgui/src/components/Notification/index.tsx | 43 +++ gpgui/src/components/PasswordAuth.tsx | 120 -------- gpgui/src/services/authService.ts | 5 +- gpgui/src/services/gatewayService.ts | 11 +- gpgui/src/services/portalService.ts | 140 +++++---- gpgui/src/services/types.ts | 8 +- gpgui/src/types.ts | 5 - gpgui/src/utils/invokeCommand.ts | 3 +- 27 files changed, 866 insertions(+), 640 deletions(-) create mode 100644 gpgui/src/atoms/gateway.ts create mode 100644 gpgui/src/atoms/notification.ts create mode 100644 gpgui/src/atoms/portal.ts create mode 100644 gpgui/src/atoms/status.ts create mode 100644 gpgui/src/components/ConnectForm/PasswordAuth.tsx create mode 100644 gpgui/src/components/ConnectForm/PortalForm.tsx create mode 100644 gpgui/src/components/ConnectForm/index.tsx delete mode 100644 gpgui/src/components/ConnectionStatus.tsx create mode 100644 gpgui/src/components/ConnectionStatus/StatusIcon.tsx create mode 100644 gpgui/src/components/ConnectionStatus/StatusText.tsx create mode 100644 gpgui/src/components/ConnectionStatus/index.tsx create mode 100644 gpgui/src/components/Feedback/index.tsx delete mode 100644 gpgui/src/components/Notification.tsx create mode 100644 gpgui/src/components/Notification/index.tsx delete mode 100644 gpgui/src/components/PasswordAuth.tsx delete mode 100644 gpgui/src/types.ts diff --git a/.editorconfig b/.editorconfig index ffe8a6b..370ba12 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,9 @@ indent_size = 4 [*.{c,h}] indent_size = 4 + +[*.{js,jsx,ts,tsx}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.vscode/settings.json b/.vscode/settings.json index fe8d44a..7b0ead5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,11 @@ { "cSpell.words": [ + "authcookie", "bindgen", + "clickaway", "clientos", "gpcommon", + "Immer", "jnlp", "oneshot", "openconnect", diff --git a/gpgui/package.json b/gpgui/package.json index f62003e..1b14efb 100644 --- a/gpgui/package.json +++ b/gpgui/package.json @@ -15,6 +15,11 @@ "@mui/lab": "5.0.0-alpha.125", "@mui/material": "^5.11.11", "@tauri-apps/api": "^1.3.0", + "immer": "^10.0.2", + "jotai": "^2.1.1", + "jotai-immer": "^0.2.0", + "jotai-optics": "^0.3.0", + "optics-ts": "^2.4.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-spinners": "^0.13.8", diff --git a/gpgui/pnpm-lock.yaml b/gpgui/pnpm-lock.yaml index 54527ec..d195d52 100644 --- a/gpgui/pnpm-lock.yaml +++ b/gpgui/pnpm-lock.yaml @@ -1,4 +1,8 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false dependencies: '@emotion/react': @@ -19,6 +23,21 @@ dependencies: '@tauri-apps/api': specifier: ^1.3.0 version: 1.3.0 + immer: + specifier: ^10.0.2 + version: 10.0.2 + jotai: + specifier: ^2.1.1 + version: 2.1.1(react@18.2.0) + jotai-immer: + specifier: ^0.2.0 + version: 0.2.0(immer@10.0.2)(jotai@2.1.1)(react@18.2.0) + jotai-optics: + specifier: ^0.3.0 + version: 0.3.0(jotai@2.1.1)(optics-ts@2.4.0) + optics-ts: + specifier: ^2.4.0 + version: 2.4.0 react: specifier: ^18.2.0 version: 18.2.0 @@ -1146,6 +1165,10 @@ packages: react-is: 16.13.1 dev: false + /immer@10.0.2: + resolution: {integrity: sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==} + dev: false + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -1163,6 +1186,40 @@ packages: dependencies: has: 1.0.3 + /jotai-immer@0.2.0(immer@10.0.2)(jotai@2.1.1)(react@18.2.0): + resolution: {integrity: sha512-hahK8EPiROS9RoNWmX/Z8rY9WkAijspX4BZ1O7umpcwI4kPNkbcCpu/PhiQ8FMcpEcF6KmbpbMpSSj/GFmo8NA==} + peerDependencies: + immer: '*' + jotai: '>=1.11.0' + react: '>=17.0.0' + dependencies: + immer: 10.0.2 + jotai: 2.1.1(react@18.2.0) + react: 18.2.0 + dev: false + + /jotai-optics@0.3.0(jotai@2.1.1)(optics-ts@2.4.0): + resolution: {integrity: sha512-5ttpCRREIBu6DJix0wlyBP6y1QDPlePnoMZSXNDi/FOkXZrhk9uIXKjwvw34/yBCHT5mYpFUD4sFDvRUU2vkvQ==} + peerDependencies: + jotai: '>=1.11.0' + optics-ts: '*' + dependencies: + jotai: 2.1.1(react@18.2.0) + optics-ts: 2.4.0 + dev: false + + /jotai@2.1.1(react@18.2.0): + resolution: {integrity: sha512-LaaiuSaq+6XkwkrCtCkczyFVZOXe0dfjAFN4DVMsSZSRv/A/4xuLHnlpHMEDqvngjWYBotTIrnQ7OogMkUE6wA==} + engines: {node: '>=12.20.0'} + peerDependencies: + react: '>=17.0.0' + peerDependenciesMeta: + react: + optional: true + dependencies: + react: 18.2.0 + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: false @@ -1193,6 +1250,10 @@ packages: engines: {node: '>=0.10.0'} dev: false + /optics-ts@2.4.0: + resolution: {integrity: sha512-BIYgnqOTEf+WiXuxuBFXeoCtyIDOwnUwCMybdQh8qdHyWXunwVVt7iD9XwNq8SCd5vUo9vqgYxF5ati/6inIuQ==} + dev: false + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} diff --git a/gpgui/src-tauri/src/auth.rs b/gpgui/src-tauri/src/auth.rs index 315f443..7fb1b28 100644 --- a/gpgui/src-tauri/src/auth.rs +++ b/gpgui/src-tauri/src/auth.rs @@ -364,6 +364,11 @@ fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Option Result { + if html.contains("Temporarily Unavailable") { + info!("SAML result page temporarily unavailable, retrying"); + return Err(AuthError::TokenInvalid); + } + let saml_auth_status = parse_xml_tag(html, "saml-auth-status"); match saml_auth_status { diff --git a/gpgui/src/App.tsx b/gpgui/src/App.tsx index 22d9768..40b3760 100644 --- a/gpgui/src/App.tsx +++ b/gpgui/src/App.tsx @@ -1,271 +1,16 @@ -import { WebviewWindow } from "@tauri-apps/api/window"; -import { Box, TextField } from "@mui/material"; -import Button from "@mui/material/Button"; -import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react"; - -import "./App.css"; -import ConnectionStatus, { Status } from "./components/ConnectionStatus"; -import Notification, { NotificationConfig } from "./components/Notification"; -import PasswordAuth, { - Credentials, - PasswordAuthData, -} from "./components/PasswordAuth"; -import gatewayService from "./services/gatewayService"; -import portalService from "./services/portalService"; -import vpnService from "./services/vpnService"; -import authService from "./services/authService"; -import { Maybe } from "./types"; +import { Box } from "@mui/material"; +import ConnectForm from "./components/ConnectForm"; +import ConnectionStatus from "./components/ConnectionStatus"; +import Feedback from "./components/Feedback"; +import Notification from "./components/Notification"; export default function App() { - const [portalAddress, setPortalAddress] = useState("vpn.microstrategy.com"); // useState("220.191.185.154"); - const [status, setStatus] = useState("disconnected"); - const [processing, setProcessing] = useState(false); - const [passwordAuthOpen, setPasswordAuthOpen] = useState(false); - const [passwordAuthenticating, setPasswordAuthenticating] = useState(false); - const [passwordAuth, setPasswordAuth] = useState(); - const [notification, setNotification] = useState({ - open: false, - message: "", - }); - const regionRef = useRef>(null); - - useEffect(() => { - return vpnService.onStatusChanged((latestStatus) => { - console.log("status changed", latestStatus); - setStatus(latestStatus); - if (latestStatus === "connected") { - clearOverlays(); - } - }); - }, []); - - useEffect(() => { - authService.onAuthError(async () => { - const preloginResponse = await portalService.prelogin(portalAddress); - if (portalService.isSamlAuth(preloginResponse)) { - // Retry SAML login when auth error occurs - await authService.emitAuthRequest({ - samlBinding: preloginResponse.samlAuthMethod, - samlRequest: preloginResponse.samlAuthRequest, - }); - } - }); - }, [portalAddress]); - - function closeNotification() { - setNotification((notification) => ({ - ...notification, - open: false, - })); - } - - function clearOverlays() { - closeNotification(); - setPasswordAuthenticating(false); - setPasswordAuthOpen(false); - } - - function handlePortalChange(e: ChangeEvent) { - const { value } = e.target; - setPortalAddress(value.trim()); - } - - async function handleConnect(e: FormEvent) { - e.preventDefault(); - - // setProcessing(true); - setStatus("processing"); - - try { - const response = await portalService.prelogin(portalAddress); - const { region } = response; - regionRef.current = region; - - if (portalService.isSamlAuth(response)) { - const { samlAuthMethod, samlAuthRequest } = response; - setStatus("authenticating"); - const authData = await authService.samlLogin( - samlAuthMethod, - samlAuthRequest - ); - if (!authData) { - throw new Error("User cancelled"); - } - - const portalConfigResponse = await portalService.fetchConfig( - portalAddress, - { - user: authData.username, - "prelogin-cookie": authData.prelogin_cookie, - "portal-userauthcookie": authData.portal_userauthcookie, - } - ); - - console.log("portalConfigResponse", portalConfigResponse); - - const { gateways, userAuthCookie, prelogonUserAuthCookie } = - portalConfigResponse; - - const preferredGateway = portalService.preferredGateway( - gateways, - regionRef.current - ); - - const token = await gatewayService.login(preferredGateway, { - user: authData.username, - userAuthCookie, - prelogonUserAuthCookie, - }); - - await vpnService.connect(preferredGateway.address!, token); - } else if (portalService.isPasswordAuth(response)) { - setPasswordAuthOpen(true); - setPasswordAuth({ - authMessage: response.authMessage, - labelPassword: response.labelPassword, - labelUsername: response.labelUsername, - }); - } else { - throw new Error("Unsupported portal login method"); - } - } catch (e) { - console.error(e); - setStatus("disconnected"); - } - } - - function handleCancel() { - // TODO cancel the request first - setStatus("disconnected"); - } - - async function handleDisconnect() { - setStatus("processing"); - - try { - await vpnService.disconnect(); - } catch (err: any) { - setNotification({ - open: true, - type: "error", - title: "Failed to disconnect", - message: err.message, - }); - } finally { - setStatus("disconnected"); - } - } - - async function handlePasswordAuth({ - username: user, - password: passwd, - }: Credentials) { - try { - setPasswordAuthenticating(true); - const portalConfigResponse = await portalService.fetchConfig( - portalAddress, - { user, passwd } - ); - - const { gateways, userAuthCookie } = portalConfigResponse; - - if (gateways.length === 0) { - // TODO handle no gateways, treat the portal as a gateway - throw new Error("No gateways found"); - } - - const preferredGateway = portalService.preferredGateway( - gateways, - regionRef.current - ); - const token = await gatewayService.login(preferredGateway, { - user, - passwd, - userAuthCookie, - }); - - await vpnService.connect(preferredGateway.address!, token); - // setProcessing(false); - } catch (err: any) { - console.error(err); - setNotification({ - open: true, - type: "error", - title: "Login failed", - message: err.message, - }); - } finally { - setPasswordAuthenticating(false); - } - } - - function cancelPasswordAuth() { - setPasswordAuthenticating(false); - setPasswordAuthOpen(false); - // setProcessing(false); - setStatus("disconnected"); - } return ( - - -
- - - {status === "disconnected" && ( - - )} - {["processing", "authenticating", "connecting"].includes(status) && ( - - )} - {status === "connected" && ( - - )} - - - - - + + + +
); } diff --git a/gpgui/src/atoms/gateway.ts b/gpgui/src/atoms/gateway.ts new file mode 100644 index 0000000..d0284e4 --- /dev/null +++ b/gpgui/src/atoms/gateway.ts @@ -0,0 +1,56 @@ +import { atom } from "jotai"; +import gatewayService from "../services/gatewayService"; +import vpnService from "../services/vpnService"; +import { notifyErrorAtom } from "./notification"; +import { isProcessingAtom, statusAtom } from "./status"; + +type GatewayCredential = { + user: string; + passwd?: string; + userAuthCookie: string; + prelogonUserAuthCookie: string; +}; + +export const gatewayLoginAtom = atom( + null, + async (get, set, gateway: string, credential: GatewayCredential) => { + set(statusAtom, "gateway-login"); + let token: string; + try { + token = await gatewayService.login(gateway, credential); + } catch (err) { + throw new Error("Failed to login to gateway"); + } + + if (!get(isProcessingAtom)) { + console.info("Request cancelled"); + return; + } + + await set(connectVpnAtom, gateway, token); + } +); + +const connectVpnAtom = atom( + null, + async (_get, set, vpnAddress: string, token: string) => { + try { + set(statusAtom, "connecting"); + await vpnService.connect(vpnAddress, token); + set(statusAtom, "connected"); + } catch (err) { + throw new Error("Failed to connect to VPN"); + } + } +); + +export const disconnectVpnAtom = atom(null, async (get, set) => { + try { + set(statusAtom, "disconnecting"); + await vpnService.disconnect(); + set(statusAtom, "disconnected"); + } catch (err) { + set(statusAtom, "disconnected"); + set(notifyErrorAtom, "Failed to disconnect from VPN"); + } +}); diff --git a/gpgui/src/atoms/notification.ts b/gpgui/src/atoms/notification.ts new file mode 100644 index 0000000..7a76b50 --- /dev/null +++ b/gpgui/src/atoms/notification.ts @@ -0,0 +1,36 @@ +import { AlertColor } from "@mui/material"; +import { atom } from "jotai"; + +export type Severity = AlertColor; + +const notificationVisibleAtom = atom(false); +export const notificationConfigAtom = atom({ + title: "", + message: "", + severity: "info" as Severity, +}); + +export const closeNotificationAtom = atom( + (get) => get(notificationVisibleAtom), + (_get, set) => { + set(notificationVisibleAtom, false); + } +); + +export const notifyErrorAtom = atom(null, (_get, set, err: unknown) => { + let msg: string; + if (err instanceof Error) { + msg = err.message; + } else if (typeof err === "string") { + msg = err; + } else { + msg = "Unknown error"; + } + + set(notificationVisibleAtom, true); + set(notificationConfigAtom, { + title: "Error", + message: msg, + severity: "error", + }); +}); diff --git a/gpgui/src/atoms/portal.ts b/gpgui/src/atoms/portal.ts new file mode 100644 index 0000000..25b0306 --- /dev/null +++ b/gpgui/src/atoms/portal.ts @@ -0,0 +1,225 @@ +import { atom } from "jotai"; +import { focusAtom } from "jotai-optics"; +import authService, { AuthData } from "../services/authService"; +import portalService, { + PasswordPrelogin, + Prelogin, + SamlPrelogin, +} from "../services/portalService"; +import { gatewayLoginAtom } from "./gateway"; +import { notifyErrorAtom } from "./notification"; +import { isProcessingAtom, statusAtom } from "./status"; + +type GatewayData = { + name: string; + address: string; +}; + +type Credential = { + user: string; + passwd: string; + userAuthCookie: string; + prelogonUserAuthCookie: string; +}; + +type AppData = { + portal: string; + gateways: GatewayData[]; + selectedGateway: string; + credentials: Record; +}; + +const appAtom = atom({ + portal: "", + gateways: [], + selectedGateway: "", + credentials: {}, +}); + +export const portalAtom = focusAtom(appAtom, (optic) => optic.prop("portal")); +export const connectPortalAtom = atom( + (get) => get(isProcessingAtom), + async (get, set, action?: "retry-auth") => { + // Retry the SAML authentication + if (action === "retry-auth") { + set(retrySamlAuthAtom); + return; + } + + const portal = get(portalAtom); + if (!portal) { + set(notifyErrorAtom, "Portal is empty"); + return; + } + + try { + set(statusAtom, "prelogin"); + const prelogin = await portalService.prelogin(portal); + if (!get(isProcessingAtom)) { + console.info("Request cancelled"); + return; + } + + if (prelogin.isSamlAuth) { + await set(launchSamlAuthAtom, prelogin); + } else { + await set(launchPasswordAuthAtom, prelogin); + } + } catch (err) { + set(cancelConnectPortalAtom); + set(notifyErrorAtom, err); + } + } +); + +connectPortalAtom.onMount = (dispatch) => { + return authService.onAuthError(() => { + dispatch("retry-auth"); + }); +}; + +export const passwordPreloginAtom = atom({ + isSamlAuth: false, + region: "", + authMessage: "", + labelUsername: "", + labelPassword: "", +}); + +export const cancelConnectPortalAtom = atom(null, (_get, set) => { + set(statusAtom, "disconnected"); +}); + +export const usernameAtom = atom(""); +export const passwordAtom = atom(""); +const passwordAuthVisibleAtom = atom(false); + +const launchPasswordAuthAtom = atom( + null, + async (_get, set, prelogin: PasswordPrelogin) => { + set(passwordAuthVisibleAtom, true); + set(passwordPreloginAtom, prelogin); + set(statusAtom, "authenticating-password"); + } +); + +export const cancelPasswordAuthAtom = atom( + (get) => get(passwordAuthVisibleAtom), + (_get, set) => { + set(passwordAuthVisibleAtom, false); + set(cancelConnectPortalAtom); + } +); + +export const passwordLoginAtom = atom( + (get) => get(portalConfigLoadingAtom), + async (get, set, username: string, password: string) => { + const portal = get(portalAtom); + if (!portal) { + set(notifyErrorAtom, "Portal is empty"); + return; + } + + if (!username) { + set(notifyErrorAtom, "Username is empty"); + return; + } + + try { + const credential = { user: username, passwd: password }; + const prelogin = get(passwordPreloginAtom); + await set(portalLoginAtom, credential, prelogin); + } catch (err) { + set(cancelConnectPortalAtom); + set(notifyErrorAtom, err); + } + } +); + +const launchSamlAuthAtom = atom( + null, + async (_get, set, prelogin: SamlPrelogin) => { + const { samlAuthMethod, samlRequest } = prelogin; + let authData: AuthData; + + try { + set(statusAtom, "authenticating-saml"); + authData = await authService.samlLogin(samlAuthMethod, samlRequest); + } catch (err) { + throw new Error("SAML login failed"); + } + + if (!authData) { + // User closed the SAML login window, cancel the login + set(cancelConnectPortalAtom); + return; + } + + const credential = { + user: authData.username, + "prelogin-cookie": authData.prelogin_cookie, + "portal-userauthcookie": authData.portal_userauthcookie, + }; + await set(portalLoginAtom, credential, prelogin); + } +); + +const retrySamlAuthAtom = atom(null, async (get) => { + const portal = get(portalAtom); + const prelogin = await portalService.prelogin(portal); + if (prelogin.isSamlAuth) { + await authService.emitAuthRequest({ + samlBinding: prelogin.samlAuthMethod, + samlRequest: prelogin.samlRequest, + }); + } +}); + +type PortalCredential = + | { + user: string; + passwd: string; + } + | { + user: string; + "prelogin-cookie": string | null; + "portal-userauthcookie": string | null; + }; + +const portalConfigLoadingAtom = atom(false); +const portalLoginAtom = atom( + (get) => get(portalConfigLoadingAtom), + async (get, set, credential: PortalCredential, prelogin: Prelogin) => { + set(statusAtom, "portal-config"); + set(portalConfigLoadingAtom, true); + + const portal = get(portalAtom); + let portalConfig; + try { + portalConfig = await portalService.fetchConfig(portal, credential); + // Ensure the password auth window is closed + set(passwordAuthVisibleAtom, false); + } finally { + set(portalConfigLoadingAtom, false); + } + + if (!get(isProcessingAtom)) { + console.info("Request cancelled"); + return; + } + + const { gateways, userAuthCookie, prelogonUserAuthCookie } = portalConfig; + console.info("portalConfig", portalConfig); + if (!gateways.length) { + throw new Error("No gateway found"); + } + + const { region } = prelogin; + const { address } = portalService.preferredGateway(gateways, region); + await set(gatewayLoginAtom, address, { + user: credential.user, + userAuthCookie, + prelogonUserAuthCookie, + }); + } +); diff --git a/gpgui/src/atoms/status.ts b/gpgui/src/atoms/status.ts new file mode 100644 index 0000000..ea7c737 --- /dev/null +++ b/gpgui/src/atoms/status.ts @@ -0,0 +1,44 @@ +import { atom } from "jotai"; +import vpnService from "../services/vpnService"; + +export type Status = + | "disconnected" + | "prelogin" + | "authenticating-saml" + | "authenticating-password" + | "portal-config" + | "gateway-login" + | "connecting" + | "connected" + | "disconnecting" + | "error"; + +export const statusAtom = atom("disconnected"); +statusAtom.onMount = (setAtom) => { + return vpnService.onStatusChanged((status) => { + status === "connected" && setAtom("connected"); + }); +}; + +const statusTextMap: Record = { + disconnected: "Not Connected", + prelogin: "Portal pre-logging in...", + "authenticating-saml": "Authenticating...", + "authenticating-password": "Authenticating...", + "portal-config": "Retrieving portal config...", + "gateway-login": "Logging in to gateway...", + connecting: "Connecting...", + connected: "Connected", + disconnecting: "Disconnecting...", + error: "Error", +}; + +export const statusTextAtom = atom((get) => { + const status = get(statusAtom); + return statusTextMap[status]; +}); + +export const isProcessingAtom = atom((get) => { + const status = get(statusAtom); + return status !== "disconnected" && status !== "connected"; +}); diff --git a/gpgui/src/components/ConnectForm/PasswordAuth.tsx b/gpgui/src/components/ConnectForm/PasswordAuth.tsx new file mode 100644 index 0000000..497640c --- /dev/null +++ b/gpgui/src/components/ConnectForm/PasswordAuth.tsx @@ -0,0 +1,78 @@ +import { LoadingButton } from "@mui/lab"; +import { Box, Button, Drawer, TextField, Typography } from "@mui/material"; +import { useAtom, useAtomValue } from "jotai"; +import { FormEvent, useEffect, useRef } from "react"; +import { + cancelPasswordAuthAtom, + passwordAtom, + passwordLoginAtom, + passwordPreloginAtom, + usernameAtom, +} from "../../atoms/portal"; + +export default function PasswordAuth() { + const [visible, cancelPasswordAuth] = useAtom(cancelPasswordAuthAtom); + const { authMessage, labelUsername, labelPassword } = + useAtomValue(passwordPreloginAtom); + const [username, setUsername] = useAtom(usernameAtom); + const [password, setPassword] = useAtom(passwordAtom); + const [loading, passwordLogin] = useAtom(passwordLoginAtom); + const usernameRef = useRef(null); + + useEffect(() => { + if (visible) { + setTimeout(() => { + usernameRef.current?.querySelector("input")?.focus(); + }, 0); + } + }, [visible]); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + passwordLogin(username, password); + } + + return ( + +
+ + {authMessage} + setUsername(e.target.value.trim())} + InputProps={{ readOnly: loading }} + /> + setPassword(e.target.value)} + InputProps={{ readOnly: loading }} + /> + + + + Login + + + +
+
+ ); +} diff --git a/gpgui/src/components/ConnectForm/PortalForm.tsx b/gpgui/src/components/ConnectForm/PortalForm.tsx new file mode 100644 index 0000000..3ad815e --- /dev/null +++ b/gpgui/src/components/ConnectForm/PortalForm.tsx @@ -0,0 +1,70 @@ +import { Button, TextField } from "@mui/material"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { ChangeEvent } from "react"; +import { disconnectVpnAtom } from "../../atoms/gateway"; +import { + cancelConnectPortalAtom, + connectPortalAtom, + portalAtom, +} from "../../atoms/portal"; +import { statusAtom } from "../../atoms/status"; + +export default function PortalForm() { + const [portal, setPortal] = useAtom(portalAtom); + const status = useAtomValue(statusAtom); + const [processing, connectPortal] = useAtom(connectPortalAtom); + const cancelConnectPortal = useSetAtom(cancelConnectPortalAtom); + const disconnectVpn = useSetAtom(disconnectVpnAtom); + + function handleSubmit(e: ChangeEvent) { + e.preventDefault(); + connectPortal(); + } + + return ( +
+ setPortal(e.target.value.trim())} + InputProps={{ readOnly: status !== "disconnected" }} + sx={{ mb: 1 }} + /> + {status === "disconnected" && ( + + )} + {processing && ( + + )} + {status === "connected" && ( + + )} + + ); +} diff --git a/gpgui/src/components/ConnectForm/index.tsx b/gpgui/src/components/ConnectForm/index.tsx new file mode 100644 index 0000000..c9bc08e --- /dev/null +++ b/gpgui/src/components/ConnectForm/index.tsx @@ -0,0 +1,11 @@ +import PasswordAuth from "./PasswordAuth"; +import PortalForm from "./PortalForm"; + +export default function ConnectForm() { + return ( + <> + + + + ); +} diff --git a/gpgui/src/components/ConnectionStatus.tsx b/gpgui/src/components/ConnectionStatus.tsx deleted file mode 100644 index e07f7d4..0000000 --- a/gpgui/src/components/ConnectionStatus.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import GppBadIcon from "@mui/icons-material/GppBad"; -import VerifiedIcon from "@mui/icons-material/VerifiedUser"; -import { - Box, - BoxProps, - CircularProgress, - Typography, - useTheme, -} from "@mui/material"; -import { BeatLoader } from "react-spinners"; - -export type Status = - | "processing" - | "authenticating" - | "disconnected" - | "connecting" - | "connected" - | "disconnecting"; - -export const statusTextMap: Record = { - processing: "Processing...", - authenticating: "Authenticating...", - connected: "Connected", - disconnected: "Not Connected", - connecting: "Connecting...", - disconnecting: "Disconnecting...", -}; - -export default function ConnectionStatus( - props: BoxProps<"div", { status?: Status }> -) { - const theme = useTheme(); - const { status = "disconnected" } = props; - const { palette } = theme; - const colorsMap: Record = { - processing: palette.info.main, - authenticating: palette.info.main, - connected: palette.success.main, - disconnected: palette.action.disabled, - connecting: palette.info.main, - disconnecting: palette.info.main, - }; - - const pending = ["processing", "authenticating", "connecting", "disconnecting"].includes(status); - const connected = status === "connected"; - const disconnected = status === "disconnected"; - - return ( - - - - {pending && } - - {connected && ( - - )} - - {disconnected && ( - - )} - - - - {statusTextMap[status]} - - - ); -} diff --git a/gpgui/src/components/ConnectionStatus/StatusIcon.tsx b/gpgui/src/components/ConnectionStatus/StatusIcon.tsx new file mode 100644 index 0000000..99a8850 --- /dev/null +++ b/gpgui/src/components/ConnectionStatus/StatusIcon.tsx @@ -0,0 +1,93 @@ +import { GppBad, VerifiedUser as VerifiedIcon } from "@mui/icons-material"; +import { Box, CircularProgress, styled, useTheme } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { BeatLoader } from "react-spinners"; +import { statusAtom, isProcessingAtom } from "../../atoms/status"; + +function useStatusColor() { + const status = useAtomValue(statusAtom); + const theme = useTheme(); + + if (status === "disconnected") { + return theme.palette.action.disabled; + } + + if (status === "connected") { + return theme.palette.success.main; + } + + if (status === "error") { + return theme.palette.error.main; + } + + return theme.palette.info.main; +} + +function BackgroundIcon() { + const color = useStatusColor(); + const processing = useAtomValue(isProcessingAtom); + + return ( + + ); +} + +const DisconnectedIcon = styled(GppBad)(({ theme }) => ({ + position: "relative", + fontSize: 90, + color: theme.palette.action.disabled, +})); + +function ProcessingIcon() { + const theme = useTheme(); + return ; +} + +const ConnectedIcon = styled(VerifiedIcon)(({ theme }) => ({ + position: "relative", + fontSize: 80, + color: theme.palette.success.main, +})); + +const IconContainer = styled(Box)(({ theme }) => + theme.unstable_sx({ + position: "relative", + width: 150, + height: 150, + textAlign: "center", + mx: "auto", + display: "flex", + alignItems: "center", + justifyContent: "center", + }) +); + +export default function StatusIcon() { + const status = useAtomValue(statusAtom); + const processing = useAtomValue(isProcessingAtom); + + return ( + + + {status === "disconnected" && } + {processing && } + {status === "connected" && } + + ); +} diff --git a/gpgui/src/components/ConnectionStatus/StatusText.tsx b/gpgui/src/components/ConnectionStatus/StatusText.tsx new file mode 100644 index 0000000..a6df980 --- /dev/null +++ b/gpgui/src/components/ConnectionStatus/StatusText.tsx @@ -0,0 +1,13 @@ +import { Typography } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { statusTextAtom } from "../../atoms/status"; + +export default function StatusText() { + const statusText = useAtomValue(statusTextAtom); + + return ( + + {statusText} + + ); +} diff --git a/gpgui/src/components/ConnectionStatus/index.tsx b/gpgui/src/components/ConnectionStatus/index.tsx new file mode 100644 index 0000000..b928e52 --- /dev/null +++ b/gpgui/src/components/ConnectionStatus/index.tsx @@ -0,0 +1,12 @@ +import { Box } from "@mui/material"; +import StatusIcon from "./StatusIcon"; +import StatusText from "./StatusText"; + +export default function ConnectionStatus() { + return ( + + + + + ); +} diff --git a/gpgui/src/components/Feedback/index.tsx b/gpgui/src/components/Feedback/index.tsx new file mode 100644 index 0000000..7af0968 --- /dev/null +++ b/gpgui/src/components/Feedback/index.tsx @@ -0,0 +1,3 @@ +export default function Feedback() { + return
Feedback
+} \ No newline at end of file diff --git a/gpgui/src/components/Notification.tsx b/gpgui/src/components/Notification.tsx deleted file mode 100644 index b8768b3..0000000 --- a/gpgui/src/components/Notification.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { - Alert, - AlertColor, - AlertTitle, - Slide, - SlideProps, - Snackbar, - SnackbarCloseReason, -} from "@mui/material"; - -type TransitionProps = Omit; - -function TransitionDown(props: TransitionProps) { - return ; -} - -export type NotificationType = AlertColor; -export type NotificationConfig = { - open: boolean; - message: string; - title?: string; - type?: NotificationType; -}; - -type NotificationProps = { - onClose: () => void; -} & NotificationConfig; - -export default function Notification(props: NotificationProps) { - const { open, message, title, type = "info", onClose } = props; - - function handleClose( - _: React.SyntheticEvent | Event, - reason?: SnackbarCloseReason - ) { - if (reason === "clickaway") { - return; - } - onClose(); - } - - return ( - - - {title && {title}} - {message} - - - ); -} diff --git a/gpgui/src/components/Notification/index.tsx b/gpgui/src/components/Notification/index.tsx new file mode 100644 index 0000000..a6cc464 --- /dev/null +++ b/gpgui/src/components/Notification/index.tsx @@ -0,0 +1,43 @@ +import { Alert, AlertTitle, Slide, SlideProps, Snackbar } from "@mui/material"; +import { useAtom, useAtomValue } from "jotai"; +import { + closeNotificationAtom, + notificationConfigAtom, +} from "../../atoms/notification"; + +type TransitionProps = Omit; +function TransitionDown(props: TransitionProps) { + return ; +} + +export default function Notification() { + const { title, message, severity } = useAtomValue(notificationConfigAtom); + const [visible, closeNotification] = useAtom(closeNotificationAtom); + + return ( + + + {title && {title}} + {message} + + + ); +} diff --git a/gpgui/src/components/PasswordAuth.tsx b/gpgui/src/components/PasswordAuth.tsx deleted file mode 100644 index 6ace68f..0000000 --- a/gpgui/src/components/PasswordAuth.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import LoadingButton from "@mui/lab/LoadingButton"; -import { Box, Button, Drawer, TextField, Typography } from "@mui/material"; -import { FormEvent, useEffect, useRef, useState } from "react"; -import { Maybe } from "../types"; - -export type PasswordAuthData = { - labelUsername: string; - labelPassword: string; - authMessage: Maybe; -}; - -export type Credentials = { - username: string; - password: string; -}; - -type LoginCallback = (params: Credentials) => void; - -type Props = { - open: boolean; - authData: PasswordAuthData | undefined; - authenticating: boolean; - onCancel: () => void; - onLogin: LoginCallback; -}; - -type AuthFormProps = { - authenticating: boolean; - onCancel: () => void; - onSubmit: LoginCallback; -} & PasswordAuthData; - -function AuthForm(props: AuthFormProps) { - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const inputRef = useRef(null); - - useEffect(() => { - inputRef.current?.querySelector("input")?.focus(); - }, []); - - const { - authenticating, - authMessage, - labelUsername, - labelPassword, - onCancel, - onSubmit, - } = props; - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - - if (username.trim() === "" || password === "") { - return; - } - - onSubmit({ username, password }); - } - - return ( -
- - {authMessage} - setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - - Login - - - -
- ); -} - -export default function PasswordAuth(props: Props) { - const { open, authData, authenticating, onCancel, onLogin } = props; - - return ( - - {authData && ( - - )} - - ); -} diff --git a/gpgui/src/services/authService.ts b/gpgui/src/services/authService.ts index 6973d81..25c6af1 100644 --- a/gpgui/src/services/authService.ts +++ b/gpgui/src/services/authService.ts @@ -1,7 +1,7 @@ import { emit, listen } from "@tauri-apps/api/event"; import invokeCommand from "../utils/invokeCommand"; -type AuthData = { +export type AuthData = { username: string; prelogin_cookie: string | null; portal_userauthcookie: string | null; @@ -22,6 +22,9 @@ class AuthService { onAuthError(callback: () => void) { this.authErrorCallback = callback; + return () => { + this.authErrorCallback = undefined; + }; } // binding: "POST" | "REDIRECT" diff --git a/gpgui/src/services/gatewayService.ts b/gpgui/src/services/gatewayService.ts index 41c31c7..12e611b 100644 --- a/gpgui/src/services/gatewayService.ts +++ b/gpgui/src/services/gatewayService.ts @@ -1,6 +1,5 @@ import { Body, ResponseType, fetch } from "@tauri-apps/api/http"; import { parseXml } from "../utils/parseXml"; -import { Gateway } from "./types"; type LoginParams = { user: string; @@ -10,13 +9,13 @@ type LoginParams = { }; class GatewayService { - async login(gateway: Gateway, params: LoginParams) { + async login(gateway: string, params: LoginParams) { const { user, passwd, userAuthCookie, prelogonUserAuthCookie } = params; - if (!gateway.address) { + if (!gateway) { throw new Error("Gateway address is required"); } - const loginUrl = `https://${gateway.address}/ssl-vpn/login.esp`; + const loginUrl = `https://${gateway}/ssl-vpn/login.esp`; const body = Body.form({ prot: "https:", inputStr: "", @@ -28,7 +27,7 @@ class GatewayService { clientVer: "4100", clientos: "Linux", "os-version": "Linux", - server: gateway.address, + server: gateway, user, passwd: passwd || "", "prelogin-cookie": "", @@ -36,8 +35,6 @@ class GatewayService { "portal-prelogonuserauthcookie": prelogonUserAuthCookie || "", }); - console.log("Login body", body); - const response = await fetch(loginUrl, { method: "POST", headers: { diff --git a/gpgui/src/services/portalService.ts b/gpgui/src/services/portalService.ts index d5fed33..87147d2 100644 --- a/gpgui/src/services/portalService.ts +++ b/gpgui/src/services/portalService.ts @@ -1,34 +1,31 @@ import { Body, ResponseType, fetch } from "@tauri-apps/api/http"; -import { Maybe, MaybeProperties } from "../types"; import { parseXml } from "../utils/parseXml"; import { Gateway } from "./types"; -type SamlPreloginResponse = { +export type SamlPrelogin = { + isSamlAuth: true; samlAuthMethod: string; - samlAuthRequest: string; -}; - -type PasswordPreloginResponse = { - labelUsername: string; - labelPassword: string; - authMessage: Maybe; -}; - -type Region = { + samlRequest: string; region: string; }; -type PreloginResponse = MaybeProperties< - SamlPreloginResponse & PasswordPreloginResponse & Region ->; +export type PasswordPrelogin = { + isSamlAuth: false; + authMessage: string; + labelUsername: string; + labelPassword: string; + region: string; +}; -type ConfigResponse = { - userAuthCookie: Maybe; - prelogonUserAuthCookie: Maybe; +export type Prelogin = SamlPrelogin | PasswordPrelogin; + +export type PortalConfig = { + userAuthCookie: string; + prelogonUserAuthCookie: string; gateways: Gateway[]; }; -type PortalConfigParams = { +export type PortalConfigParams = { user: string; passwd?: string | null; "prelogin-cookie"?: string | null; @@ -37,54 +34,75 @@ type PortalConfigParams = { }; class PortalService { - async prelogin(portal: string) { + async prelogin(portal: string): Promise { const preloginUrl = `https://${portal}/global-protect/prelogin.esp`; + try { + const response = await fetch(preloginUrl, { + method: "POST", + headers: { + "User-Agent": "PAN GlobalProtect", + }, + responseType: ResponseType.Text, + query: { + "kerberos-support": "yes", + }, + body: Body.form({ + tmp: "tmp", + clientVer: "4100", + clientos: "Linux", + "os-version": "Linux", + "ipv6-support": "yes", + "default-browser": "0", + "cas-support": "yes", + // "host-id": "TODO, mac address?", + }), + }); - const response = await fetch(preloginUrl, { - method: "GET", - headers: { - "User-Agent": "PAN GlobalProtect", - }, - responseType: ResponseType.Text, - query: { - tmp: "tmp", - "kerberos-support": "yes", - "ipv6-support": "yes", - clientVer: "4100", - clientos: "Linux", - }, - }); - - if (!response.ok) { - throw new Error(`Failed to connect to portal: ${response.status}`); + if (!response.ok) { + throw new Error(`Failed to prelogin: ${response.status}`); + } + return this.parsePrelogin(response.data); + } catch (err) { + throw new Error(`Failed to prelogin: Network error`); } - return this.parsePreloginResponse(response.data); } - private parsePreloginResponse(response: string): PreloginResponse { + private parsePrelogin(response: string): Prelogin { const doc = parseXml(response); + const status = doc.text("status").toUpperCase(); - return { - samlAuthMethod: doc.text("saml-auth-method").toUpperCase(), - samlAuthRequest: atob(doc.text("saml-request")), - labelUsername: doc.text("username-label"), - labelPassword: doc.text("password-label"), - authMessage: doc.text("authentication-message"), - region: doc.text("region"), - }; - } - - isSamlAuth(response: PreloginResponse): response is SamlPreloginResponse { - return !!(response.samlAuthMethod && response.samlAuthRequest); - } - - isPasswordAuth( - response: PreloginResponse - ): response is PasswordPreloginResponse { - if (response.labelUsername && response.labelPassword) { - return true; + if (status !== "SUCCESS") { + const message = doc.text("msg") || "Unknown error"; + throw new Error(message); } - return false; + + const samlAuthMethod = doc.text("saml-auth-method").toUpperCase(); + const samlRequest = doc.text("saml-request"); + const labelUsername = doc.text("username-label"); + const labelPassword = doc.text("password-label"); + const authMessage = doc.text("authentication-message"); + const region = doc.text("region"); + + if (samlAuthMethod && samlRequest) { + return { + isSamlAuth: true, + samlAuthMethod, + samlRequest: atob(samlRequest), + region, + }; + } + + if (labelUsername && labelPassword) { + return { + isSamlAuth: false, + authMessage, + labelUsername, + labelPassword, + region, + }; + } + + throw new Error("Unknown prelogin response"); } async fetchConfig(portal: string, params: PortalConfigParams) { @@ -133,7 +151,7 @@ class PortalService { return this.parsePortalConfigResponse(response.data); } - private parsePortalConfigResponse(response: string): ConfigResponse { + private parsePortalConfigResponse(response: string): PortalConfig { console.log(response); const result = parseXml(response); @@ -164,7 +182,7 @@ class PortalService { }; } - preferredGateway(gateways: Gateway[], region: Maybe) { + preferredGateway(gateways: Gateway[], region: string) { console.log(gateways); let defaultGateway = gateways[0]; for (const gateway of gateways) { diff --git a/gpgui/src/services/types.ts b/gpgui/src/services/types.ts index 640964d..bdfc6bb 100644 --- a/gpgui/src/services/types.ts +++ b/gpgui/src/services/types.ts @@ -1,13 +1,11 @@ -import { Maybe } from '../types'; - type PriorityRule = { - name: Maybe; + name: string; priority: number; }; export type Gateway = { - name: Maybe; - address: Maybe; + name: string; + address: string; priorityRules: PriorityRule[]; priority: number; }; diff --git a/gpgui/src/types.ts b/gpgui/src/types.ts deleted file mode 100644 index 254ba00..0000000 --- a/gpgui/src/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type Maybe = T | null | undefined; - -export type MaybeProperties = { - [P in keyof T]?: Maybe; -}; diff --git a/gpgui/src/utils/invokeCommand.ts b/gpgui/src/utils/invokeCommand.ts index ea9cdb3..fb97d8d 100644 --- a/gpgui/src/utils/invokeCommand.ts +++ b/gpgui/src/utils/invokeCommand.ts @@ -4,6 +4,7 @@ export default async function invokeCommand(command: string, args?: any) { try { return await invoke(command, args); } catch (err: any) { - throw new Error(err.message); + const message = err?.message ?? "Unknown error"; + throw new Error(message); } }