mirror of
				https://github.com/yuezk/GlobalProtect-openconnect.git
				synced 2025-05-20 07:26:58 -04:00 
			
		
		
		
	refactor: refactor UI using jotai
This commit is contained in:
		| @@ -13,3 +13,9 @@ indent_size = 4 | |||||||
|  |  | ||||||
| [*.{c,h}] | [*.{c,h}] | ||||||
| indent_size = 4 | indent_size = 4 | ||||||
|  |  | ||||||
|  | [*.{js,jsx,ts,tsx}] | ||||||
|  | indent_size = 2 | ||||||
|  |  | ||||||
|  | [*.md] | ||||||
|  | trim_trailing_whitespace = false | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @@ -1,8 +1,11 @@ | |||||||
| { | { | ||||||
|   "cSpell.words": [ |   "cSpell.words": [ | ||||||
|  |     "authcookie", | ||||||
|     "bindgen", |     "bindgen", | ||||||
|  |     "clickaway", | ||||||
|     "clientos", |     "clientos", | ||||||
|     "gpcommon", |     "gpcommon", | ||||||
|  |     "Immer", | ||||||
|     "jnlp", |     "jnlp", | ||||||
|     "oneshot", |     "oneshot", | ||||||
|     "openconnect", |     "openconnect", | ||||||
|   | |||||||
| @@ -15,6 +15,11 @@ | |||||||
|     "@mui/lab": "5.0.0-alpha.125", |     "@mui/lab": "5.0.0-alpha.125", | ||||||
|     "@mui/material": "^5.11.11", |     "@mui/material": "^5.11.11", | ||||||
|     "@tauri-apps/api": "^1.3.0", |     "@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": "^18.2.0", | ||||||
|     "react-dom": "^18.2.0", |     "react-dom": "^18.2.0", | ||||||
|     "react-spinners": "^0.13.8", |     "react-spinners": "^0.13.8", | ||||||
|   | |||||||
							
								
								
									
										63
									
								
								gpgui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										63
									
								
								gpgui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -1,4 +1,8 @@ | |||||||
| lockfileVersion: '6.0' | lockfileVersion: '6.1' | ||||||
|  |  | ||||||
|  | settings: | ||||||
|  |   autoInstallPeers: true | ||||||
|  |   excludeLinksFromLockfile: false | ||||||
|  |  | ||||||
| dependencies: | dependencies: | ||||||
|   '@emotion/react': |   '@emotion/react': | ||||||
| @@ -19,6 +23,21 @@ dependencies: | |||||||
|   '@tauri-apps/api': |   '@tauri-apps/api': | ||||||
|     specifier: ^1.3.0 |     specifier: ^1.3.0 | ||||||
|     version: 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: |   react: | ||||||
|     specifier: ^18.2.0 |     specifier: ^18.2.0 | ||||||
|     version: 18.2.0 |     version: 18.2.0 | ||||||
| @@ -1146,6 +1165,10 @@ packages: | |||||||
|       react-is: 16.13.1 |       react-is: 16.13.1 | ||||||
|     dev: false |     dev: false | ||||||
|  |  | ||||||
|  |   /immer@10.0.2: | ||||||
|  |     resolution: {integrity: sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==} | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /import-fresh@3.3.0: |   /import-fresh@3.3.0: | ||||||
|     resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} |     resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} | ||||||
|     engines: {node: '>=6'} |     engines: {node: '>=6'} | ||||||
| @@ -1163,6 +1186,40 @@ packages: | |||||||
|     dependencies: |     dependencies: | ||||||
|       has: 1.0.3 |       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: |   /js-tokens@4.0.0: | ||||||
|     resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} |     resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} | ||||||
|     dev: false |     dev: false | ||||||
| @@ -1193,6 +1250,10 @@ packages: | |||||||
|     engines: {node: '>=0.10.0'} |     engines: {node: '>=0.10.0'} | ||||||
|     dev: false |     dev: false | ||||||
|  |  | ||||||
|  |   /optics-ts@2.4.0: | ||||||
|  |     resolution: {integrity: sha512-BIYgnqOTEf+WiXuxuBFXeoCtyIDOwnUwCMybdQh8qdHyWXunwVVt7iD9XwNq8SCd5vUo9vqgYxF5ati/6inIuQ==} | ||||||
|  |     dev: false | ||||||
|  |  | ||||||
|   /parent-module@1.0.1: |   /parent-module@1.0.1: | ||||||
|     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} |     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} | ||||||
|     engines: {node: '>=6'} |     engines: {node: '>=6'} | ||||||
|   | |||||||
| @@ -364,6 +364,11 @@ fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Option<Au | |||||||
|  |  | ||||||
| /// Read the authentication data from the HTML content | /// Read the authentication data from the HTML content | ||||||
| fn read_auth_data_from_html(html: &str) -> Result<AuthData, AuthError> { | fn read_auth_data_from_html(html: &str) -> Result<AuthData, AuthError> { | ||||||
|  |     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"); |     let saml_auth_status = parse_xml_tag(html, "saml-auth-status"); | ||||||
|  |  | ||||||
|     match saml_auth_status { |     match saml_auth_status { | ||||||
|   | |||||||
| @@ -1,271 +1,16 @@ | |||||||
| import { WebviewWindow } from "@tauri-apps/api/window"; | import { Box } from "@mui/material"; | ||||||
| import { Box, TextField } from "@mui/material"; | import ConnectForm from "./components/ConnectForm"; | ||||||
| import Button from "@mui/material/Button"; | import ConnectionStatus from "./components/ConnectionStatus"; | ||||||
| import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react"; | import Feedback from "./components/Feedback"; | ||||||
|  | import Notification from "./components/Notification"; | ||||||
| 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"; |  | ||||||
|  |  | ||||||
| export default function App() { | export default function App() { | ||||||
|   const [portalAddress, setPortalAddress] = useState("vpn.microstrategy.com"); // useState("220.191.185.154"); |  | ||||||
|   const [status, setStatus] = useState<Status>("disconnected"); |  | ||||||
|   const [processing, setProcessing] = useState(false); |  | ||||||
|   const [passwordAuthOpen, setPasswordAuthOpen] = useState(false); |  | ||||||
|   const [passwordAuthenticating, setPasswordAuthenticating] = useState(false); |  | ||||||
|   const [passwordAuth, setPasswordAuth] = useState<PasswordAuthData>(); |  | ||||||
|   const [notification, setNotification] = useState<NotificationConfig>({ |  | ||||||
|     open: false, |  | ||||||
|     message: "", |  | ||||||
|   }); |  | ||||||
|   const regionRef = useRef<Maybe<string>>(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<HTMLInputElement>) { |  | ||||||
|     const { value } = e.target; |  | ||||||
|     setPortalAddress(value.trim()); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   async function handleConnect(e: FormEvent<HTMLFormElement>) { |  | ||||||
|     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 ( |   return ( | ||||||
|     <Box padding={2} paddingTop={3}> |     <Box padding={2} paddingTop={3}> | ||||||
|       <ConnectionStatus |       <ConnectionStatus /> | ||||||
|         sx={{ mb: 2 }} |       <ConnectForm /> | ||||||
|         status={processing ? "processing" : status} |       <Feedback /> | ||||||
|       /> |       <Notification /> | ||||||
|  |  | ||||||
|       <form onSubmit={handleConnect}> |  | ||||||
|         <TextField |  | ||||||
|           autoFocus |  | ||||||
|           label="Portal address" |  | ||||||
|           placeholder="Hostname or IP address" |  | ||||||
|           fullWidth |  | ||||||
|           size="small" |  | ||||||
|           value={portalAddress} |  | ||||||
|           onChange={handlePortalChange} |  | ||||||
|           InputProps={{ readOnly: status !== "disconnected" }} |  | ||||||
|         /> |  | ||||||
|         <Box sx={{ mt: 1.5 }}> |  | ||||||
|           {status === "disconnected" && ( |  | ||||||
|             <Button |  | ||||||
|               type="submit" |  | ||||||
|               variant="contained" |  | ||||||
|               fullWidth |  | ||||||
|               sx={{ textTransform: "none" }} |  | ||||||
|             > |  | ||||||
|               Connect |  | ||||||
|             </Button> |  | ||||||
|           )} |  | ||||||
|           {["processing", "authenticating", "connecting"].includes(status) && ( |  | ||||||
|             <Button |  | ||||||
|               variant="outlined" |  | ||||||
|               fullWidth |  | ||||||
|               disabled={status === "authenticating"} |  | ||||||
|               onClick={handleCancel} |  | ||||||
|               sx={{ textTransform: "none" }} |  | ||||||
|             > |  | ||||||
|               Cancel |  | ||||||
|             </Button> |  | ||||||
|           )} |  | ||||||
|           {status === "connected" && ( |  | ||||||
|             <Button |  | ||||||
|               variant="contained" |  | ||||||
|               fullWidth |  | ||||||
|               onClick={handleDisconnect} |  | ||||||
|               sx={{ textTransform: "none" }} |  | ||||||
|             > |  | ||||||
|               Disconnect |  | ||||||
|             </Button> |  | ||||||
|           )} |  | ||||||
|         </Box> |  | ||||||
|       </form> |  | ||||||
|  |  | ||||||
|       <PasswordAuth |  | ||||||
|         open={passwordAuthOpen} |  | ||||||
|         authData={passwordAuth} |  | ||||||
|         authenticating={passwordAuthenticating} |  | ||||||
|         onCancel={cancelPasswordAuth} |  | ||||||
|         onLogin={handlePasswordAuth} |  | ||||||
|       /> |  | ||||||
|       <Notification {...notification} onClose={closeNotification} /> |  | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										56
									
								
								gpgui/src/atoms/gateway.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								gpgui/src/atoms/gateway.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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"); | ||||||
|  |   } | ||||||
|  | }); | ||||||
							
								
								
									
										36
									
								
								gpgui/src/atoms/notification.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								gpgui/src/atoms/notification.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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", | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										225
									
								
								gpgui/src/atoms/portal.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								gpgui/src/atoms/portal.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<string, Credential>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const appAtom = atom<AppData>({ | ||||||
|  |   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<PasswordPrelogin>({ | ||||||
|  |   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, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
							
								
								
									
										44
									
								
								gpgui/src/atoms/status.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								gpgui/src/atoms/status.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Status>("disconnected"); | ||||||
|  | statusAtom.onMount = (setAtom) => { | ||||||
|  |   return vpnService.onStatusChanged((status) => { | ||||||
|  |     status === "connected" && setAtom("connected"); | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const statusTextMap: Record<Status, String> = { | ||||||
|  |   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"; | ||||||
|  | }); | ||||||
							
								
								
									
										78
									
								
								gpgui/src/components/ConnectForm/PasswordAuth.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								gpgui/src/components/ConnectForm/PasswordAuth.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<HTMLDivElement>(null); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (visible) { | ||||||
|  |       setTimeout(() => { | ||||||
|  |         usernameRef.current?.querySelector("input")?.focus(); | ||||||
|  |       }, 0); | ||||||
|  |     } | ||||||
|  |   }, [visible]); | ||||||
|  |  | ||||||
|  |   function handleSubmit(e: FormEvent<HTMLFormElement>) { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     passwordLogin(username, password); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Drawer open={visible} anchor="bottom" variant="temporary"> | ||||||
|  |       <form onSubmit={handleSubmit}> | ||||||
|  |         <Box display="flex" flexDirection="column" gap={1.5} padding={2}> | ||||||
|  |           <Typography>{authMessage}</Typography> | ||||||
|  |           <TextField | ||||||
|  |             ref={usernameRef} | ||||||
|  |             label={labelUsername} | ||||||
|  |             size="small" | ||||||
|  |             value={username} | ||||||
|  |             onChange={(e) => setUsername(e.target.value.trim())} | ||||||
|  |             InputProps={{ readOnly: loading }} | ||||||
|  |           /> | ||||||
|  |           <TextField | ||||||
|  |             label={labelPassword} | ||||||
|  |             size="small" | ||||||
|  |             type="password" | ||||||
|  |             value={password} | ||||||
|  |             onChange={(e) => setPassword(e.target.value)} | ||||||
|  |             InputProps={{ readOnly: loading }} | ||||||
|  |           /> | ||||||
|  |           <Box display="flex" gap={1.5}> | ||||||
|  |             <Button | ||||||
|  |               variant="outlined" | ||||||
|  |               onClick={cancelPasswordAuth} | ||||||
|  |               sx={{ flex: 1, textTransform: "none" }} | ||||||
|  |             > | ||||||
|  |               Cancel | ||||||
|  |             </Button> | ||||||
|  |             <LoadingButton | ||||||
|  |               variant="contained" | ||||||
|  |               type="submit" | ||||||
|  |               loading={loading} | ||||||
|  |               disabled={loading} | ||||||
|  |               sx={{ flex: 1, textTransform: "none" }} | ||||||
|  |             > | ||||||
|  |               Login | ||||||
|  |             </LoadingButton> | ||||||
|  |           </Box> | ||||||
|  |         </Box> | ||||||
|  |       </form> | ||||||
|  |     </Drawer> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										70
									
								
								gpgui/src/components/ConnectForm/PortalForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								gpgui/src/components/ConnectForm/PortalForm.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<HTMLFormElement>) { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     connectPortal(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <form onSubmit={handleSubmit}> | ||||||
|  |       <TextField | ||||||
|  |         autoFocus | ||||||
|  |         label="Portal address" | ||||||
|  |         placeholder="Hostname or IP address" | ||||||
|  |         fullWidth | ||||||
|  |         size="small" | ||||||
|  |         value={portal} | ||||||
|  |         onChange={(e) => setPortal(e.target.value.trim())} | ||||||
|  |         InputProps={{ readOnly: status !== "disconnected" }} | ||||||
|  |         sx={{ mb: 1 }} | ||||||
|  |       /> | ||||||
|  |       {status === "disconnected" && ( | ||||||
|  |         <Button | ||||||
|  |           fullWidth | ||||||
|  |           type="submit" | ||||||
|  |           variant="contained" | ||||||
|  |           sx={{ textTransform: "none" }} | ||||||
|  |         > | ||||||
|  |           Connect | ||||||
|  |         </Button> | ||||||
|  |       )} | ||||||
|  |       {processing && ( | ||||||
|  |         <Button | ||||||
|  |           fullWidth | ||||||
|  |           variant="outlined" | ||||||
|  |           disabled={status === "authenticating-saml"} | ||||||
|  |           onClick={cancelConnectPortal} | ||||||
|  |           sx={{ textTransform: "none" }} | ||||||
|  |         > | ||||||
|  |           Cancel | ||||||
|  |         </Button> | ||||||
|  |       )} | ||||||
|  |       {status === "connected" && ( | ||||||
|  |         <Button | ||||||
|  |           fullWidth | ||||||
|  |           variant="contained" | ||||||
|  |           onClick={disconnectVpn} | ||||||
|  |           sx={{ textTransform: "none" }} | ||||||
|  |         > | ||||||
|  |           Disconnect | ||||||
|  |         </Button> | ||||||
|  |       )} | ||||||
|  |     </form> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								gpgui/src/components/ConnectForm/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								gpgui/src/components/ConnectForm/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | import PasswordAuth from "./PasswordAuth"; | ||||||
|  | import PortalForm from "./PortalForm"; | ||||||
|  |  | ||||||
|  | export default function ConnectForm() { | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <PortalForm /> | ||||||
|  |       <PasswordAuth /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -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<Status, string> = { |  | ||||||
|   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<Status, string> = { |  | ||||||
|     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 ( |  | ||||||
|     <Box {...props}> |  | ||||||
|       <Box |  | ||||||
|         sx={{ |  | ||||||
|           textAlign: "center", |  | ||||||
|           position: "relative", |  | ||||||
|           width: 150, |  | ||||||
|           height: 150, |  | ||||||
|           mx: "auto", |  | ||||||
|           display: "flex", |  | ||||||
|           alignItems: "center", |  | ||||||
|           justifyContent: "center", |  | ||||||
|         }} |  | ||||||
|       > |  | ||||||
|         <CircularProgress |  | ||||||
|           size={150} |  | ||||||
|           thickness={1} |  | ||||||
|           value={pending ? undefined : 100} |  | ||||||
|           variant={pending ? "indeterminate" : "determinate"} |  | ||||||
|           sx={{ |  | ||||||
|             position: "absolute", |  | ||||||
|             top: 0, |  | ||||||
|             left: 0, |  | ||||||
|             color: colorsMap[status], |  | ||||||
|             "& circle": { |  | ||||||
|               fill: colorsMap[status], |  | ||||||
|               fillOpacity: pending ? 0.1 : 0.25, |  | ||||||
|               transition: "all 0.3s ease", |  | ||||||
|             }, |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|         {pending && <BeatLoader color={colorsMap[status]} />} |  | ||||||
|  |  | ||||||
|         {connected && ( |  | ||||||
|           <VerifiedIcon |  | ||||||
|             sx={{ |  | ||||||
|               position: "relative", |  | ||||||
|               fontSize: 80, |  | ||||||
|               color: colorsMap[status], |  | ||||||
|             }} |  | ||||||
|           /> |  | ||||||
|         )} |  | ||||||
|  |  | ||||||
|         {disconnected && ( |  | ||||||
|           <GppBadIcon |  | ||||||
|             color="disabled" |  | ||||||
|             sx={{ |  | ||||||
|               fontSize: 80, |  | ||||||
|               color: colorsMap[status], |  | ||||||
|             }} |  | ||||||
|           /> |  | ||||||
|         )} |  | ||||||
|       </Box> |  | ||||||
|  |  | ||||||
|       <Typography textAlign="center" mt={1.5} variant="subtitle1" paragraph> |  | ||||||
|         {statusTextMap[status]} |  | ||||||
|       </Typography> |  | ||||||
|     </Box> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
							
								
								
									
										93
									
								
								gpgui/src/components/ConnectionStatus/StatusIcon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								gpgui/src/components/ConnectionStatus/StatusIcon.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||||
|  |     <CircularProgress | ||||||
|  |       size={150} | ||||||
|  |       thickness={1} | ||||||
|  |       value={processing ? undefined : 100} | ||||||
|  |       variant={processing ? "indeterminate" : "determinate"} | ||||||
|  |       sx={{ | ||||||
|  |         position: "absolute", | ||||||
|  |         top: 0, | ||||||
|  |         left: 0, | ||||||
|  |         color, | ||||||
|  |         "& circle": { | ||||||
|  |           fill: color, | ||||||
|  |           fillOpacity: processing ? 0.1 : 0.25, | ||||||
|  |           transition: "all 0.3s ease", | ||||||
|  |         }, | ||||||
|  |       }} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const DisconnectedIcon = styled(GppBad)(({ theme }) => ({ | ||||||
|  |   position: "relative", | ||||||
|  |   fontSize: 90, | ||||||
|  |   color: theme.palette.action.disabled, | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | function ProcessingIcon() { | ||||||
|  |   const theme = useTheme(); | ||||||
|  |   return <BeatLoader color={theme.palette.info.main} />; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 ( | ||||||
|  |     <IconContainer> | ||||||
|  |       <BackgroundIcon /> | ||||||
|  |       {status === "disconnected" && <DisconnectedIcon />} | ||||||
|  |       {processing && <ProcessingIcon />} | ||||||
|  |       {status === "connected" && <ConnectedIcon />} | ||||||
|  |     </IconContainer> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								gpgui/src/components/ConnectionStatus/StatusText.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								gpgui/src/components/ConnectionStatus/StatusText.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||||
|  |     <Typography textAlign="center" mt={1.5} variant="subtitle1" paragraph> | ||||||
|  |       {statusText} | ||||||
|  |     </Typography> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								gpgui/src/components/ConnectionStatus/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								gpgui/src/components/ConnectionStatus/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | import { Box } from "@mui/material"; | ||||||
|  | import StatusIcon from "./StatusIcon"; | ||||||
|  | import StatusText from "./StatusText"; | ||||||
|  |  | ||||||
|  | export default function ConnectionStatus() { | ||||||
|  |   return ( | ||||||
|  |     <Box> | ||||||
|  |       <StatusIcon /> | ||||||
|  |       <StatusText /> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								gpgui/src/components/Feedback/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								gpgui/src/components/Feedback/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | export default function Feedback() { | ||||||
|  |     return <div>Feedback</div> | ||||||
|  | } | ||||||
| @@ -1,68 +0,0 @@ | |||||||
| import { |  | ||||||
|   Alert, |  | ||||||
|   AlertColor, |  | ||||||
|   AlertTitle, |  | ||||||
|   Slide, |  | ||||||
|   SlideProps, |  | ||||||
|   Snackbar, |  | ||||||
|   SnackbarCloseReason, |  | ||||||
| } from "@mui/material"; |  | ||||||
|  |  | ||||||
| type TransitionProps = Omit<SlideProps, "direction">; |  | ||||||
|  |  | ||||||
| function TransitionDown(props: TransitionProps) { |  | ||||||
|   return <Slide {...props} direction="down" />; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| 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 ( |  | ||||||
|     <Snackbar |  | ||||||
|       open={open} |  | ||||||
|       anchorOrigin={{ vertical: "top", horizontal: "center" }} |  | ||||||
|       autoHideDuration={5000} |  | ||||||
|       TransitionComponent={TransitionDown} |  | ||||||
|       onClose={handleClose} |  | ||||||
|       sx={{ |  | ||||||
|         top: 0, |  | ||||||
|         left: 0, |  | ||||||
|         right: 0, |  | ||||||
|       }} |  | ||||||
|     > |  | ||||||
|       <Alert |  | ||||||
|         severity={type} |  | ||||||
|         icon={false} |  | ||||||
|         sx={{ |  | ||||||
|           width: "100%", |  | ||||||
|           borderRadius: 0, |  | ||||||
|         }} |  | ||||||
|       > |  | ||||||
|         {title && <AlertTitle>{title}</AlertTitle>} |  | ||||||
|         {message} |  | ||||||
|       </Alert> |  | ||||||
|     </Snackbar> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
							
								
								
									
										43
									
								
								gpgui/src/components/Notification/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								gpgui/src/components/Notification/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<SlideProps, "direction">; | ||||||
|  | function TransitionDown(props: TransitionProps) { | ||||||
|  |   return <Slide {...props} direction="down" />; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default function Notification() { | ||||||
|  |   const { title, message, severity } = useAtomValue(notificationConfigAtom); | ||||||
|  |   const [visible, closeNotification] = useAtom(closeNotificationAtom); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Snackbar | ||||||
|  |       open={visible} | ||||||
|  |       anchorOrigin={{ vertical: "top", horizontal: "center" }} | ||||||
|  |       autoHideDuration={5000} | ||||||
|  |       TransitionComponent={TransitionDown} | ||||||
|  |       onClose={closeNotification} | ||||||
|  |       sx={{ | ||||||
|  |         top: 0, | ||||||
|  |         left: 0, | ||||||
|  |         right: 0, | ||||||
|  |       }} | ||||||
|  |     > | ||||||
|  |       <Alert | ||||||
|  |         severity={severity} | ||||||
|  |         icon={false} | ||||||
|  |         sx={{ | ||||||
|  |           width: "100%", | ||||||
|  |           borderRadius: 0, | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         {title && <AlertTitle>{title}</AlertTitle>} | ||||||
|  |         {message} | ||||||
|  |       </Alert> | ||||||
|  |     </Snackbar> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -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<string>; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| 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<HTMLDivElement>(null); |  | ||||||
|  |  | ||||||
|   useEffect(() => { |  | ||||||
|     inputRef.current?.querySelector("input")?.focus(); |  | ||||||
|   }, []); |  | ||||||
|  |  | ||||||
|   const { |  | ||||||
|     authenticating, |  | ||||||
|     authMessage, |  | ||||||
|     labelUsername, |  | ||||||
|     labelPassword, |  | ||||||
|     onCancel, |  | ||||||
|     onSubmit, |  | ||||||
|   } = props; |  | ||||||
|  |  | ||||||
|   function handleSubmit(e: FormEvent<HTMLFormElement>) { |  | ||||||
|     e.preventDefault(); |  | ||||||
|  |  | ||||||
|     if (username.trim() === "" || password === "") { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     onSubmit({ username, password }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return ( |  | ||||||
|     <form onSubmit={handleSubmit}> |  | ||||||
|       <Box display="flex" flexDirection="column" gap={1.5} padding={2}> |  | ||||||
|         <Typography>{authMessage}</Typography> |  | ||||||
|         <TextField |  | ||||||
|           ref={inputRef} |  | ||||||
|           label={labelUsername} |  | ||||||
|           size="small" |  | ||||||
|           autoFocus |  | ||||||
|           value={username} |  | ||||||
|           InputProps={{ readOnly: authenticating }} |  | ||||||
|           onChange={(e) => setUsername(e.target.value)} |  | ||||||
|         /> |  | ||||||
|         <TextField |  | ||||||
|           label={labelPassword} |  | ||||||
|           size="small" |  | ||||||
|           type="password" |  | ||||||
|           value={password} |  | ||||||
|           InputProps={{ readOnly: authenticating }} |  | ||||||
|           onChange={(e) => setPassword(e.target.value)} |  | ||||||
|         /> |  | ||||||
|         <Box display="flex" gap={1.5}> |  | ||||||
|           <Button |  | ||||||
|             variant="outlined" |  | ||||||
|             sx={{ flex: 1, textTransform: "none" }} |  | ||||||
|             onClick={onCancel} |  | ||||||
|           > |  | ||||||
|             Cancel |  | ||||||
|           </Button> |  | ||||||
|           <LoadingButton |  | ||||||
|             loading={authenticating} |  | ||||||
|             variant="contained" |  | ||||||
|             sx={{ flex: 1, textTransform: "none" }} |  | ||||||
|             type="submit" |  | ||||||
|             disabled={authenticating} |  | ||||||
|           > |  | ||||||
|             Login |  | ||||||
|           </LoadingButton> |  | ||||||
|         </Box> |  | ||||||
|       </Box> |  | ||||||
|     </form> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export default function PasswordAuth(props: Props) { |  | ||||||
|   const { open, authData, authenticating, onCancel, onLogin } = props; |  | ||||||
|  |  | ||||||
|   return ( |  | ||||||
|     <Drawer anchor="bottom" variant="temporary" open={open}> |  | ||||||
|       {authData && ( |  | ||||||
|         <AuthForm |  | ||||||
|           {...authData} |  | ||||||
|           authenticating={authenticating} |  | ||||||
|           onCancel={onCancel} |  | ||||||
|           onSubmit={onLogin} |  | ||||||
|         /> |  | ||||||
|       )} |  | ||||||
|     </Drawer> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| import { emit, listen } from "@tauri-apps/api/event"; | import { emit, listen } from "@tauri-apps/api/event"; | ||||||
| import invokeCommand from "../utils/invokeCommand"; | import invokeCommand from "../utils/invokeCommand"; | ||||||
|  |  | ||||||
| type AuthData = { | export type AuthData = { | ||||||
|   username: string; |   username: string; | ||||||
|   prelogin_cookie: string | null; |   prelogin_cookie: string | null; | ||||||
|   portal_userauthcookie: string | null; |   portal_userauthcookie: string | null; | ||||||
| @@ -22,6 +22,9 @@ class AuthService { | |||||||
|  |  | ||||||
|   onAuthError(callback: () => void) { |   onAuthError(callback: () => void) { | ||||||
|     this.authErrorCallback = callback; |     this.authErrorCallback = callback; | ||||||
|  |     return () => { | ||||||
|  |       this.authErrorCallback = undefined; | ||||||
|  |     }; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // binding: "POST" | "REDIRECT" |   // binding: "POST" | "REDIRECT" | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| import { Body, ResponseType, fetch } from "@tauri-apps/api/http"; | import { Body, ResponseType, fetch } from "@tauri-apps/api/http"; | ||||||
| import { parseXml } from "../utils/parseXml"; | import { parseXml } from "../utils/parseXml"; | ||||||
| import { Gateway } from "./types"; |  | ||||||
|  |  | ||||||
| type LoginParams = { | type LoginParams = { | ||||||
|   user: string; |   user: string; | ||||||
| @@ -10,13 +9,13 @@ type LoginParams = { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| class GatewayService { | class GatewayService { | ||||||
|   async login(gateway: Gateway, params: LoginParams) { |   async login(gateway: string, params: LoginParams) { | ||||||
|     const { user, passwd, userAuthCookie, prelogonUserAuthCookie } = params; |     const { user, passwd, userAuthCookie, prelogonUserAuthCookie } = params; | ||||||
|     if (!gateway.address) { |     if (!gateway) { | ||||||
|       throw new Error("Gateway address is required"); |       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({ |     const body = Body.form({ | ||||||
|       prot: "https:", |       prot: "https:", | ||||||
|       inputStr: "", |       inputStr: "", | ||||||
| @@ -28,7 +27,7 @@ class GatewayService { | |||||||
|       clientVer: "4100", |       clientVer: "4100", | ||||||
|       clientos: "Linux", |       clientos: "Linux", | ||||||
|       "os-version": "Linux", |       "os-version": "Linux", | ||||||
|       server: gateway.address, |       server: gateway, | ||||||
|       user, |       user, | ||||||
|       passwd: passwd || "", |       passwd: passwd || "", | ||||||
|       "prelogin-cookie": "", |       "prelogin-cookie": "", | ||||||
| @@ -36,8 +35,6 @@ class GatewayService { | |||||||
|       "portal-prelogonuserauthcookie": prelogonUserAuthCookie || "", |       "portal-prelogonuserauthcookie": prelogonUserAuthCookie || "", | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     console.log("Login body", body); |  | ||||||
|  |  | ||||||
|     const response = await fetch<string>(loginUrl, { |     const response = await fetch<string>(loginUrl, { | ||||||
|       method: "POST", |       method: "POST", | ||||||
|       headers: { |       headers: { | ||||||
|   | |||||||
| @@ -1,34 +1,31 @@ | |||||||
| import { Body, ResponseType, fetch } from "@tauri-apps/api/http"; | import { Body, ResponseType, fetch } from "@tauri-apps/api/http"; | ||||||
| import { Maybe, MaybeProperties } from "../types"; |  | ||||||
| import { parseXml } from "../utils/parseXml"; | import { parseXml } from "../utils/parseXml"; | ||||||
| import { Gateway } from "./types"; | import { Gateway } from "./types"; | ||||||
|  |  | ||||||
| type SamlPreloginResponse = { | export type SamlPrelogin = { | ||||||
|  |   isSamlAuth: true; | ||||||
|   samlAuthMethod: string; |   samlAuthMethod: string; | ||||||
|   samlAuthRequest: string; |   samlRequest: string; | ||||||
| }; |  | ||||||
|  |  | ||||||
| type PasswordPreloginResponse = { |  | ||||||
|   labelUsername: string; |  | ||||||
|   labelPassword: string; |  | ||||||
|   authMessage: Maybe<string>; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| type Region = { |  | ||||||
|   region: string; |   region: string; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| type PreloginResponse = MaybeProperties< | export type PasswordPrelogin = { | ||||||
|   SamlPreloginResponse & PasswordPreloginResponse & Region |   isSamlAuth: false; | ||||||
| >; |   authMessage: string; | ||||||
|  |   labelUsername: string; | ||||||
|  |   labelPassword: string; | ||||||
|  |   region: string; | ||||||
|  | }; | ||||||
|  |  | ||||||
| type ConfigResponse = { | export type Prelogin = SamlPrelogin | PasswordPrelogin; | ||||||
|   userAuthCookie: Maybe<string>; |  | ||||||
|   prelogonUserAuthCookie: Maybe<string>; | export type PortalConfig = { | ||||||
|  |   userAuthCookie: string; | ||||||
|  |   prelogonUserAuthCookie: string; | ||||||
|   gateways: Gateway[]; |   gateways: Gateway[]; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| type PortalConfigParams = { | export type PortalConfigParams = { | ||||||
|   user: string; |   user: string; | ||||||
|   passwd?: string | null; |   passwd?: string | null; | ||||||
|   "prelogin-cookie"?: string | null; |   "prelogin-cookie"?: string | null; | ||||||
| @@ -37,54 +34,75 @@ type PortalConfigParams = { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| class PortalService { | class PortalService { | ||||||
|   async prelogin(portal: string) { |   async prelogin(portal: string): Promise<Prelogin> { | ||||||
|     const preloginUrl = `https://${portal}/global-protect/prelogin.esp`; |     const preloginUrl = `https://${portal}/global-protect/prelogin.esp`; | ||||||
|  |     try { | ||||||
|  |       const response = await fetch<string>(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<string>(preloginUrl, { |       if (!response.ok) { | ||||||
|       method: "GET", |         throw new Error(`Failed to prelogin: ${response.status}`); | ||||||
|       headers: { |       } | ||||||
|         "User-Agent": "PAN GlobalProtect", |       return this.parsePrelogin(response.data); | ||||||
|       }, |     } catch (err) { | ||||||
|       responseType: ResponseType.Text, |       throw new Error(`Failed to prelogin: Network error`); | ||||||
|       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}`); |  | ||||||
|     } |     } | ||||||
|     return this.parsePreloginResponse(response.data); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private parsePreloginResponse(response: string): PreloginResponse { |   private parsePrelogin(response: string): Prelogin { | ||||||
|     const doc = parseXml(response); |     const doc = parseXml(response); | ||||||
|  |     const status = doc.text("status").toUpperCase(); | ||||||
|  |  | ||||||
|     return { |     if (status !== "SUCCESS") { | ||||||
|       samlAuthMethod: doc.text("saml-auth-method").toUpperCase(), |       const message = doc.text("msg") || "Unknown error"; | ||||||
|       samlAuthRequest: atob(doc.text("saml-request")), |       throw new Error(message); | ||||||
|       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; |  | ||||||
|     } |     } | ||||||
|     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) { |   async fetchConfig(portal: string, params: PortalConfigParams) { | ||||||
| @@ -133,7 +151,7 @@ class PortalService { | |||||||
|     return this.parsePortalConfigResponse(response.data); |     return this.parsePortalConfigResponse(response.data); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private parsePortalConfigResponse(response: string): ConfigResponse { |   private parsePortalConfigResponse(response: string): PortalConfig { | ||||||
|     console.log(response); |     console.log(response); | ||||||
|  |  | ||||||
|     const result = parseXml(response); |     const result = parseXml(response); | ||||||
| @@ -164,7 +182,7 @@ class PortalService { | |||||||
|     }; |     }; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   preferredGateway(gateways: Gateway[], region: Maybe<string>) { |   preferredGateway(gateways: Gateway[], region: string) { | ||||||
|     console.log(gateways); |     console.log(gateways); | ||||||
|     let defaultGateway = gateways[0]; |     let defaultGateway = gateways[0]; | ||||||
|     for (const gateway of gateways) { |     for (const gateway of gateways) { | ||||||
|   | |||||||
| @@ -1,13 +1,11 @@ | |||||||
| import { Maybe } from '../types'; |  | ||||||
|  |  | ||||||
| type PriorityRule = { | type PriorityRule = { | ||||||
|   name: Maybe<string>; |   name: string; | ||||||
|   priority: number; |   priority: number; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export type Gateway = { | export type Gateway = { | ||||||
|   name: Maybe<string>; |   name: string; | ||||||
|   address: Maybe<string>; |   address: string; | ||||||
|   priorityRules: PriorityRule[]; |   priorityRules: PriorityRule[]; | ||||||
|   priority: number; |   priority: number; | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,5 +0,0 @@ | |||||||
| export type Maybe<T> = T | null | undefined; |  | ||||||
|  |  | ||||||
| export type MaybeProperties<T> = { |  | ||||||
|   [P in keyof T]?: Maybe<T[P]>; |  | ||||||
| }; |  | ||||||
| @@ -4,6 +4,7 @@ export default async function invokeCommand<T>(command: string, args?: any) { | |||||||
|   try { |   try { | ||||||
|     return await invoke<T>(command, args); |     return await invoke<T>(command, args); | ||||||
|   } catch (err: any) { |   } catch (err: any) { | ||||||
|     throw new Error(err.message); |     const message = err?.message ?? "Unknown error"; | ||||||
|  |     throw new Error(message); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user