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,6 +1,6 @@
mod tunnel_manager;
use std::{fs, path::PathBuf, sync::Mutex};
use std::{fs, path::PathBuf, sync::Mutex, time::{SystemTime, UNIX_EPOCH}};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use rand_core::OsRng;
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, State, WebviewWindow, WindowEvent,
AppHandle, Manager, State, WebviewWindow, Window, WindowEvent,
};
use x25519_dalek::{PublicKey, StaticSecret};
@@ -80,11 +80,17 @@ struct EnrollRequest<'a> {
#[derive(Debug, Deserialize)]
struct EnrollResponse {
device: DeviceView,
peer: PeerView,
profile: ProfileView,
resources: Vec<ResourceView>,
}
#[derive(Debug, Deserialize)]
struct DeviceView {
id: String,
}
#[derive(Debug, Deserialize)]
struct PeerView {
#[serde(rename = "assigned_ip")]
@@ -180,7 +186,7 @@ async fn enroll_device(
profile_revision: enroll.peer.profile_revision,
gateway_endpoint: enroll.peer.gateway.endpoint,
profile_path: profile_path.display().to_string(),
last_sync_time: "just now".into(),
last_sync_time: now_label(),
tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(),
};
@@ -207,6 +213,65 @@ fn load_state(app: AppHandle, state: State<'_, AppState>) -> Result<Option<Enrol
Ok(loaded.map(|value| value.enrollment))
}
#[tauri::command]
async fn sync_profile(app: AppHandle, state: State<'_, AppState>) -> Result<EnrollmentResult, String> {
let existing = {
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
session.clone().ok_or_else(|| "No enrolled profile is available yet".to_string())?
};
let client = Client::builder()
.use_rustls_tls()
.build()
.map_err(|err| err.to_string())?;
let response = client
.get(format!("{}/api/v1/me/profile", existing.server_url.trim_end_matches('/')))
.bearer_auth(&existing.access_token)
.send()
.await
.map_err(|err| format!("Profile sync failed: {}", err))?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "<unable to read response body>".into());
return Err(format!("Profile sync failed with status {}: {}", status, body));
}
let enroll = response
.json::<EnrollResponse>()
.await
.map_err(|err| format!("Unable to decode profile sync response: {}", err))?;
let profile_path = write_profile(&app, &enroll.profile.content)?;
let result = EnrollmentResult {
assigned_ip: enroll.peer.assigned_ip,
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
profile_revision: enroll.peer.profile_revision,
gateway_endpoint: enroll.peer.gateway.endpoint,
profile_path: profile_path.display().to_string(),
last_sync_time: now_label(),
tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(),
};
let session_state = SessionState {
access_token: existing.access_token,
refresh_token: existing.refresh_token,
server_url: existing.server_url,
profile_path: result.profile_path.clone(),
enrollment: result.clone(),
};
write_session_state(&app, &session_state)?;
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
*session = Some(session_state);
Ok(result)
}
#[tauri::command]
fn connect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
@@ -230,6 +295,14 @@ fn generate_keypair() -> (String, String) {
)
}
fn now_label() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
format!("synced at {}", seconds)
}
fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> String {
format!("nexavpn:{}:{}:{}", server_url, username, public_key)
}
@@ -268,13 +341,13 @@ fn ensure_app_dir(app: &AppHandle) -> Result<PathBuf, String> {
Ok(dir)
}
fn restore_main_window(window: &WebviewWindow) {
fn restore_main_window(window: &Window) {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
fn hide_main_window(window: &WebviewWindow) {
fn hide_main_window(window: &Window) {
let _ = window.hide();
}
@@ -335,7 +408,7 @@ pub fn run() {
}
_ => {}
})
.invoke_handler(tauri::generate_handler![load_state, enroll_device, connect_tunnel, disconnect_tunnel])
.invoke_handler(tauri::generate_handler![load_state, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}