refactor: refactor UI using jotai

This commit is contained in:
Kevin Yue 2023-06-07 09:20:44 +08:00
parent c07e232ec2
commit 1af21432d4
27 changed files with 866 additions and 640 deletions

View File

@ -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

View File

@ -1,8 +1,11 @@
{ {
"cSpell.words": [ "cSpell.words": [
"authcookie",
"bindgen", "bindgen",
"clickaway",
"clientos", "clientos",
"gpcommon", "gpcommon",
"Immer",
"jnlp", "jnlp",
"oneshot", "oneshot",
"openconnect", "openconnect",

View File

@ -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
View File

@ -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'}

View File

@ -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 {

View File

@ -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>
); );
} }

View 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");
}
});

View 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
View 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
View 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";
});

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,11 @@
import PasswordAuth from "./PasswordAuth";
import PortalForm from "./PortalForm";
export default function ConnectForm() {
return (
<>
<PortalForm />
<PasswordAuth />
</>
);
}

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,3 @@
export default function Feedback() {
return <div>Feedback</div>
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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"

View File

@ -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: {

View File

@ -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) {

View File

@ -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;
}; };

View File

@ -1,5 +0,0 @@
export type Maybe<T> = T | null | undefined;
export type MaybeProperties<T> = {
[P in keyof T]?: Maybe<T[P]>;
};

View File

@ -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);
} }
} }