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");
|
||||
}
|
||||
|
||||
@@ -4,9 +4,19 @@ import { AppHeader } from "./components/AppHeader";
|
||||
import { ResourcePanel } from "./components/ResourcePanel";
|
||||
import { StatusCard } from "./components/StatusCard";
|
||||
|
||||
type AccessProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fullTunnel: boolean;
|
||||
destinations: string[];
|
||||
};
|
||||
|
||||
type EnrollmentState = {
|
||||
assignedIp: string;
|
||||
resources: string[];
|
||||
availableProfiles: AccessProfile[];
|
||||
selectedProfileId: string | null;
|
||||
profileRevision: number;
|
||||
gatewayEndpoint: string;
|
||||
profilePath: string;
|
||||
@@ -36,6 +46,11 @@ function currentProfileLabel(state: EnrollmentState | null) {
|
||||
return "Not provisioned";
|
||||
}
|
||||
|
||||
const selectedProfile = state.availableProfiles.find((profile) => profile.id === state.selectedProfileId);
|
||||
if (selectedProfile) {
|
||||
return selectedProfile.name;
|
||||
}
|
||||
|
||||
if (state.resources.includes("0.0.0.0/0")) {
|
||||
return "Full tunnel";
|
||||
}
|
||||
@@ -53,6 +68,7 @@ export function App() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [selectingProfile, setSelectingProfile] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [state, setState] = useState<EnrollmentState | null>(null);
|
||||
@@ -150,6 +166,25 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectProfile(profileId: string) {
|
||||
if (!state || profileId === state.selectedProfileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectingProfile(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await invoke<EnrollmentState>("select_access_profile", { profileId });
|
||||
setState(result);
|
||||
await refreshTunnelStatus();
|
||||
} catch (err) {
|
||||
setError(formatInvokeError(err, "Profile selection failed"));
|
||||
} finally {
|
||||
setSelectingProfile(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleConnection() {
|
||||
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
|
||||
try {
|
||||
@@ -248,9 +283,14 @@ export function App() {
|
||||
)}
|
||||
|
||||
<ResourcePanel
|
||||
connected={connected}
|
||||
onReset={resetEnrollment}
|
||||
onSelectProfile={onSelectProfile}
|
||||
profileLabel={profileLabel}
|
||||
profiles={state?.availableProfiles ?? []}
|
||||
resources={state?.resources ?? []}
|
||||
selectedProfileId={state?.selectedProfileId ?? null}
|
||||
selectingProfile={selectingProfile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,23 +33,27 @@ export function AppHeader({
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
{enrolled ? (
|
||||
<>
|
||||
<ActionButton disabled={syncing} onClick={onSync} variant="secondary">
|
||||
{syncing ? "Syncing..." : "Sync"}
|
||||
</ActionButton>
|
||||
<ActionButton onClick={onLogout} variant="ghost">
|
||||
Logout
|
||||
</ActionButton>
|
||||
</>
|
||||
) : null}
|
||||
<ActionButton
|
||||
disabled={!enrolled}
|
||||
onClick={onToggleConnection}
|
||||
variant={connected ? "danger" : "primary"}
|
||||
>
|
||||
{!enrolled ? "Provision first" : connected ? "Disconnect" : "Connect"}
|
||||
</ActionButton>
|
||||
<div className="header-actions-secondary">
|
||||
{enrolled ? (
|
||||
<>
|
||||
<ActionButton disabled={syncing} onClick={onSync} variant="secondary">
|
||||
{syncing ? "Syncing..." : "Sync"}
|
||||
</ActionButton>
|
||||
<ActionButton onClick={onLogout} variant="ghost">
|
||||
Logout
|
||||
</ActionButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="header-actions-primary">
|
||||
<ActionButton
|
||||
disabled={!enrolled}
|
||||
onClick={onToggleConnection}
|
||||
variant={connected ? "danger" : "primary"}
|
||||
>
|
||||
{!enrolled ? "Provision first" : connected ? "Disconnect" : "Connect"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,34 @@ function ResourceListItem({ value }: { value: string }) {
|
||||
}
|
||||
|
||||
type ResourcePanelProps = {
|
||||
connected: boolean;
|
||||
profiles: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
destinations: string[];
|
||||
}>;
|
||||
resources: string[];
|
||||
profileLabel: string;
|
||||
selectedProfileId: string | null;
|
||||
selectingProfile: boolean;
|
||||
onSelectProfile: (profileId: string) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export function ResourcePanel({ resources, profileLabel, onReset }: ResourcePanelProps) {
|
||||
export function ResourcePanel({
|
||||
connected,
|
||||
profiles,
|
||||
resources,
|
||||
profileLabel,
|
||||
selectedProfileId,
|
||||
selectingProfile,
|
||||
onSelectProfile,
|
||||
onReset
|
||||
}: ResourcePanelProps) {
|
||||
const effectiveResources = resources.length > 0 ? resources : ["Keine Ressourcen zugewiesen"];
|
||||
const showSelector = profiles.length > 1;
|
||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
|
||||
return (
|
||||
<aside className="resource-panel">
|
||||
@@ -31,8 +52,29 @@ export function ResourcePanel({ resources, profileLabel, onReset }: ResourcePane
|
||||
<div className="resource-meta">
|
||||
<span className="resource-meta-label">Zugriffsprofil</span>
|
||||
<strong>{profileLabel}</strong>
|
||||
{selectedProfile?.description ? <small>{selectedProfile.description}</small> : null}
|
||||
</div>
|
||||
|
||||
{showSelector ? (
|
||||
<label className="resource-selector">
|
||||
<span className="resource-meta-label">Ressource auswählen</span>
|
||||
<select
|
||||
disabled={connected || selectingProfile}
|
||||
onChange={(event) => onSelectProfile(event.target.value)}
|
||||
value={selectedProfileId ?? profiles[0]?.id ?? ""}
|
||||
>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
{connected ? "Auswahl ist nur getrennt möglich." : selectingProfile ? "Profil wird aktualisiert..." : "Auswahl wird vor dem Verbinden in die Config übernommen."}
|
||||
</small>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<ul className="resource-list">
|
||||
{effectiveResources.map((resource) => (
|
||||
<ResourceListItem key={resource} value={resource} />
|
||||
|
||||
@@ -69,6 +69,7 @@ input {
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.brand-icon-shell {
|
||||
@@ -119,14 +120,23 @@ input {
|
||||
margin: 0;
|
||||
color: #9fb3d4;
|
||||
font-size: 0.98rem;
|
||||
max-width: 620px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.header-actions-secondary,
|
||||
.header-actions-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
@@ -135,6 +145,7 @@ input {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
@@ -382,7 +393,8 @@ input {
|
||||
}
|
||||
|
||||
.resource-panel {
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
grid-auto-rows: min-content;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.resource-count {
|
||||
@@ -408,6 +420,40 @@ input {
|
||||
border: 1px solid rgba(154, 181, 228, 0.08);
|
||||
}
|
||||
|
||||
.resource-meta small,
|
||||
.resource-selector small {
|
||||
color: #91a8cc;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.resource-selector {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-selector select {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(177, 197, 229, 0.16);
|
||||
background: rgba(7, 14, 27, 0.9);
|
||||
color: #f5f7fb;
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
outline: none;
|
||||
transition: border-color 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
|
||||
.resource-selector select:hover:not(:disabled),
|
||||
.resource-selector select:focus-visible {
|
||||
border-color: rgba(118, 218, 200, 0.28);
|
||||
background: rgba(10, 18, 32, 0.96);
|
||||
}
|
||||
|
||||
.resource-selector select:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.resource-meta-label {
|
||||
color: #8fa6ca;
|
||||
font-size: 0.75rem;
|
||||
@@ -506,7 +552,7 @@ input {
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.body-grid {
|
||||
@@ -539,12 +585,16 @@ input {
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.header-actions-secondary,
|
||||
.header-actions-primary,
|
||||
.resource-footer,
|
||||
.login-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions .action-button,
|
||||
.header-actions-secondary .action-button,
|
||||
.header-actions-primary .action-button,
|
||||
.resource-footer .action-button,
|
||||
.login-actions .action-button {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user