Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,11 @@ private string[] DiscoverVisibleConfigLeafEditorIds()
.Select(static item => RouteToEditorId(item.Route!));

using var security = new SecurityAccessViewModel(_paths);
var securityEditors = security.Items.Select(SecurityAccessItemToEditorId);
var securityEditors = security.Items
// "Done" is a navigation row (back to the config dashboard), not a leaf editor — exclude it,
// the same way the dashboard's non-routed "Run Full Doctor"/"Quit" rows are excluded above.
.Where(static item => item.Label != "Done")
.Select(SecurityAccessItemToEditorId);

return rootEditors.Concat(securityEditors).OrderBy(static id => id).ToArray();
}
Expand Down
61 changes: 60 additions & 1 deletion src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,69 @@ public void Security_access_lists_expected_leaf_entries()
"Security Posture",
"Enabled Features",
"Audience Profiles",
"Exposure Mode"
"Exposure Mode",
"Done"
], labels);
}

[Fact]
public void Done_item_returns_to_the_config_dashboard()
{
using var vm = new SecurityAccessViewModel(Context.Paths);
string? routed = null;
vm.RouteRequested = route => routed = route;

var doneIndex = vm.Items
.Select((item, index) => (item, index))
.Single(entry => entry.item.Label == "Done")
.index;
vm.SelectedIndex.Value = doneIndex;
vm.ActivateSelected();

Assert.Equal("/config", routed);
}

[Fact]
public void Posture_editor_done_row_backs_out_to_menu()
{
using var vm = new SecurityAccessViewModel(Context.Paths);
vm.OpenPostureEditor();
vm.SelectedPostureIndex.Value = vm.PostureOptions.Count; // the appended Done row
vm.ApplySelectedPosture(); // Enter on Done
Assert.Equal(SecurityAccessEditorMode.Menu, vm.Mode.Value);
}

[Fact]
public void Feature_editor_done_row_backs_out_to_menu()
{
using var vm = new SecurityAccessViewModel(Context.Paths);
vm.OpenFeatureEditor();
vm.SelectedFeatureIndex.Value = vm.FeatureNames.Count; // the appended Done row
vm.ToggleSelectedFeature(); // Space/Enter on Done
Assert.Equal(SecurityAccessEditorMode.Menu, vm.Mode.Value);
}

[Fact]
public void Audience_list_done_row_backs_out_to_menu()
{
using var vm = new SecurityAccessViewModel(Context.Paths);
vm.OpenAudienceList();
vm.SelectedAudienceIndex.Value = vm.AudienceOptions.Count; // the appended Done row
vm.OpenSelectedAudienceProfile(); // Enter on Done
Assert.Equal(SecurityAccessEditorMode.Menu, vm.Mode.Value);
}

[Fact]
public void Audience_profile_done_row_backs_out_to_audience_list()
{
using var vm = new SecurityAccessViewModel(Context.Paths);
vm.OpenAudienceList();
vm.OpenSelectedAudienceProfile(); // enter the first audience's profile
vm.SelectedAudienceRowIndex.Value = vm.ProfileRows.Count; // the appended Done row
vm.ActivateSelectedAudienceProfileRow(); // Space/Enter on Done
Assert.Equal(SecurityAccessEditorMode.AudienceList, vm.Mode.Value);
}

[Fact]
public void Unparseable_posture_fails_loud_and_closed_not_permissive()
{
Expand Down
28 changes: 26 additions & 2 deletions src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ private ILayoutNode BuildPostureEditor()
active));
}

var doneFocused = ViewModel.SelectedPostureIndex.Value == options.Count;
layout = layout
.WithChild(Layouts.Empty().Height(1))
.WithChild(Row($"{FocusPrefix(doneFocused)}Done Return to Security & Access.", doneFocused));

return layout;
}

Expand Down Expand Up @@ -138,6 +143,11 @@ private ILayoutNode BuildFeatureToggles()
enabled));
}

var doneFocused = ViewModel.SelectedFeatureIndex.Value == names.Count;
layout = layout
.WithChild(Layouts.Empty().Height(1))
.WithChild(Row($"{FocusPrefix(doneFocused)}Done Return to Security & Access.", doneFocused));

return layout;
}

Expand All @@ -162,6 +172,11 @@ private ILayoutNode BuildAudienceList()
focused));
}

var doneFocused = ViewModel.SelectedAudienceIndex.Value == options.Count;
layout = layout
.WithChild(Layouts.Empty().Height(1))
.WithChild(Row($"{FocusPrefix(doneFocused)}Done Return to Security & Access.", doneFocused));

return layout;
}

Expand Down Expand Up @@ -206,10 +221,19 @@ private ILayoutNode BuildAudienceProfile()
layout = layout.WithChild(Row(line, focused, enabled));
}

var focusedRow = rows[ViewModel.SelectedAudienceRowIndex.Value];
var doneFocused = ViewModel.SelectedAudienceRowIndex.Value == rows.Count;
layout = layout
.WithChild(Layouts.Empty().Height(1))
.WithChild(Hint($" {ViewModel.AudienceRowHelp(focusedRow.Kind)}"));
.WithChild(Row($"{FocusPrefix(doneFocused)}Done Return to Audiences.", doneFocused));

// Per-row help applies only to a real row; the appended Done row (index == rows.Count) has none.
if (!doneFocused)
{
var focusedRow = rows[ViewModel.SelectedAudienceRowIndex.Value];
layout = layout
.WithChild(Layouts.Empty().Height(1))
.WithChild(Hint($" {ViewModel.AudienceRowHelp(focusedRow.Kind)}"));
}

return layout;
}
Expand Down
44 changes: 39 additions & 5 deletions src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,13 @@ public void MoveSelection(int delta)
SelectedIndex.Value = next;
}

public void MovePostureSelection(int delta) => Move(SelectedPostureIndex, delta, Postures.Length);
// Each editor appends a "Done" row after its real items (index == item count), so navigation extends one
// past the array; activation at that index backs out instead of acting on a row (see the action guards).
public void MovePostureSelection(int delta) => Move(SelectedPostureIndex, delta, Postures.Length + 1);
public void MoveCascadeSelection(int delta) => Move(SelectedCascadeIndex, delta, CascadeOptions.Length);
public void MoveFeatureSelection(int delta) => Move(SelectedFeatureIndex, delta, FeatureConfigPaths.Length);
public void MoveAudienceSelection(int delta) => Move(SelectedAudienceIndex, delta, Audiences.Length);
public void MoveAudienceRow(int delta) => Move(SelectedAudienceRowIndex, delta, AudienceRows.Length);
public void MoveFeatureSelection(int delta) => Move(SelectedFeatureIndex, delta, FeatureConfigPaths.Length + 1);
public void MoveAudienceSelection(int delta) => Move(SelectedAudienceIndex, delta, Audiences.Length + 1);
public void MoveAudienceRow(int delta) => Move(SelectedAudienceRowIndex, delta, AudienceRows.Length + 1);

public void ActivateSelected()
{
Expand Down Expand Up @@ -203,6 +205,10 @@ internal void Activate(SecurityAccessItem item)
case "Audience Profiles":
OpenAudienceList();
return;
case "Done":
// Discoverable equivalent of Esc — back out to the config dashboard.
GoBack();
return;
}

if (item.Route is not null)
Expand Down Expand Up @@ -256,6 +262,12 @@ public void OpenPostureEditor()

public void ApplySelectedPosture()
{
if (SelectedPostureIndex.Value >= Postures.Length)
{
GoBack();
return;
}

var posture = Postures[SelectedPostureIndex.Value].Value;
if (posture == CurrentPosture)
{
Expand Down Expand Up @@ -314,6 +326,12 @@ public void OpenFeatureEditor()

public void ToggleSelectedFeature()
{
if (SelectedFeatureIndex.Value >= FeatureConfigPaths.Length)
{
GoBack();
return;
}

var index = SelectedFeatureIndex.Value;
_enabledFeatures[index] = !_enabledFeatures[index];

Expand All @@ -340,6 +358,12 @@ public void OpenAudienceList()

public void OpenSelectedAudienceProfile()
{
if (SelectedAudienceIndex.Value >= Audiences.Length)
{
GoBack();
return;
}

SelectedAudienceRowIndex.Value = 0;
Mode.Value = SecurityAccessEditorMode.AudienceProfile;
StatusMessage.Value = "";
Expand Down Expand Up @@ -386,6 +410,12 @@ public string AudienceValue(AudienceProfileRowKind kind)

public void ActivateSelectedAudienceProfileRow()
{
if (SelectedAudienceRowIndex.Value >= AudienceRows.Length)
{
GoBack();
return;
}

var row = AudienceRows[SelectedAudienceRowIndex.Value];
switch (row.Kind)
{
Expand Down Expand Up @@ -423,6 +453,9 @@ public void ActivateSelectedAudienceProfileRow()

public void ChangeSelectedAudienceProfileRow(int direction)
{
if (SelectedAudienceRowIndex.Value >= AudienceRows.Length)
return; // the Done row has no value to cycle with ←/→

var row = AudienceRows[SelectedAudienceRowIndex.Value];
switch (row.Kind)
{
Expand Down Expand Up @@ -657,7 +690,8 @@ private IReadOnlyList<SecurityAccessItem> BuildItems()
"Deployment trust stance."),
new("Enabled Features", ReadEnabledFeaturesSummary(config), "Deployment-wide runtime feature gates."),
new("Audience Profiles", ReadAudienceProfilesSummary(config), "Curated per-audience access rules."),
new("Exposure Mode", ReadExposureModeSummary(config), "Daemon reachability and tunnel topology.", "/exposure-mode")
new("Exposure Mode", ReadExposureModeSummary(config), "Daemon reachability and tunnel topology.", "/exposure-mode"),
new("Done", "", "Return to Settings Areas.")
];
}

Expand Down
Loading