mirror of
https://github.com/yuezk/GlobalProtect-openconnect.git
synced 2025-04-02 18:31:50 -04:00
refactor: refactor UI using jotai
This commit is contained in:
parent
c07e232ec2
commit
1af21432d4
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user