feat: add access profile selection support with device-specific profile persistence

Add SelectOwnProfile handler to allow users to choose from available access profiles. Store selected profile ID per device in settings table with device_access_profile category. Implement GetSelectedProfileID and SetSelectedProfileID repository methods using JSONB storage.

Add ListSelectableProfiles to policy repository and service to query user/group/device-specific profiles ordered by priority. Filter gateway
This commit is contained in:
2026-03-18 12:21:48 +01:00
parent 1ddcbf0b14
commit aaa601a8ba
14 changed files with 549 additions and 43 deletions

View File

@@ -58,7 +58,12 @@ struct EnrollmentPayload {
#[serde(rename_all = "camelCase")]
struct EnrollmentResult {
assigned_ip: String,
#[serde(default)]
resources: Vec<String>,
#[serde(default)]
available_profiles: Vec<AccessProfile>,
#[serde(default)]
selected_profile_id: Option<String>,
profile_revision: u32,
gateway_endpoint: String,
profile_path: String,
@@ -66,6 +71,16 @@ struct EnrollmentResult {
tunnel_strategy: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AccessProfile {
id: String,
name: String,
description: String,
full_tunnel: bool,
destinations: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TunnelMetrics {
@@ -113,6 +128,8 @@ struct EnrollResponse {
peer: PeerView,
profile: ProfileView,
resources: Vec<ResourceView>,
available_profiles: Vec<AccessProfileView>,
selected_profile_id: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -139,11 +156,25 @@ struct ResourceView {
value: String,
}
#[derive(Debug, Deserialize)]
struct AccessProfileView {
id: String,
name: String,
description: String,
full_tunnel: bool,
destinations: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ProfileView {
content: String,
}
#[derive(Debug, Serialize)]
struct SelectProfilePayload<'a> {
profile_id: &'a str,
}
#[tauri::command]
async fn enroll_device(
app: AppHandle,
@@ -213,6 +244,8 @@ async fn enroll_device(
let result = EnrollmentResult {
assigned_ip: enroll.peer.assigned_ip,
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
available_profiles: map_access_profiles(enroll.available_profiles),
selected_profile_id: enroll.selected_profile_id,
profile_revision: enroll.peer.profile_revision,
gateway_endpoint: enroll.peer.gateway.endpoint,
profile_path: profile_path.display().to_string(),
@@ -275,6 +308,88 @@ async fn sync_profile(app: AppHandle, _state: State<'_, AppState>) -> Result<Enr
Ok(session_state.enrollment)
}
#[tauri::command]
async fn select_access_profile(app: AppHandle, profile_id: String) -> Result<EnrollmentResult, String> {
let mut existing = {
let state = app.state::<AppState>();
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 mut response = client
.put(format!(
"{}/api/v1/me/profile-selection",
existing.server_url.trim_end_matches('/')
))
.bearer_auth(&existing.access_token)
.json(&SelectProfilePayload {
profile_id: &profile_id,
})
.send()
.await
.map_err(|err| format!("Profile selection failed: {}", err))?;
if response.status().as_u16() == 401 {
let refresh = client
.post(format!("{}/api/v1/auth/refresh", existing.server_url.trim_end_matches('/')))
.json(&RefreshRequest {
refresh_token: &existing.refresh_token,
})
.send()
.await
.map_err(|err| format!("Session refresh failed: {}", err))?;
if !refresh.status().is_success() {
let status = refresh.status();
let body = refresh
.text()
.await
.unwrap_or_else(|_| "<unable to read response body>".into());
return Err(format!("Session refresh failed with status {}: {}", status, body));
}
let refreshed = refresh
.json::<LoginResponse>()
.await
.map_err(|err| format!("Unable to decode refresh response: {}", err))?;
existing.access_token = refreshed.access_token;
existing.refresh_token = refreshed.refresh_token;
response = client
.put(format!(
"{}/api/v1/me/profile-selection",
existing.server_url.trim_end_matches('/')
))
.bearer_auth(&existing.access_token)
.json(&SelectProfilePayload {
profile_id: &profile_id,
})
.send()
.await
.map_err(|err| format!("Profile selection 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 selection failed with status {}: {}", status, body));
}
let _ = response;
existing = sync_current_session(&app).await?;
refresh_tray_menu(&app);
Ok(existing.enrollment)
}
#[tauri::command]
async fn connect_tunnel(app: AppHandle) -> Result<EnrollmentResult, String> {
let session_state = sync_current_session(&app).await?;
@@ -346,6 +461,19 @@ fn materialize_profile(profile_content: &str, private_key: &str) -> String {
.replace("__CLIENT_PRIVATE_KEY_REQUIRED__", private_key)
}
fn map_access_profiles(items: Vec<AccessProfileView>) -> Vec<AccessProfile> {
items
.into_iter()
.map(|item| AccessProfile {
id: item.id,
name: item.name,
description: item.description,
full_tunnel: item.full_tunnel,
destinations: item.destinations,
})
.collect()
}
fn write_profile(app: &AppHandle, profile_content: &str) -> Result<PathBuf, String> {
let app_dir = ensure_app_dir(app)?;
let profile_path = app_dir.join(format!("{}.conf", PROFILE_NAME));
@@ -499,6 +627,8 @@ async fn sync_current_session(app: &AppHandle) -> Result<SessionState, String> {
let result = EnrollmentResult {
assigned_ip: enroll.peer.assigned_ip,
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
available_profiles: map_access_profiles(enroll.available_profiles),
selected_profile_id: enroll.selected_profile_id,
profile_revision: enroll.peer.profile_revision,
gateway_endpoint: enroll.peer.gateway.endpoint,
profile_path: profile_path.display().to_string(),
@@ -805,7 +935,17 @@ pub fn run() {
}
_ => {}
})
.invoke_handler(tauri::generate_handler![load_state, clear_session, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel, tunnel_status, tunnel_metrics])
.invoke_handler(tauri::generate_handler![
load_state,
clear_session,
enroll_device,
sync_profile,
select_access_profile,
connect_tunnel,
disconnect_tunnel,
tunnel_status,
tunnel_metrics
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}