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:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user