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}]
|
||||
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": [
|
||||
"authcookie",
|
||||
"bindgen",
|
||||
"clickaway",
|
||||
"clientos",
|
||||
"gpcommon",
|
||||
"Immer",
|
||||
"jnlp",
|
||||
"oneshot",
|
||||
"openconnect",
|
||||
|
@ -15,6 +15,11 @@
|
||||
"@mui/lab": "5.0.0-alpha.125",
|
||||
"@mui/material": "^5.11.11",
|
||||
"@tauri-apps/api": "^1.3.0",
|
||||
"immer": "^10.0.2",
|
||||
"jotai": "^2.1.1",
|
||||
"jotai-immer": "^0.2.0",
|
||||
"jotai-optics": "^0.3.0",
|
||||
"optics-ts": "^2.4.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-spinners": "^0.13.8",
|
||||
|
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:
|
||||
'@emotion/react':
|
||||
@ -19,6 +23,21 @@ dependencies:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
immer:
|
||||
specifier: ^10.0.2
|
||||
version: 10.0.2
|
||||
jotai:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1(react@18.2.0)
|
||||
jotai-immer:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0(immer@10.0.2)(jotai@2.1.1)(react@18.2.0)
|
||||
jotai-optics:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0(jotai@2.1.1)(optics-ts@2.4.0)
|
||||
optics-ts:
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0
|
||||
@ -1146,6 +1165,10 @@ packages:
|
||||
react-is: 16.13.1
|
||||
dev: false
|
||||
|
||||
/immer@10.0.2:
|
||||
resolution: {integrity: sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==}
|
||||
dev: false
|
||||
|
||||
/import-fresh@3.3.0:
|
||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||
engines: {node: '>=6'}
|
||||
@ -1163,6 +1186,40 @@ packages:
|
||||
dependencies:
|
||||
has: 1.0.3
|
||||
|
||||
/jotai-immer@0.2.0(immer@10.0.2)(jotai@2.1.1)(react@18.2.0):
|
||||
resolution: {integrity: sha512-hahK8EPiROS9RoNWmX/Z8rY9WkAijspX4BZ1O7umpcwI4kPNkbcCpu/PhiQ8FMcpEcF6KmbpbMpSSj/GFmo8NA==}
|
||||
peerDependencies:
|
||||
immer: '*'
|
||||
jotai: '>=1.11.0'
|
||||
react: '>=17.0.0'
|
||||
dependencies:
|
||||
immer: 10.0.2
|
||||
jotai: 2.1.1(react@18.2.0)
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/jotai-optics@0.3.0(jotai@2.1.1)(optics-ts@2.4.0):
|
||||
resolution: {integrity: sha512-5ttpCRREIBu6DJix0wlyBP6y1QDPlePnoMZSXNDi/FOkXZrhk9uIXKjwvw34/yBCHT5mYpFUD4sFDvRUU2vkvQ==}
|
||||
peerDependencies:
|
||||
jotai: '>=1.11.0'
|
||||
optics-ts: '*'
|
||||
dependencies:
|
||||
jotai: 2.1.1(react@18.2.0)
|
||||
optics-ts: 2.4.0
|
||||
dev: false
|
||||
|
||||
/jotai@2.1.1(react@18.2.0):
|
||||
resolution: {integrity: sha512-LaaiuSaq+6XkwkrCtCkczyFVZOXe0dfjAFN4DVMsSZSRv/A/4xuLHnlpHMEDqvngjWYBotTIrnQ7OogMkUE6wA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
react: '>=17.0.0'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
dev: false
|
||||
@ -1193,6 +1250,10 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/optics-ts@2.4.0:
|
||||
resolution: {integrity: sha512-BIYgnqOTEf+WiXuxuBFXeoCtyIDOwnUwCMybdQh8qdHyWXunwVVt7iD9XwNq8SCd5vUo9vqgYxF5ati/6inIuQ==}
|
||||
dev: false
|
||||
|
||||
/parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -364,6 +364,11 @@ fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Option<Au
|
||||
|
||||
/// Read the authentication data from the HTML content
|
||||
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");
|
||||
|
||||
match saml_auth_status {
|
||||
|
@ -1,271 +1,16 @@
|
||||
import { WebviewWindow } from "@tauri-apps/api/window";
|
||||
import { Box, TextField } from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
import "./App.css";
|
||||
import ConnectionStatus, { Status } from "./components/ConnectionStatus";
|
||||
import Notification, { NotificationConfig } from "./components/Notification";
|
||||
import PasswordAuth, {
|
||||
Credentials,
|
||||
PasswordAuthData,
|
||||
} from "./components/PasswordAuth";
|
||||
import gatewayService from "./services/gatewayService";
|
||||
import portalService from "./services/portalService";
|
||||
import vpnService from "./services/vpnService";
|
||||
import authService from "./services/authService";
|
||||
import { Maybe } from "./types";
|
||||
import { Box } from "@mui/material";
|
||||
import ConnectForm from "./components/ConnectForm";
|
||||
import ConnectionStatus from "./components/ConnectionStatus";
|
||||
import Feedback from "./components/Feedback";
|
||||
import Notification from "./components/Notification";
|
||||
|
||||
export default function App() {
|
||||
const [portalAddress, setPortalAddress] = useState("vpn.microstrategy.com"); // useState("220.191.185.154");
|
||||
const [status, setStatus] = useState<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 (
|
||||
<Box padding={2} paddingTop={3}>
|
||||
<ConnectionStatus
|
||||
sx={{ mb: 2 }}
|
||||
status={processing ? "processing" : status}
|
||||
/>
|
||||
|
||||
<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} />
|
||||
<ConnectionStatus />
|
||||
<ConnectForm />
|
||||
<Feedback />
|
||||
<Notification />
|
||||
</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 invokeCommand from "../utils/invokeCommand";
|
||||
|
||||
type AuthData = {
|
||||
export type AuthData = {
|
||||
username: string;
|
||||
prelogin_cookie: string | null;
|
||||
portal_userauthcookie: string | null;
|
||||
@ -22,6 +22,9 @@ class AuthService {
|
||||
|
||||
onAuthError(callback: () => void) {
|
||||
this.authErrorCallback = callback;
|
||||
return () => {
|
||||
this.authErrorCallback = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// binding: "POST" | "REDIRECT"
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
|
||||
import { parseXml } from "../utils/parseXml";
|
||||
import { Gateway } from "./types";
|
||||
|
||||
type LoginParams = {
|
||||
user: string;
|
||||
@ -10,13 +9,13 @@ type LoginParams = {
|
||||
};
|
||||
|
||||
class GatewayService {
|
||||
async login(gateway: Gateway, params: LoginParams) {
|
||||
async login(gateway: string, params: LoginParams) {
|
||||
const { user, passwd, userAuthCookie, prelogonUserAuthCookie } = params;
|
||||
if (!gateway.address) {
|
||||
if (!gateway) {
|
||||
throw new Error("Gateway address is required");
|
||||
}
|
||||
|
||||
const loginUrl = `https://${gateway.address}/ssl-vpn/login.esp`;
|
||||
const loginUrl = `https://${gateway}/ssl-vpn/login.esp`;
|
||||
const body = Body.form({
|
||||
prot: "https:",
|
||||
inputStr: "",
|
||||
@ -28,7 +27,7 @@ class GatewayService {
|
||||
clientVer: "4100",
|
||||
clientos: "Linux",
|
||||
"os-version": "Linux",
|
||||
server: gateway.address,
|
||||
server: gateway,
|
||||
user,
|
||||
passwd: passwd || "",
|
||||
"prelogin-cookie": "",
|
||||
@ -36,8 +35,6 @@ class GatewayService {
|
||||
"portal-prelogonuserauthcookie": prelogonUserAuthCookie || "",
|
||||
});
|
||||
|
||||
console.log("Login body", body);
|
||||
|
||||
const response = await fetch<string>(loginUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -1,34 +1,31 @@
|
||||
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
|
||||
import { Maybe, MaybeProperties } from "../types";
|
||||
import { parseXml } from "../utils/parseXml";
|
||||
import { Gateway } from "./types";
|
||||
|
||||
type SamlPreloginResponse = {
|
||||
export type SamlPrelogin = {
|
||||
isSamlAuth: true;
|
||||
samlAuthMethod: string;
|
||||
samlAuthRequest: string;
|
||||
};
|
||||
|
||||
type PasswordPreloginResponse = {
|
||||
labelUsername: string;
|
||||
labelPassword: string;
|
||||
authMessage: Maybe<string>;
|
||||
};
|
||||
|
||||
type Region = {
|
||||
samlRequest: string;
|
||||
region: string;
|
||||
};
|
||||
|
||||
type PreloginResponse = MaybeProperties<
|
||||
SamlPreloginResponse & PasswordPreloginResponse & Region
|
||||
>;
|
||||
export type PasswordPrelogin = {
|
||||
isSamlAuth: false;
|
||||
authMessage: string;
|
||||
labelUsername: string;
|
||||
labelPassword: string;
|
||||
region: string;
|
||||
};
|
||||
|
||||
type ConfigResponse = {
|
||||
userAuthCookie: Maybe<string>;
|
||||
prelogonUserAuthCookie: Maybe<string>;
|
||||
export type Prelogin = SamlPrelogin | PasswordPrelogin;
|
||||
|
||||
export type PortalConfig = {
|
||||
userAuthCookie: string;
|
||||
prelogonUserAuthCookie: string;
|
||||
gateways: Gateway[];
|
||||
};
|
||||
|
||||
type PortalConfigParams = {
|
||||
export type PortalConfigParams = {
|
||||
user: string;
|
||||
passwd?: string | null;
|
||||
"prelogin-cookie"?: string | null;
|
||||
@ -37,54 +34,75 @@ type PortalConfigParams = {
|
||||
};
|
||||
|
||||
class PortalService {
|
||||
async prelogin(portal: string) {
|
||||
async prelogin(portal: string): Promise<Prelogin> {
|
||||
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, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "PAN GlobalProtect",
|
||||
},
|
||||
responseType: ResponseType.Text,
|
||||
query: {
|
||||
tmp: "tmp",
|
||||
"kerberos-support": "yes",
|
||||
"ipv6-support": "yes",
|
||||
clientVer: "4100",
|
||||
clientos: "Linux",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to connect to portal: ${response.status}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to prelogin: ${response.status}`);
|
||||
}
|
||||
return this.parsePrelogin(response.data);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to prelogin: Network error`);
|
||||
}
|
||||
return this.parsePreloginResponse(response.data);
|
||||
}
|
||||
|
||||
private parsePreloginResponse(response: string): PreloginResponse {
|
||||
private parsePrelogin(response: string): Prelogin {
|
||||
const doc = parseXml(response);
|
||||
const status = doc.text("status").toUpperCase();
|
||||
|
||||
return {
|
||||
samlAuthMethod: doc.text("saml-auth-method").toUpperCase(),
|
||||
samlAuthRequest: atob(doc.text("saml-request")),
|
||||
labelUsername: doc.text("username-label"),
|
||||
labelPassword: doc.text("password-label"),
|
||||
authMessage: doc.text("authentication-message"),
|
||||
region: doc.text("region"),
|
||||
};
|
||||
}
|
||||
|
||||
isSamlAuth(response: PreloginResponse): response is SamlPreloginResponse {
|
||||
return !!(response.samlAuthMethod && response.samlAuthRequest);
|
||||
}
|
||||
|
||||
isPasswordAuth(
|
||||
response: PreloginResponse
|
||||
): response is PasswordPreloginResponse {
|
||||
if (response.labelUsername && response.labelPassword) {
|
||||
return true;
|
||||
if (status !== "SUCCESS") {
|
||||
const message = doc.text("msg") || "Unknown error";
|
||||
throw new Error(message);
|
||||
}
|
||||
return false;
|
||||
|
||||
const samlAuthMethod = doc.text("saml-auth-method").toUpperCase();
|
||||
const samlRequest = doc.text("saml-request");
|
||||
const labelUsername = doc.text("username-label");
|
||||
const labelPassword = doc.text("password-label");
|
||||
const authMessage = doc.text("authentication-message");
|
||||
const region = doc.text("region");
|
||||
|
||||
if (samlAuthMethod && samlRequest) {
|
||||
return {
|
||||
isSamlAuth: true,
|
||||
samlAuthMethod,
|
||||
samlRequest: atob(samlRequest),
|
||||
region,
|
||||
};
|
||||
}
|
||||
|
||||
if (labelUsername && labelPassword) {
|
||||
return {
|
||||
isSamlAuth: false,
|
||||
authMessage,
|
||||
labelUsername,
|
||||
labelPassword,
|
||||
region,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Unknown prelogin response");
|
||||
}
|
||||
|
||||
async fetchConfig(portal: string, params: PortalConfigParams) {
|
||||
@ -133,7 +151,7 @@ class PortalService {
|
||||
return this.parsePortalConfigResponse(response.data);
|
||||
}
|
||||
|
||||
private parsePortalConfigResponse(response: string): ConfigResponse {
|
||||
private parsePortalConfigResponse(response: string): PortalConfig {
|
||||
console.log(response);
|
||||
|
||||
const result = parseXml(response);
|
||||
@ -164,7 +182,7 @@ class PortalService {
|
||||
};
|
||||
}
|
||||
|
||||
preferredGateway(gateways: Gateway[], region: Maybe<string>) {
|
||||
preferredGateway(gateways: Gateway[], region: string) {
|
||||
console.log(gateways);
|
||||
let defaultGateway = gateways[0];
|
||||
for (const gateway of gateways) {
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { Maybe } from '../types';
|
||||
|
||||
type PriorityRule = {
|
||||
name: Maybe<string>;
|
||||
name: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
export type Gateway = {
|
||||
name: Maybe<string>;
|
||||
address: Maybe<string>;
|
||||
name: string;
|
||||
address: string;
|
||||
priorityRules: PriorityRule[];
|
||||
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 {
|
||||
return await invoke<T>(command, args);
|
||||
} 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