mirror of
https://github.com/yuezk/GlobalProtect-openconnect.git
synced 2025-05-20 07:26:58 -04:00
refactor: add auth window
This commit is contained in:
12
gpgui/public/auth.html
Normal file
12
gpgui/public/auth.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GlobalProtect Login</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting...</p>
|
||||
</body>
|
||||
</html>
|
@@ -15,13 +15,16 @@ rust-version = "1.59"
|
||||
tauri-build = { version = "1.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "1.3", features = ["http-all"] }
|
||||
gpcommon = { path = "../../gpcommon" }
|
||||
tauri = { version = "1.3", features = ["http-all", "window-data-url"] }
|
||||
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.10"
|
||||
gpcommon = { path = "../../gpcommon" }
|
||||
webkit2gtk = "0.18.2"
|
||||
regex = "1"
|
||||
url = "2.3"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
297
gpgui/src-tauri/src/auth.rs
Normal file
297
gpgui/src-tauri/src/auth.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc};
|
||||
use tauri::{AppHandle, Manager, WindowBuilder, WindowEvent::CloseRequested, WindowUrl};
|
||||
use url::Url;
|
||||
use webkit2gtk::{
|
||||
gio::Cancellable, glib::GString, traits::WebViewExt, LoadEvent, URIResponseExt, WebResource,
|
||||
WebResourceExt,
|
||||
};
|
||||
|
||||
const AUTH_WINDOW_LABEL: &str = "auth_window";
|
||||
const AUTH_SUCCESS_EVENT: &str = "auth-success";
|
||||
const AUTH_ERROR_EVENT: &str = "auth-error";
|
||||
const AUTH_CANCEL_EVENT: &str = "auth-cancel";
|
||||
const AUTH_REQUEST_EVENT: &str = "auth-request";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) enum SamlBinding {
|
||||
#[serde(rename = "REDIRECT")]
|
||||
Redirect,
|
||||
#[serde(rename = "POST")]
|
||||
Post,
|
||||
}
|
||||
|
||||
pub(crate) struct AuthOptions {
|
||||
saml_binding: SamlBinding,
|
||||
saml_request: String,
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthRequestPayload {
|
||||
#[serde(alias = "samlRequest")]
|
||||
saml_request: String,
|
||||
}
|
||||
|
||||
impl AuthOptions {
|
||||
pub fn new(saml_binding: SamlBinding, saml_request: String, user_agent: String) -> Self {
|
||||
Self {
|
||||
saml_binding,
|
||||
saml_request,
|
||||
user_agent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AuthData {
|
||||
username: Option<String>,
|
||||
prelogin_cookie: Option<String>,
|
||||
portal_userauthcookie: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthData {
|
||||
fn new(
|
||||
username: Option<String>,
|
||||
prelogin_cookie: Option<String>,
|
||||
portal_userauthcookie: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
username,
|
||||
prelogin_cookie,
|
||||
portal_userauthcookie,
|
||||
}
|
||||
}
|
||||
|
||||
fn check(&self) -> bool {
|
||||
self.username.is_some()
|
||||
&& (self.prelogin_cookie.is_some() || self.portal_userauthcookie.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AuthError {
|
||||
NotFound,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AuthEventEmitter {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl AuthEventEmitter {
|
||||
fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
|
||||
fn emit_success(&self, saml_result: AuthData) {
|
||||
self.app_handle.emit_all(AUTH_SUCCESS_EVENT, saml_result);
|
||||
if let Some(window) = self.app_handle.get_window(AUTH_WINDOW_LABEL) {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_error(&self, error: String) {
|
||||
self.app_handle.emit_all(AUTH_ERROR_EVENT, error);
|
||||
}
|
||||
|
||||
fn emit_cancel(&self) {
|
||||
self.app_handle.emit_all(AUTH_CANCEL_EVENT, ());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AuthWindow {
|
||||
event_emitter: Arc<AuthEventEmitter>,
|
||||
app_handle: AppHandle,
|
||||
saml_binding: SamlBinding,
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
impl AuthWindow {
|
||||
pub fn new(app_handle: AppHandle, saml_binding: SamlBinding, user_agent: String) -> Self {
|
||||
Self {
|
||||
event_emitter: Arc::new(AuthEventEmitter::new(app_handle.clone())),
|
||||
app_handle,
|
||||
saml_binding,
|
||||
user_agent,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(&self, saml_request: String) -> tauri::Result<()> {
|
||||
let url = self.window_url(&saml_request)?;
|
||||
let window = WindowBuilder::new(&self.app_handle, AUTH_WINDOW_LABEL, url)
|
||||
.title("GlobalProtect Login")
|
||||
.user_agent(&self.user_agent)
|
||||
.always_on_top(true)
|
||||
.focused(true)
|
||||
.center()
|
||||
.build()?;
|
||||
|
||||
let event_emitter = self.event_emitter.clone();
|
||||
let is_post = matches!(self.saml_binding, SamlBinding::Post);
|
||||
|
||||
window.with_webview(move |wv| {
|
||||
let wv = wv.inner();
|
||||
// Load SAML request as HTML if POST binding is used
|
||||
if is_post {
|
||||
wv.load_html(&saml_request, None);
|
||||
}
|
||||
wv.connect_load_changed(move |wv, event| {
|
||||
if LoadEvent::Finished == event {
|
||||
if let Some(uri) = wv.uri() {
|
||||
if uri.is_empty() {
|
||||
println!("Empty URI");
|
||||
event_emitter.emit_error("Empty URI".to_string());
|
||||
return;
|
||||
} else {
|
||||
println!("Loaded URI: {}", uri);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(main_res) = wv.main_resource() {
|
||||
AuthResultParser::new(&event_emitter).parse(&main_res);
|
||||
}
|
||||
}
|
||||
});
|
||||
})?;
|
||||
|
||||
let event_emitter = self.event_emitter.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let CloseRequested { .. } = event {
|
||||
event_emitter.emit_cancel();
|
||||
}
|
||||
});
|
||||
|
||||
let window_clone = window.clone();
|
||||
window.listen_global(AUTH_REQUEST_EVENT, move |event| {
|
||||
let auth_request_payload: AuthRequestPayload = serde_json::from_str(event.payload().unwrap()).unwrap();
|
||||
let saml_request = auth_request_payload.saml_request;
|
||||
|
||||
window_clone.with_webview(move |wv| {
|
||||
let wv = wv.inner();
|
||||
if is_post {
|
||||
// Load SAML request as HTML if POST binding is used
|
||||
wv.load_html(&saml_request, None);
|
||||
} else {
|
||||
println!("Redirecting to SAML request URL: {}", saml_request);
|
||||
// Redirect to SAML request URL if REDIRECT binding is used
|
||||
wv.load_uri(&saml_request);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn window_url(&self, saml_request: &String) -> tauri::Result<WindowUrl> {
|
||||
match self.saml_binding {
|
||||
SamlBinding::Redirect => match Url::parse(saml_request) {
|
||||
Ok(url) => Ok(WindowUrl::External(url)),
|
||||
Err(err) => Err(tauri::Error::InvalidUrl(err)),
|
||||
},
|
||||
SamlBinding::Post => Ok(WindowUrl::App("auth.html".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthResultParser<'a> {
|
||||
event_emitter: &'a Arc<AuthEventEmitter>,
|
||||
}
|
||||
|
||||
impl<'a> AuthResultParser<'a> {
|
||||
fn new(event_emitter: &'a Arc<AuthEventEmitter>) -> Self {
|
||||
Self { event_emitter }
|
||||
}
|
||||
|
||||
fn parse(&self, main_res: &WebResource) {
|
||||
if let Some(response) = main_res.response() {
|
||||
if let Some(saml_result) = read_auth_result_from_response(&response) {
|
||||
// Got SAML result from HTTP headers
|
||||
println!("SAML result: {:?}", saml_result);
|
||||
self.event_emitter.emit_success(saml_result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let event_emitter = self.event_emitter.clone();
|
||||
main_res.data(Cancellable::NONE, move |data| {
|
||||
if let Ok(data) = data {
|
||||
let html = String::from_utf8_lossy(&data);
|
||||
match read_auth_result_from_html(&html) {
|
||||
Ok(saml_result) => {
|
||||
// Got SAML result from HTML
|
||||
println!("SAML result: {:?}", saml_result);
|
||||
event_emitter.emit_success(saml_result);
|
||||
return;
|
||||
}
|
||||
Err(AuthError::Invalid) => {
|
||||
// Invalid SAML result
|
||||
println!("Invalid SAML result");
|
||||
event_emitter.emit_error("Invalid SAML result".to_string())
|
||||
}
|
||||
Err(AuthError::NotFound) => {
|
||||
let has_form = html.contains("</form>");
|
||||
if has_form {
|
||||
// SAML form found
|
||||
println!("SAML form found");
|
||||
} else {
|
||||
// No SAML form found
|
||||
println!("No SAML form found");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn read_auth_result_from_response(response: &webkit2gtk::URIResponse) -> Option<AuthData> {
|
||||
response.http_headers().and_then(|mut headers| {
|
||||
let saml_result = AuthData::new(
|
||||
headers.get("saml-username").map(GString::into),
|
||||
headers.get("prelogin-cookie").map(GString::into),
|
||||
headers.get("portal-userauthcookie").map(GString::into),
|
||||
);
|
||||
|
||||
if saml_result.check() {
|
||||
Some(saml_result)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn read_auth_result_from_html(html: &str) -> Result<AuthData, AuthError> {
|
||||
let saml_auth_status = parse_xml_tag(html, "saml-auth-status");
|
||||
|
||||
|
||||
match saml_auth_status {
|
||||
Some(status) if status == "1" => extract_auth_data(html).ok_or(AuthError::Invalid),
|
||||
Some(status) if status == "-1" => Err(AuthError::Invalid),
|
||||
_ => Err(AuthError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_auth_data(html: &str) -> Option<AuthData> {
|
||||
let auth_data = AuthData::new(
|
||||
parse_xml_tag(html, "saml-username"),
|
||||
parse_xml_tag(html, "prelogin-cookie"),
|
||||
parse_xml_tag(html, "portal-userauthcookie"),
|
||||
);
|
||||
|
||||
if auth_data.check() {
|
||||
Some(auth_data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_xml_tag(html: &str, tag: &str) -> Option<String> {
|
||||
let re = Regex::new(&format!("<{}>(.*)</{}>", tag, tag)).unwrap();
|
||||
re.captures(html)
|
||||
.and_then(|captures| captures.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
@@ -3,13 +3,16 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use gpcommon::{Client, ServerApiError, VpnStatus};
|
||||
use auth::{SamlBinding, AuthWindow};
|
||||
use env_logger::Env;
|
||||
use gpcommon::{Client, ServerApiError, VpnStatus};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Manager, State};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri_plugin_log::LogTarget;
|
||||
|
||||
mod auth;
|
||||
|
||||
#[tauri::command]
|
||||
async fn vpn_status<'a>(client: State<'a, Arc<Client>>) -> Result<VpnStatus, ServerApiError> {
|
||||
client.status().await
|
||||
@@ -29,6 +32,20 @@ async fn vpn_disconnect<'a>(client: State<'a, Arc<Client>>) -> Result<(), Server
|
||||
client.disconnect().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn saml_login(
|
||||
binding: SamlBinding,
|
||||
request: String,
|
||||
app_handle: AppHandle,
|
||||
) -> tauri::Result<()> {
|
||||
let auth_window = AuthWindow::new(app_handle, binding, String::from("PAN GlobalProtect"));
|
||||
if let Err(err) = auth_window.process(request) {
|
||||
println!("Error processing auth window: {}", err);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct StatusPayload {
|
||||
status: VpnStatus,
|
||||
@@ -43,11 +60,11 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = client_clone.subscribe_status(move |status| {
|
||||
let payload = StatusPayload { status };
|
||||
if let Err(err) = app_handle.emit_all("vpn-status-received", payload) {
|
||||
println!("Error emmiting event: {}", err);
|
||||
println!("Error emitting event: {}", err);
|
||||
}
|
||||
});
|
||||
|
||||
let _ = client_clone.run().await;
|
||||
// let _ = client_clone.run().await;
|
||||
});
|
||||
|
||||
app.manage(client);
|
||||
@@ -70,7 +87,8 @@ fn main() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
vpn_status,
|
||||
vpn_connect,
|
||||
vpn_disconnect
|
||||
vpn_disconnect,
|
||||
saml_login,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
@@ -12,9 +12,10 @@ import PasswordAuth, {
|
||||
import gatewayService from "./services/gatewayService";
|
||||
import portalService from "./services/portalService";
|
||||
import vpnService from "./services/vpnService";
|
||||
import authService from "./services/authService";
|
||||
|
||||
export default function App() {
|
||||
const [portalAddress, setPortalAddress] = useState("220.191.185.154");
|
||||
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);
|
||||
@@ -35,6 +36,16 @@ export default function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
authService.onAuthSuccess((data) => {});
|
||||
authService.onAuthError(async () => {
|
||||
const preloginResponse = await portalService.prelogin(portalAddress);
|
||||
// Retry SAML login when auth error occurs
|
||||
authService.emitAuthRequest(preloginResponse.samlAuthRequest!);
|
||||
});
|
||||
authService.onAuthCancel(() => {});
|
||||
}, [portalAddress]);
|
||||
|
||||
function closeNotification() {
|
||||
setNotification((notification) => ({
|
||||
...notification,
|
||||
@@ -62,7 +73,8 @@ export default function App() {
|
||||
const response = await portalService.prelogin(portalAddress);
|
||||
|
||||
if (portalService.isSamlAuth(response)) {
|
||||
// TODO SAML login
|
||||
const { samlAuthMethod, samlAuthRequest } = response;
|
||||
await authService.samlLogin(samlAuthMethod, samlAuthRequest);
|
||||
} else if (portalService.isPasswordAuth(response)) {
|
||||
setPasswordAuthOpen(true);
|
||||
setPasswordAuth({
|
||||
@@ -74,6 +86,7 @@ export default function App() {
|
||||
throw new Error("Unsupported portal login method");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
|
53
gpgui/src/services/authService.ts
Normal file
53
gpgui/src/services/authService.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Event, emit, listen } from "@tauri-apps/api/event";
|
||||
import invokeCommand from "../utils/invokeCommand";
|
||||
|
||||
type AuthData = {
|
||||
username: string;
|
||||
prelogin_cookie: string | null;
|
||||
portal_userauthcookie: string | null;
|
||||
};
|
||||
|
||||
class AuthService {
|
||||
private authSuccessCallback: ((data: AuthData) => void) | undefined;
|
||||
private authErrorCallback: (() => void) | undefined;
|
||||
private authCancelCallback: (() => void) | undefined;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await listen("auth-success", (event: Event<AuthData>) => {
|
||||
this.authSuccessCallback?.(event.payload);
|
||||
});
|
||||
await listen("auth-error", (event) => {
|
||||
this.authErrorCallback?.();
|
||||
});
|
||||
await listen("auth-cancel", (event) => {
|
||||
this.authCancelCallback?.();
|
||||
});
|
||||
}
|
||||
|
||||
onAuthSuccess(callback: (data: AuthData) => void) {
|
||||
this.authSuccessCallback = callback;
|
||||
}
|
||||
|
||||
onAuthError(callback: () => void) {
|
||||
this.authErrorCallback = callback;
|
||||
}
|
||||
|
||||
onAuthCancel(callback: () => void) {
|
||||
this.authCancelCallback = callback;
|
||||
}
|
||||
|
||||
// binding: "POST" | "REDIRECT"
|
||||
async samlLogin(binding: string, request: string) {
|
||||
return invokeCommand("saml_login", { binding, request });
|
||||
}
|
||||
|
||||
emitAuthRequest(authRequest: string) {
|
||||
emit("auth-request", { samlRequest: authRequest });
|
||||
}
|
||||
}
|
||||
|
||||
export default new AuthService();
|
@@ -1,6 +1,7 @@
|
||||
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
|
||||
import { Maybe, MaybeProperties } from "../types";
|
||||
import { parseXml } from "../utils/parseXml";
|
||||
import authService from "./authService";
|
||||
import { Gateway } from "./types";
|
||||
|
||||
type SamlPreloginResponse = {
|
||||
@@ -35,6 +36,9 @@ class PortalService {
|
||||
|
||||
const response = await fetch<string>(preloginUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "PAN GlobalProtect",
|
||||
},
|
||||
responseType: ResponseType.Text,
|
||||
query: {
|
||||
tmp: "tmp",
|
||||
@@ -55,8 +59,8 @@ class PortalService {
|
||||
const doc = parseXml(response);
|
||||
|
||||
return {
|
||||
samlAuthMethod: doc.text("saml-auth-method"),
|
||||
samlAuthRequest: doc.text("saml-auth-request"),
|
||||
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"),
|
||||
|
@@ -1,12 +1,10 @@
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Event, listen } from "@tauri-apps/api/event";
|
||||
import invokeCommand from "../utils/invokeCommand";
|
||||
|
||||
type Status = "disconnected" | "connecting" | "connected" | "disconnecting";
|
||||
type StatusCallback = (status: Status) => void;
|
||||
type StatusEvent = {
|
||||
payload: {
|
||||
status: Status;
|
||||
};
|
||||
type StatusPayload = {
|
||||
status: Status;
|
||||
};
|
||||
|
||||
class VpnService {
|
||||
@@ -18,7 +16,7 @@ class VpnService {
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await listen("vpn-status-received", (event: StatusEvent) => {
|
||||
await listen("vpn-status-received", (event: Event<StatusPayload>) => {
|
||||
console.log("vpn-status-received", event.payload);
|
||||
this.setStatus(event.payload.status);
|
||||
});
|
||||
@@ -35,15 +33,15 @@ class VpnService {
|
||||
}
|
||||
|
||||
private async status(): Promise<Status> {
|
||||
return this.invokeCommand<Status>("vpn_status");
|
||||
return invokeCommand<Status>("vpn_status");
|
||||
}
|
||||
|
||||
async connect(server: string, cookie: string) {
|
||||
return this.invokeCommand("vpn_connect", { server, cookie });
|
||||
return invokeCommand("vpn_connect", { server, cookie });
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
return this.invokeCommand("vpn_disconnect");
|
||||
return invokeCommand("vpn_disconnect");
|
||||
}
|
||||
|
||||
onStatusChanged(callback: StatusCallback) {
|
||||
@@ -59,14 +57,6 @@ class VpnService {
|
||||
private removeStatusCallback(callback: StatusCallback) {
|
||||
this.statusCallbacks = this.statusCallbacks.filter((cb) => cb !== callback);
|
||||
}
|
||||
|
||||
private async invokeCommand<T>(command: string, args?: any) {
|
||||
try {
|
||||
return await invoke<T>(command, args);
|
||||
} catch (err: any) {
|
||||
throw new Error(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new VpnService();
|
||||
|
9
gpgui/src/utils/invokeCommand.ts
Normal file
9
gpgui/src/utils/invokeCommand.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user