feat: add logout functionality and redesign desktop client UI for simplified workflow
Add clear_session command to remove session state and profile files from disk. Add resetEnrollment handler in frontend to clear local state and invoke clear_session. Remove hero surface section with profile metadata tiles. Simplify top strip to show profile label in brand copy when enrolled. Add Logout button to top actions and resources sidebar. Redesign status panel with simplified labels and layout. Update surface
This commit is contained in:
@@ -213,6 +213,24 @@ fn load_state(app: AppHandle, state: State<'_, AppState>) -> Result<Option<Enrol
|
|||||||
Ok(loaded.map(|value| value.enrollment))
|
Ok(loaded.map(|value| value.enrollment))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn clear_session(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let app_dir = ensure_app_dir(&app)?;
|
||||||
|
let session_path = app_dir.join("session.json");
|
||||||
|
let profile_path = app_dir.join(format!("{}.conf", PROFILE_NAME));
|
||||||
|
|
||||||
|
if session_path.exists() {
|
||||||
|
fs::remove_file(&session_path).map_err(|err| format!("Unable to remove session state: {}", err))?;
|
||||||
|
}
|
||||||
|
if profile_path.exists() {
|
||||||
|
fs::remove_file(&profile_path).map_err(|err| format!("Unable to remove profile: {}", err))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut session = state.session.lock().map_err(|_| "Unable to clear client state".to_string())?;
|
||||||
|
*session = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn sync_profile(app: AppHandle, state: State<'_, AppState>) -> Result<EnrollmentResult, String> {
|
async fn sync_profile(app: AppHandle, state: State<'_, AppState>) -> Result<EnrollmentResult, String> {
|
||||||
let existing = {
|
let existing = {
|
||||||
@@ -417,7 +435,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![load_state, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel])
|
.invoke_handler(tauri::generate_handler![load_state, clear_session, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,17 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetEnrollment() {
|
||||||
|
try {
|
||||||
|
await invoke("clear_session");
|
||||||
|
setConnected(false);
|
||||||
|
setState(null);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatInvokeError(err, "Unable to clear local profile"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="client-shell">
|
<div className="client-shell">
|
||||||
<div className="app-frame">
|
<div className="app-frame">
|
||||||
@@ -111,14 +122,19 @@ export function App() {
|
|||||||
<img src="/icon.png" alt="NexaVPN" />
|
<img src="/icon.png" alt="NexaVPN" />
|
||||||
<div className="brand-copy">
|
<div className="brand-copy">
|
||||||
<p className="eyebrow">NexaVPN</p>
|
<p className="eyebrow">NexaVPN</p>
|
||||||
<h1>Desktop Access Client</h1>
|
<h1>VPN Client</h1>
|
||||||
<p>Provisioned WireGuard access with profile sync and one-click reconnect.</p>
|
<p>{state ? profileLabel : "Sign in to provision this device"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="top-actions">
|
<div className="top-actions">
|
||||||
{state ? (
|
{state ? (
|
||||||
<button className="shell-button-secondary" disabled={syncing} onClick={onSyncProfile} type="button">
|
<button className="shell-button-secondary" disabled={syncing} onClick={onSyncProfile} type="button">
|
||||||
{syncing ? "Syncing..." : "Sync profile"}
|
{syncing ? "Syncing..." : "Sync"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{state ? (
|
||||||
|
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
|
||||||
|
Logout
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button className="shell-button" disabled={!state} onClick={toggleConnection} type="button">
|
<button className="shell-button" disabled={!state} onClick={toggleConnection} type="button">
|
||||||
@@ -127,34 +143,13 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="hero-meta">
|
|
||||||
<div className="meta-tile">
|
|
||||||
<span className="eyebrow">Current profile</span>
|
|
||||||
<strong>{profileLabel}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="meta-tile">
|
|
||||||
<span className="eyebrow">Last sync</span>
|
|
||||||
<strong>{state?.lastSyncTime ?? "Not synced yet"}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="body-grid">
|
<div className="body-grid">
|
||||||
{!state ? (
|
{!state ? (
|
||||||
<section className="login-panel">
|
<section className="login-panel">
|
||||||
<div className="surface-header">
|
<div className="surface-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Onboarding</p>
|
<p className="eyebrow">Sign in</p>
|
||||||
<h3>Sign in and provision this device</h3>
|
<h3>Provision device</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
@@ -182,11 +177,11 @@ export function App() {
|
|||||||
<section className="status-panel">
|
<section className="status-panel">
|
||||||
<div className="status-top">
|
<div className="status-top">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Connection</p>
|
<p className="eyebrow">Status</p>
|
||||||
<h3>{connected ? "Connected" : "Disconnected"}</h3>
|
<h3>{connected ? "Connected" : "Disconnected"}</h3>
|
||||||
<div className="status-state">
|
<div className="status-state">
|
||||||
<span className={`status-dot ${connected ? "online" : ""}`} />
|
<span className={`status-dot ${connected ? "online" : ""}`} />
|
||||||
{connected ? "Tunnel active" : "Ready to connect"}
|
{connected ? "Tunnel active" : "Ready"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,8 +189,8 @@ export function App() {
|
|||||||
<div className="surface">
|
<div className="surface">
|
||||||
<div className="surface-header">
|
<div className="surface-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Session</p>
|
<p className="eyebrow">Connection</p>
|
||||||
<h4>Provisioned device state</h4>
|
<h4>Current profile</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-grid">
|
<div className="status-grid">
|
||||||
@@ -208,8 +203,8 @@ export function App() {
|
|||||||
<strong>{state.gatewayEndpoint}</strong>
|
<strong>{state.gatewayEndpoint}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-card">
|
<div className="detail-card">
|
||||||
<span>Profile revision</span>
|
<span>Profile</span>
|
||||||
<strong>{state.profileRevision}</strong>
|
<strong>{profileLabel}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-card">
|
<div className="detail-card">
|
||||||
<span>Last sync</span>
|
<span>Last sync</span>
|
||||||
@@ -221,8 +216,8 @@ export function App() {
|
|||||||
<div className="surface">
|
<div className="surface">
|
||||||
<div className="surface-header">
|
<div className="surface-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Profile</p>
|
<p className="eyebrow">Local</p>
|
||||||
<h4>Assigned access profile</h4>
|
<h4>Stored config</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-grid">
|
<div className="profile-grid">
|
||||||
@@ -231,8 +226,8 @@ export function App() {
|
|||||||
<strong>{state.profilePath}</strong>
|
<strong>{state.profilePath}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-card">
|
<div className="profile-card">
|
||||||
<span>Tunnel strategy</span>
|
<span>Revision</span>
|
||||||
<strong>{state.tunnelStrategy}</strong>
|
<strong>{state.profileRevision}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,19 +239,21 @@ export function App() {
|
|||||||
<aside className="status-panel">
|
<aside className="status-panel">
|
||||||
<div className="surface-header">
|
<div className="surface-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Resources</p>
|
<p className="eyebrow">Access</p>
|
||||||
<h4>Allowed destinations</h4>
|
<h4>Resources</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>After policy changes, press <strong>Sync</strong> to refresh this device profile.</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">
|
<ul className="resource-list">
|
||||||
{(state?.resources ?? ["No resources assigned yet"]).map((resource) => (
|
{(state?.resources ?? ["No resources assigned yet"]).map((resource) => (
|
||||||
<li key={resource}>{resource}</li>
|
<li key={resource}>{resource}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{state ? (
|
||||||
|
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
|
||||||
|
Neu einbinden
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ input {
|
|||||||
width: min(1120px, 100%);
|
width: min(1120px, 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 22px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-strip {
|
.top-strip {
|
||||||
@@ -72,7 +72,6 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-copy h1,
|
.brand-copy h1,
|
||||||
.hero-copy h2,
|
|
||||||
.status-panel h3 {
|
.status-panel h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -120,7 +119,6 @@ input {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-surface,
|
|
||||||
.surface,
|
.surface,
|
||||||
.status-panel,
|
.status-panel,
|
||||||
.login-panel {
|
.login-panel {
|
||||||
@@ -130,50 +128,6 @@ input {
|
|||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-surface {
|
|
||||||
border-radius: 30px;
|
|
||||||
padding: 28px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.2fr 0.8fr;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy h2 {
|
|
||||||
font-size: clamp(2rem, 4vw, 3.6rem);
|
|
||||||
line-height: 1.04;
|
|
||||||
max-width: 10ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy p {
|
|
||||||
max-width: 56ch;
|
|
||||||
font-size: 1.03rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-tile {
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: 22px;
|
|
||||||
background: linear-gradient(180deg, rgba(117, 227, 186, 0.08), rgba(117, 227, 186, 0.02));
|
|
||||||
border: 1px solid rgba(117, 227, 186, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-tile strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body-grid {
|
.body-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.3fr 0.7fr;
|
grid-template-columns: 1.3fr 0.7fr;
|
||||||
@@ -188,6 +142,12 @@ input {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-panel > p {
|
||||||
|
margin: 0;
|
||||||
|
color: #9eb1d1;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.status-top {
|
.status-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -310,7 +270,6 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.hero-surface,
|
|
||||||
.body-grid {
|
.body-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user