Add formatInvokeError helper function to handle various error types from Tauri invoke calls with fallback messages. Update enroll_device to include response body in error message when enrollment fails with non-success status. Add windows_subsystem attribute to main.rs to suppress console window in release builds on Windows.
153 lines
4.5 KiB
TypeScript
153 lines
4.5 KiB
TypeScript
import { FormEvent, useEffect, useState } from "react";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
type EnrollmentState = {
|
|
assignedIp: string;
|
|
resources: string[];
|
|
profileRevision: number;
|
|
gatewayEndpoint: string;
|
|
profilePath: string;
|
|
lastSyncTime: string;
|
|
tunnelStrategy: string;
|
|
};
|
|
|
|
function formatInvokeError(err: unknown, fallback: string) {
|
|
if (typeof err === "string" && err.trim().length > 0) {
|
|
return err;
|
|
}
|
|
|
|
if (err instanceof Error && err.message.trim().length > 0) {
|
|
return err.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
export function App() {
|
|
const [serverUrl, setServerUrl] = useState("http://localhost");
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [connected, setConnected] = useState(false);
|
|
const [state, setState] = useState<EnrollmentState | null>(null);
|
|
|
|
useEffect(() => {
|
|
void invoke<EnrollmentState | null>("load_state")
|
|
.then((value) => {
|
|
if (value) {
|
|
setState(value);
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
async function onSubmit(event: FormEvent) {
|
|
event.preventDefault();
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await invoke<EnrollmentState>("enroll_device", {
|
|
payload: { serverUrl, username, password }
|
|
});
|
|
setState(result);
|
|
} catch (err) {
|
|
setError(formatInvokeError(err, "Enrollment failed"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function toggleConnection() {
|
|
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
|
|
try {
|
|
await invoke(command);
|
|
setConnected((value) => !value);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(formatInvokeError(err, "Tunnel action failed"));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="client-shell">
|
|
<div className="hero">
|
|
<p className="eyebrow">NexaVPN</p>
|
|
<h1>Private access without manual WireGuard setup.</h1>
|
|
<p className="lede">
|
|
Enter your VPN address, username, and password. NexaVPN provisions and stores the profile for you.
|
|
</p>
|
|
</div>
|
|
|
|
{!state ? (
|
|
<form className="panel" onSubmit={onSubmit}>
|
|
<label>
|
|
VPN server address
|
|
<input value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
Username
|
|
<input value={username} onChange={(event) => setUsername(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
Password
|
|
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
|
</label>
|
|
{error ? <div className="error">{error}</div> : null}
|
|
<button disabled={loading} type="submit">
|
|
{loading ? "Provisioning..." : "Sign in"}
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<div className="panel status">
|
|
<div className="status-row">
|
|
<div>
|
|
<p className="eyebrow">Connection</p>
|
|
<h2>{connected ? "Connected" : "Disconnected"}</h2>
|
|
</div>
|
|
<button onClick={toggleConnection}>{connected ? "Disconnect" : "Connect"}</button>
|
|
</div>
|
|
<div className="details">
|
|
<div>
|
|
<span>Assigned VPN IP</span>
|
|
<strong>{state.assignedIp}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Gateway</span>
|
|
<strong>{state.gatewayEndpoint}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Profile revision</span>
|
|
<strong>{state.profileRevision}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Last sync</span>
|
|
<strong>{state.lastSyncTime}</strong>
|
|
</div>
|
|
</div>
|
|
<div className="details">
|
|
<div>
|
|
<span>Profile path</span>
|
|
<strong>{state.profilePath}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Tunnel strategy</span>
|
|
<strong>{state.tunnelStrategy}</strong>
|
|
</div>
|
|
</div>
|
|
{error ? <div className="error">{error}</div> : null}
|
|
<div>
|
|
<p className="eyebrow">Allowed resources</p>
|
|
<ul className="resource-list">
|
|
{state.resources.map((resource) => (
|
|
<li key={resource}>{resource}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|