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

@@ -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>

View File

@@ -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>
);

View File

@@ -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} />

View File

@@ -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%;