diff --git a/backend/internal/device/handler.go b/backend/internal/device/handler.go index 9426257..97cc16c 100644 --- a/backend/internal/device/handler.go +++ b/backend/internal/device/handler.go @@ -113,6 +113,28 @@ func (h *Handler) GetOwnProfile(w http.ResponseWriter, r *http.Request) { apiutil.JSON(w, http.StatusOK, response) } +func (h *Handler) SelectOwnProfile(w http.ResponseWriter, r *http.Request) { + userID, ok := requestctx.MustUserID(r.Context()) + if !ok { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims") + return + } + + var input SelectProfileRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body") + return + } + + response, err := h.service.SelectProfile(r.Context(), userID, input.ProfileID) + if err != nil { + apiutil.Error(w, http.StatusBadRequest, "profile_selection_failed", err.Error()) + return + } + + apiutil.JSON(w, http.StatusOK, response) +} + func (h *Handler) GetProfileByDeviceID(w http.ResponseWriter, r *http.Request) { deviceID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { diff --git a/backend/internal/device/repository.go b/backend/internal/device/repository.go index 3c8d3c5..b9cffb5 100644 --- a/backend/internal/device/repository.go +++ b/backend/internal/device/repository.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -18,6 +19,8 @@ type Repository interface { ListAll(ctx context.Context) ([]Device, error) GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUID) (EnrollmentResponse, error) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error) + GetSelectedProfileID(ctx context.Context, deviceID uuid.UUID) (*uuid.UUID, error) + SetSelectedProfileID(ctx context.Context, deviceID uuid.UUID, profileID uuid.UUID) error Revoke(ctx context.Context, deviceID uuid.UUID) error Rotate(ctx context.Context, deviceID uuid.UUID) error } @@ -178,6 +181,43 @@ func (r *PGRepository) GetEnrollmentByDeviceID(ctx context.Context, deviceID uui return scanEnrollmentRow(row) } +func (r *PGRepository) GetSelectedProfileID(ctx context.Context, deviceID uuid.UUID) (*uuid.UUID, error) { + row := r.db.QueryRow(ctx, ` + select value->>'profile_id' + from settings + where category = 'device_access_profile' and key = $1 + `, deviceID.String()) + + var raw string + if err := row.Scan(&raw); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + value, err := uuid.Parse(raw) + if err != nil { + return nil, err + } + return &value, nil +} + +func (r *PGRepository) SetSelectedProfileID(ctx context.Context, deviceID uuid.UUID, profileID uuid.UUID) error { + payload, err := json.Marshal(map[string]string{"profile_id": profileID.String()}) + if err != nil { + return err + } + + _, err = r.db.Exec(ctx, ` + insert into settings (category, key, value, updated_at) + values ('device_access_profile', $1, $2::jsonb, now()) + on conflict (category, key) + do update set value = excluded.value, updated_at = now() + `, deviceID.String(), string(payload)) + return err +} + func (r *PGRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Device, error) { rows, err := r.db.Query(ctx, ` select d.id, d.user_id, d.gateway_id, d.name, d.platform, d.status, coalesce(host(wp.assigned_ip), '') diff --git a/backend/internal/device/service.go b/backend/internal/device/service.go index 6871062..dd14177 100644 --- a/backend/internal/device/service.go +++ b/backend/internal/device/service.go @@ -2,6 +2,7 @@ package device import ( "context" + "fmt" "os" "strings" @@ -63,7 +64,14 @@ func (s *Service) Enroll(ctx context.Context, userID uuid.UUID, input EnrollRequ if len(destinations) == 0 { destinations = []string{"172.16.10.0/24"} } - profileAllowedIPs := mergeProfileAllowedIPs(destinations, selectedGateway.DNSServers, alwaysAllowWebProxyTargets()) + availableProfiles, selectedProfileID, selectedDestinations, err := s.resolveAccessProfiles(ctx, userID, enrollment.Device.ID) + if err != nil { + return EnrollmentResponse{}, err + } + if len(selectedDestinations) == 0 { + selectedDestinations = destinations + } + profileAllowedIPs := mergeProfileAllowedIPs(selectedDestinations, selectedGateway.DNSServers, alwaysAllowWebProxyTargets()) enrollment.Peer = PeerView{ AssignedIP: assignedIP, @@ -77,13 +85,9 @@ func (s *Service) Enroll(ctx context.Context, userID uuid.UUID, input EnrollRequ }, ProfileRevision: 1, } - for _, destination := range destinations { - enrollment.Resources = append(enrollment.Resources, Resource{ - Type: "cidr", - Value: destination, - Label: destination, - }) - } + enrollment.Resources = resourcesFromDestinations(selectedDestinations) + enrollment.AvailableProfiles = availableProfiles + enrollment.SelectedProfileID = selectedProfileID enrollment.Profile = ProfileView{ Format: "wireguard", @@ -125,6 +129,35 @@ func (s *Service) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUI return s.applyCurrentPolicy(ctx, enrollment) } +func (s *Service) SelectProfile(ctx context.Context, userID uuid.UUID, profileID uuid.UUID) (EnrollmentResponse, error) { + enrollment, err := s.repo.GetLatestEnrollmentByUser(ctx, userID) + if err != nil { + return EnrollmentResponse{}, err + } + + profiles, err := s.policyService.ListSelectableProfiles(ctx, userID, &enrollment.Device.ID) + if err != nil { + return EnrollmentResponse{}, err + } + + var exists bool + for _, profile := range profiles { + if profile.ID == profileID { + exists = true + break + } + } + if !exists { + return EnrollmentResponse{}, fmt.Errorf("selected access profile is not available for this device") + } + + if err := s.repo.SetSelectedProfileID(ctx, enrollment.Device.ID, profileID); err != nil { + return EnrollmentResponse{}, err + } + + return s.applyCurrentPolicy(ctx, enrollment) +} + func (s *Service) GetConnectionStatus(ctx context.Context, userID uuid.UUID) (ConnectionStatus, error) { enrollment, err := s.repo.GetLatestEnrollmentByUser(ctx, userID) if err != nil { @@ -134,6 +167,11 @@ func (s *Service) GetConnectionStatus(ctx context.Context, userID uuid.UUID) (Co }, nil } + enrollment, err = s.applyCurrentPolicy(ctx, enrollment) + if err != nil { + return ConnectionStatus{}, err + } + lastSync := "just now" return ConnectionStatus{ Status: "provisioned", @@ -169,24 +207,70 @@ func withDebugProfile(enrollment EnrollmentResponse) EnrollmentResponse { } func (s *Service) applyCurrentPolicy(ctx context.Context, enrollment EnrollmentResponse) (EnrollmentResponse, error) { - destinations, err := s.policyService.ResolveDestinations(ctx, enrollment.Device.UserID, &enrollment.Device.ID) + availableProfiles, selectedProfileID, selectedDestinations, err := s.resolveAccessProfiles(ctx, enrollment.Device.UserID, enrollment.Device.ID) if err != nil { return EnrollmentResponse{}, err } - if len(destinations) == 0 { - destinations = []string{"172.16.10.0/24"} + if len(selectedDestinations) == 0 { + selectedDestinations = []string{"172.16.10.0/24"} } - enrollment.Resources = nil + enrollment.Resources = resourcesFromDestinations(selectedDestinations) + enrollment.AvailableProfiles = availableProfiles + enrollment.SelectedProfileID = selectedProfileID + enrollment.Peer.AllowedIPs = mergeProfileAllowedIPs(selectedDestinations, enrollment.Peer.DNSServers, alwaysAllowWebProxyTargets()) + return withDebugProfile(enrollment), nil +} + +func (s *Service) resolveAccessProfiles(ctx context.Context, userID uuid.UUID, deviceID uuid.UUID) ([]AccessProfile, *uuid.UUID, []string, error) { + profiles, err := s.policyService.ListSelectableProfiles(ctx, userID, &deviceID) + if err != nil { + return nil, nil, nil, err + } + + availableProfiles := make([]AccessProfile, 0, len(profiles)) + for _, profile := range profiles { + availableProfiles = append(availableProfiles, AccessProfile{ + ID: profile.ID, + Name: profile.Name, + Description: profile.Description, + FullTunnel: profile.FullTunnel, + Destinations: profile.Destinations, + }) + } + + if len(availableProfiles) == 0 { + return nil, nil, nil, nil + } + + selectedProfileID, err := s.repo.GetSelectedProfileID(ctx, deviceID) + if err != nil { + return nil, nil, nil, err + } + + for _, profile := range availableProfiles { + if selectedProfileID != nil && profile.ID == *selectedProfileID { + return availableProfiles, selectedProfileID, profile.Destinations, nil + } + } + + fallback := availableProfiles[0] + if err := s.repo.SetSelectedProfileID(ctx, deviceID, fallback.ID); err != nil { + return nil, nil, nil, err + } + return availableProfiles, &fallback.ID, fallback.Destinations, nil +} + +func resourcesFromDestinations(destinations []string) []Resource { + resources := make([]Resource, 0, len(destinations)) for _, destination := range destinations { - enrollment.Resources = append(enrollment.Resources, Resource{ + resources = append(resources, Resource{ Type: "cidr", Value: destination, Label: destination, }) } - enrollment.Peer.AllowedIPs = mergeProfileAllowedIPs(destinations, enrollment.Peer.DNSServers, alwaysAllowWebProxyTargets()) - return withDebugProfile(enrollment), nil + return resources } func mergeProfileAllowedIPs(destinations []string, dnsServers []string, webProxyTargets []string) []string { diff --git a/backend/internal/device/types.go b/backend/internal/device/types.go index 0745876..8ca89f7 100644 --- a/backend/internal/device/types.go +++ b/backend/internal/device/types.go @@ -37,10 +37,20 @@ type Resource struct { } type EnrollmentResponse struct { - Device Device `json:"device"` - Peer PeerView `json:"peer"` - Profile ProfileView `json:"profile"` - Resources []Resource `json:"resources"` + Device Device `json:"device"` + Peer PeerView `json:"peer"` + Profile ProfileView `json:"profile"` + Resources []Resource `json:"resources"` + AvailableProfiles []AccessProfile `json:"available_profiles"` + SelectedProfileID *uuid.UUID `json:"selected_profile_id,omitempty"` +} + +type AccessProfile struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + FullTunnel bool `json:"full_tunnel"` + Destinations []string `json:"destinations"` } type PeerView struct { @@ -62,3 +72,7 @@ type ProfileView struct { Format string `json:"format"` Content string `json:"content"` } + +type SelectProfileRequest struct { + ProfileID uuid.UUID `json:"profile_id"` +} diff --git a/backend/internal/gateway/repository.go b/backend/internal/gateway/repository.go index 45096c8..d8126ea 100644 --- a/backend/internal/gateway/repository.go +++ b/backend/internal/gateway/repository.go @@ -98,13 +98,22 @@ func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) from devices d join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null join gateways g on g.id = d.gateway_id + left join settings s on s.category = 'device_access_profile' and s.key = d.id::text left join group_memberships gm on gm.user_id = d.user_id left join policy_targets pt on ( (pt.target_type = 'device' and pt.target_id = d.id) or (pt.target_type = 'user' and pt.target_id = d.user_id) or (pt.target_type = 'group' and pt.target_id = gm.group_id) ) - left join policy_destinations pd on pd.policy_id = pt.policy_id + left join policies p on p.id = pt.policy_id + and p.deleted_at is null + and p.is_active = true + and p.effect = 'allow' + left join policy_destinations pd on pd.policy_id = p.id + and ( + s.value->>'profile_id' is null + or p.id::text = s.value->>'profile_id' + ) where d.gateway_id = $1 and d.deleted_at is null and d.status = 'active' group by d.id, wp.public_key, wp.assigned_ip, g.dns_servers `, gatewayID) diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go index a5adb1c..a0b6251 100644 --- a/backend/internal/httpserver/router.go +++ b/backend/internal/httpserver/router.go @@ -49,6 +49,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler { r.Post("/devices/enroll", handlers.Device.Enroll) r.Get("/me/devices", handlers.Device.ListOwn) r.Get("/me/profile", handlers.Device.GetOwnProfile) + r.Put("/me/profile-selection", handlers.Device.SelectOwnProfile) r.Get("/connection/status", handlers.Device.ConnectionStatus) r.Route("/admin", func(r chi.Router) { diff --git a/backend/internal/policy/repository.go b/backend/internal/policy/repository.go index 5fb8583..3723af9 100644 --- a/backend/internal/policy/repository.go +++ b/backend/internal/policy/repository.go @@ -14,6 +14,7 @@ type Repository interface { Update(ctx context.Context, policyID uuid.UUID, input UpdateRequest) (Policy, error) Delete(ctx context.Context, policyID uuid.UUID) error ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) + ListSelectableProfiles(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]SelectableProfile, error) } type PGRepository struct { @@ -206,6 +207,53 @@ func (r *PGRepository) ResolveDestinations(ctx context.Context, userID uuid.UUID return destinations, rows.Err() } +func (r *PGRepository) ListSelectableProfiles(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]SelectableProfile, error) { + query := ` + select + p.id, + p.name, + p.description, + p.full_tunnel, + coalesce(array_agg(pd.destination::text order by pd.destination::text) filter (where pd.destination is not null), '{}') + from policies p + left join policy_destinations pd on pd.policy_id = p.id + join policy_targets pt on pt.policy_id = p.id + where p.deleted_at is null + and p.is_active = true + and p.effect = 'allow' + and ( + (pt.target_type = 'user' and pt.target_id = $1) + or (pt.target_type = 'group' and exists ( + select 1 from group_memberships gm + where gm.group_id = pt.target_id and gm.user_id = $1 + )) + ` + args := []any{userID} + if deviceID != nil { + query += ` or (pt.target_type = 'device' and pt.target_id = $2)` + args = append(args, *deviceID) + } + query += `) + group by p.id, p.name, p.description, p.full_tunnel, p.priority, p.created_at + order by p.priority asc, p.created_at desc` + + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var profiles []SelectableProfile + for rows.Next() { + var item SelectableProfile + if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.FullTunnel, &item.Destinations); err != nil { + return nil, err + } + profiles = append(profiles, item) + } + return profiles, rows.Err() +} + func (r *PGRepository) getByID(ctx context.Context, policyID uuid.UUID) (Policy, error) { items, err := r.List(ctx) if err != nil { diff --git a/backend/internal/policy/service.go b/backend/internal/policy/service.go index e1fac9f..1fe5f7b 100644 --- a/backend/internal/policy/service.go +++ b/backend/internal/policy/service.go @@ -39,3 +39,7 @@ func (s *Service) Delete(ctx context.Context, policyID uuid.UUID) error { func (s *Service) ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) { return s.repo.ResolveDestinations(ctx, userID, deviceID) } + +func (s *Service) ListSelectableProfiles(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]SelectableProfile, error) { + return s.repo.ListSelectableProfiles(ctx, userID, deviceID) +} diff --git a/backend/internal/policy/types.go b/backend/internal/policy/types.go index 5f384bd..bd73c9f 100644 --- a/backend/internal/policy/types.go +++ b/backend/internal/policy/types.go @@ -40,3 +40,11 @@ type UpdateRequest struct { Destinations []string `json:"destinations"` Targets []Target `json:"targets"` } + +type SelectableProfile struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + FullTunnel bool `json:"full_tunnel"` + Destinations []string `json:"destinations"` +} diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index cc7011f..95c85e4 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -58,7 +58,12 @@ struct EnrollmentPayload { #[serde(rename_all = "camelCase")] struct EnrollmentResult { assigned_ip: String, + #[serde(default)] resources: Vec, + #[serde(default)] + available_profiles: Vec, + #[serde(default)] + selected_profile_id: Option, 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, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct TunnelMetrics { @@ -113,6 +128,8 @@ struct EnrollResponse { peer: PeerView, profile: ProfileView, resources: Vec, + available_profiles: Vec, + selected_profile_id: Option, } #[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, +} + #[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 Result { + let mut existing = { + let state = app.state::(); + 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(|_| "".into()); + return Err(format!("Session refresh failed with status {}: {}", status, body)); + } + + let refreshed = refresh + .json::() + .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(|_| "".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 { 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) -> Vec { + 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 { 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 { 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"); } diff --git a/desktop-client/src/App.tsx b/desktop-client/src/App.tsx index 2ec8560..0a14500 100644 --- a/desktop-client/src/App.tsx +++ b/desktop-client/src/App.tsx @@ -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(null); const [connected, setConnected] = useState(false); const [state, setState] = useState(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("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() { )} diff --git a/desktop-client/src/components/AppHeader.tsx b/desktop-client/src/components/AppHeader.tsx index 3dbd1f4..1269738 100644 --- a/desktop-client/src/components/AppHeader.tsx +++ b/desktop-client/src/components/AppHeader.tsx @@ -33,23 +33,27 @@ export function AppHeader({
- {enrolled ? ( - <> - - {syncing ? "Syncing..." : "Sync"} - - - Logout - - - ) : null} - - {!enrolled ? "Provision first" : connected ? "Disconnect" : "Connect"} - +
+ {enrolled ? ( + <> + + {syncing ? "Syncing..." : "Sync"} + + + Logout + + + ) : null} +
+
+ + {!enrolled ? "Provision first" : connected ? "Disconnect" : "Connect"} + +
); diff --git a/desktop-client/src/components/ResourcePanel.tsx b/desktop-client/src/components/ResourcePanel.tsx index 97e1884..67a4719 100644 --- a/desktop-client/src/components/ResourcePanel.tsx +++ b/desktop-client/src/components/ResourcePanel.tsx @@ -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 (