feat: add profile sync functionality and redesign desktop client UI

Add sync_profile command to fetch latest profile from backend without re-enrollment. Add DeviceView struct to EnrollResponse. Replace hardcoded "just now" timestamp with now_label helper using Unix epoch seconds. Add sync button to UI with loading state. Redesign client interface with top strip containing brand lockup and action buttons, hero surface with profile metadata tiles, body grid with login/status panels and resources sidebar
This commit is contained in:
2026-03-17 21:24:50 +01:00
parent 72c5bb6f55
commit a4c5a3f0ca
4 changed files with 585 additions and 139 deletions

View File

@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
type EnrollmentState = {
@@ -23,11 +23,28 @@ function formatInvokeError(err: unknown, fallback: string) {
return fallback;
}
function currentProfileLabel(state: EnrollmentState | null) {
if (!state) {
return "No profile provisioned";
}
if (state.resources.includes("0.0.0.0/0")) {
return "Full tunnel";
}
if (state.resources.length === 1) {
return "Single resource access";
}
return `Split tunnel (${state.resources.length} resources)`;
}
export function App() {
const [serverUrl, setServerUrl] = useState("http://localhost");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [state, setState] = useState<EnrollmentState | null>(null);
@@ -42,6 +59,8 @@ export function App() {
.catch(() => undefined);
}, []);
const profileLabel = useMemo(() => currentProfileLabel(state), [state]);
async function onSubmit(event: FormEvent) {
event.preventDefault();
setLoading(true);
@@ -59,6 +78,20 @@ export function App() {
}
}
async function onSyncProfile() {
setSyncing(true);
setError(null);
try {
const result = await invoke<EnrollmentState>("sync_profile");
setState(result);
} catch (err) {
setError(formatInvokeError(err, "Profile sync failed"));
} finally {
setSyncing(false);
}
}
async function toggleConnection() {
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
try {
@@ -72,81 +105,161 @@ export function App() {
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>
<div className="app-frame">
<div className="top-strip">
<div className="brand-lockup">
<img src="/icon.png" alt="NexaVPN" />
<div className="brand-copy">
<p className="eyebrow">NexaVPN</p>
<h1>Desktop Access Client</h1>
<p>Provisioned WireGuard access with profile sync and one-click reconnect.</p>
</div>
</div>
<div className="top-actions">
{state ? (
<button className="shell-button-secondary" disabled={syncing} onClick={onSyncProfile} type="button">
{syncing ? "Syncing..." : "Sync profile"}
</button>
) : null}
<button className="shell-button" disabled={!state} onClick={toggleConnection} type="button">
{!state ? "Provision first" : connected ? "Disconnect" : "Connect"}
</button>
</div>
</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>
<section className="hero-surface">
<div className="hero-copy">
<p className="eyebrow">Managed Tunnel</p>
<h2>Private access with fewer moving parts.</h2>
<p>
NexaVPN signs you in, enrolls the device, stores the WireGuard profile locally, and lets you resync the
assigned access profile after policy changes.
</p>
</div>
<div className="details">
<div>
<span>Assigned VPN IP</span>
<strong>{state.assignedIp}</strong>
<div className="hero-meta">
<div className="meta-tile">
<span className="eyebrow">Current profile</span>
<strong>{profileLabel}</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 className="meta-tile">
<span className="eyebrow">Last sync</span>
<strong>{state?.lastSyncTime ?? "Not synced yet"}</strong>
</div>
</div>
<div className="details">
<div>
<span>Profile path</span>
<strong>{state.profilePath}</strong>
</section>
<div className="body-grid">
{!state ? (
<section className="login-panel">
<div className="surface-header">
<div>
<p className="eyebrow">Onboarding</p>
<h3>Sign in and provision this device</h3>
</div>
</div>
<form 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}
<div className="login-card-actions">
<button className="shell-button" disabled={loading} type="submit">
{loading ? "Provisioning..." : "Sign in"}
</button>
</div>
</form>
</section>
) : (
<section className="status-panel">
<div className="status-top">
<div>
<p className="eyebrow">Connection</p>
<h3>{connected ? "Connected" : "Disconnected"}</h3>
<div className="status-state">
<span className={`status-dot ${connected ? "online" : ""}`} />
{connected ? "Tunnel active" : "Ready to connect"}
</div>
</div>
</div>
<div className="surface">
<div className="surface-header">
<div>
<p className="eyebrow">Session</p>
<h4>Provisioned device state</h4>
</div>
</div>
<div className="status-grid">
<div className="detail-card">
<span>Assigned VPN IP</span>
<strong>{state.assignedIp}</strong>
</div>
<div className="detail-card">
<span>Gateway endpoint</span>
<strong>{state.gatewayEndpoint}</strong>
</div>
<div className="detail-card">
<span>Profile revision</span>
<strong>{state.profileRevision}</strong>
</div>
<div className="detail-card">
<span>Last sync</span>
<strong>{state.lastSyncTime}</strong>
</div>
</div>
</div>
<div className="surface">
<div className="surface-header">
<div>
<p className="eyebrow">Profile</p>
<h4>Assigned access profile</h4>
</div>
</div>
<div className="profile-grid">
<div className="profile-card">
<span>Profile path</span>
<strong>{state.profilePath}</strong>
</div>
<div className="profile-card">
<span>Tunnel strategy</span>
<strong>{state.tunnelStrategy}</strong>
</div>
</div>
</div>
{error ? <div className="error">{error}</div> : null}
</section>
)}
<aside className="status-panel">
<div className="surface-header">
<div>
<p className="eyebrow">Resources</p>
<h4>Allowed destinations</h4>
</div>
</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>
<p>
The current backend issues one effective profile per device. After policy changes, use <strong>Sync
profile</strong> to pull the latest assigned access.
</p>
<ul className="resource-list">
{state.resources.map((resource) => (
{(state?.resources ?? ["No resources assigned yet"]).map((resource) => (
<li key={resource}>{resource}</li>
))}
</ul>
</div>
</aside>
</div>
)}
</div>
</div>
);
}