feat(workspace): sealed-area names are confidential by default
Some checks failed
Security / Security check (push) Failing after 2s

The switcher listed sealed areas by name ('lbs', 'stromnetz') on
any glance or screenshot — but the names themselves often carry
client/mandate identity (usertest security finding). The sealed
section now renders one aggregated row ('2 sealed areas') with a
deliberate 'Show names' reveal per menu opening; selection still
pops the regular s:<slug> value. Settings -> Security gains 'list
sealed areas with their names right away' (WorkspacePrefs,
SidebarPrefs pattern, default off).

The aggregate row wraps to two lines — popup menus cap their
width and action texts must never be truncated (the first cut
showed '1 abgeschotte…' in the proof shot). Guard: switcher tests
cover aggregated-until-reveal and the Settings toggle; the old
direct-listing test now asserts the reveal contract. DE+EN.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-19 03:34:05 +02:00
parent ed680c507a
commit 588f437395
11 changed files with 370 additions and 41 deletions

View file

@ -6,6 +6,16 @@ lockstep.
## Unreleased ## Unreleased
### Security (0.75.0)
- **Sealed-area names are confidential by default.** The workspace
switcher no longer lists sealed areas by name on a casual glance
(names often carry client/mandate identity — usertest security
finding): it shows one aggregated row ("2 sealed areas") and
reveals the named rows only after a deliberate click, per menu
opening. Settings → Security offers "list sealed areas with
their names right away" for single-operator machines.
### Changed (0.75.0) ### Changed (0.75.0)
- **One segment control everywhere.** The same single-select - **One segment control everywhere.** The same single-select

View file

@ -0,0 +1,32 @@
// Workspace display preferences (SidebarPrefs pattern: eager
// ValueNotifier + SharedPreferences persistence, loaded once at
// startup).
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class WorkspacePrefs {
WorkspacePrefs._();
static const _kSealedNamesKey = 'workspace.sealed_names_visible';
/// Whether the workspace switcher lists sealed areas with their
/// names right away. Default FALSE: area names often carry
/// client/mandate identity, so the switcher shows an aggregated
/// row ("2 sealed areas") until the operator deliberately
/// expands it (usertest security finding). The Settings toggle
/// restores the direct listing for single-operator machines.
static final ValueNotifier<bool> sealedNamesVisible =
ValueNotifier(false);
static Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false;
}
static Future<void> setSealedNamesVisible(bool value) async {
sealedNamesVisible.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kSealedNamesKey, value);
}
}

View file

@ -1799,6 +1799,11 @@
"runsPhaseUnknown": "Unbekannt", "runsPhaseUnknown": "Unbekannt",
"navRuns": "Läufe", "navRuns": "Läufe",
"workspaceSealedHeader": "ABGESCHOTTETE BEREICHE", "workspaceSealedHeader": "ABGESCHOTTETE BEREICHE",
"workspaceSealedAggregate": "{n, plural, =1{1 abgeschotteter Bereich} other{{n} abgeschottete Bereiche}}",
"@workspaceSealedAggregate": {"placeholders": {"n": {"type": "int"}}},
"workspaceSealedRevealAction": "Namen einblenden",
"settingsSealedNamesTitle": "Abgeschottete Bereiche direkt mit Namen anzeigen",
"settingsSealedNamesBody": "Bereichsnamen können schutzwürdig sein (Kunden-/Mandantenbezug). Standardmäßig zeigt der Projekt-Umschalter sie nur zusammengefasst; die Namen erscheinen erst nach einem Klick.",
"workspaceSealedRunning": "läuft", "workspaceSealedRunning": "läuft",
"workspaceSealedStopped": "gestoppt", "workspaceSealedStopped": "gestoppt",
"workspaceSealedRunningHint": "Der abgeschottete Bereich läuft als eigener Hub-Prozess. Auswählen wechselt hinein.", "workspaceSealedRunningHint": "Der abgeschottete Bereich läuft als eigener Hub-Prozess. Auswählen wechselt hinein.",

View file

@ -1838,6 +1838,11 @@
"runsPhaseUnknown": "Unknown", "runsPhaseUnknown": "Unknown",
"navRuns": "Runs", "navRuns": "Runs",
"workspaceSealedHeader": "SEALED AREAS", "workspaceSealedHeader": "SEALED AREAS",
"workspaceSealedAggregate": "{n, plural, =1{1 sealed area} other{{n} sealed areas}}",
"@workspaceSealedAggregate": {"placeholders": {"n": {"type": "int"}}},
"workspaceSealedRevealAction": "Show names",
"settingsSealedNamesTitle": "List sealed areas with their names right away",
"settingsSealedNamesBody": "Area names can be sensitive (client/mandate identity). By default the workspace switcher shows them aggregated; the names appear only after a click.",
"workspaceSealedRunning": "running", "workspaceSealedRunning": "running",
"workspaceSealedStopped": "stopped", "workspaceSealedStopped": "stopped",
"workspaceSealedRunningHint": "The sealed area runs as its own hub process. Selecting it switches in.", "workspaceSealedRunningHint": "The sealed area runs as its own hub process. Selecting it switches in.",

View file

@ -5545,6 +5545,30 @@ abstract class AppLocalizations {
/// **'SEALED AREAS'** /// **'SEALED AREAS'**
String get workspaceSealedHeader; String get workspaceSealedHeader;
/// No description provided for @workspaceSealedAggregate.
///
/// In en, this message translates to:
/// **'{n, plural, =1{1 sealed area} other{{n} sealed areas}}'**
String workspaceSealedAggregate(int n);
/// No description provided for @workspaceSealedRevealAction.
///
/// In en, this message translates to:
/// **'Show names'**
String get workspaceSealedRevealAction;
/// No description provided for @settingsSealedNamesTitle.
///
/// In en, this message translates to:
/// **'List sealed areas with their names right away'**
String get settingsSealedNamesTitle;
/// No description provided for @settingsSealedNamesBody.
///
/// In en, this message translates to:
/// **'Area names can be sensitive (client/mandate identity). By default the workspace switcher shows them aggregated; the names appear only after a click.'**
String get settingsSealedNamesBody;
/// No description provided for @workspaceSealedRunning. /// No description provided for @workspaceSealedRunning.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -3280,6 +3280,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get workspaceSealedHeader => 'ABGESCHOTTETE BEREICHE'; String get workspaceSealedHeader => 'ABGESCHOTTETE BEREICHE';
@override
String workspaceSealedAggregate(int n) {
String _temp0 = intl.Intl.pluralLogic(
n,
locale: localeName,
other: '$n abgeschottete Bereiche',
one: '1 abgeschotteter Bereich',
);
return '$_temp0';
}
@override
String get workspaceSealedRevealAction => 'Namen einblenden';
@override
String get settingsSealedNamesTitle =>
'Abgeschottete Bereiche direkt mit Namen anzeigen';
@override
String get settingsSealedNamesBody =>
'Bereichsnamen können schutzwürdig sein (Kunden-/Mandantenbezug). Standardmäßig zeigt der Projekt-Umschalter sie nur zusammengefasst; die Namen erscheinen erst nach einem Klick.';
@override @override
String get workspaceSealedRunning => 'läuft'; String get workspaceSealedRunning => 'läuft';

View file

@ -3274,6 +3274,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get workspaceSealedHeader => 'SEALED AREAS'; String get workspaceSealedHeader => 'SEALED AREAS';
@override
String workspaceSealedAggregate(int n) {
String _temp0 = intl.Intl.pluralLogic(
n,
locale: localeName,
other: '$n sealed areas',
one: '1 sealed area',
);
return '$_temp0';
}
@override
String get workspaceSealedRevealAction => 'Show names';
@override
String get settingsSealedNamesTitle =>
'List sealed areas with their names right away';
@override
String get settingsSealedNamesBody =>
'Area names can be sensitive (client/mandate identity). By default the workspace switcher shows them aggregated; the names appear only after a click.';
@override @override
String get workspaceSealedRunning => 'running'; String get workspaceSealedRunning => 'running';

View file

@ -15,6 +15,7 @@ import 'data/chain_log.dart';
import 'data/error_presentation.dart'; import 'data/error_presentation.dart';
import 'data/hub.dart'; import 'data/hub.dart';
import 'data/sidebar_prefs.dart'; import 'data/sidebar_prefs.dart';
import 'data/workspace_prefs.dart';
import 'data/workspace.dart'; import 'data/workspace.dart';
import 'data/system_actions.dart'; import 'data/system_actions.dart';
import 'data/theme_plugin.dart'; import 'data/theme_plugin.dart';
@ -78,6 +79,7 @@ Future<void> main() async {
final themePlugin = await _restoreOr(null, loadActiveThemePlugin); final themePlugin = await _restoreOr(null, loadActiveThemePlugin);
await _restoreOr(null, () async { await _restoreOr(null, () async {
await SidebarPrefs.load(); await SidebarPrefs.load();
await WorkspacePrefs.load();
return null; return null;
}); });
runApp( runApp(

View file

@ -12,6 +12,7 @@ import '../data/hub.dart';
import '../data/hub_auth_token.dart'; import '../data/hub_auth_token.dart';
import '../data/registry_token.dart'; import '../data/registry_token.dart';
import '../data/sidebar_prefs.dart'; import '../data/sidebar_prefs.dart';
import '../data/workspace_prefs.dart';
import '../data/system_actions.dart'; import '../data/system_actions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../pages/welcome.dart' show showFaiDoc; import '../pages/welcome.dart' show showFaiDoc;
@ -745,6 +746,20 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
theme, theme,
docSlug: 'security', docSlug: 'security',
), ),
// Confidentiality: whether the workspace switcher lists
// sealed areas by name or aggregated (default). Mirrors the
// SidebarPrefs pattern.
ValueListenableBuilder<bool>(
valueListenable: WorkspacePrefs.sealedNamesVisible,
builder: (context, visible, _) => SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l.settingsSealedNamesTitle),
subtitle: Text(l.settingsSealedNamesBody),
value: visible,
onChanged: (v) => WorkspacePrefs.setSealedNamesVisible(v),
),
),
const SizedBox(height: ChainSpace.xl),
_RegistryCredentialsPanel( _RegistryCredentialsPanel(
configuredChars: _registryTokenChars, configuredChars: _registryTokenChars,
onSave: _saveRegistryToken, onSave: _saveRegistryToken,

View file

@ -13,6 +13,7 @@ import 'package:flutter/material.dart';
import '../data/hub.dart' show ProjectRef; import '../data/hub.dart' show ProjectRef;
import '../data/sealed_areas.dart'; import '../data/sealed_areas.dart';
import '../data/workspace_prefs.dart';
import '../data/workspace.dart'; import '../data/workspace.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
@ -124,45 +125,23 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
), ),
), ),
); );
for (final a in ws.sealedAreas) { // Area names often carry client/mandate identity, so the
items.add( // section starts aggregated ("2 sealed areas") and reveals
PopupMenuItem( // names only on a deliberate tap unless the operator turned
value: '$_pSealed${a.slug}', // the direct listing back on in Settings -> Security
child: Row( // (usertest security finding). The disabled PopupMenuItem is
children: [ // just the host; the section handles its own taps and pops
_ProjectDot(color: a.color), // the menu route with the regular `s:<slug>` value.
const SizedBox(width: ChainSpace.sm), items.add(
Icon( PopupMenuItem<String>(
Icons.lock_outline, enabled: false,
size: 13, padding: EdgeInsets.zero,
color: theme.colorScheme.onSurfaceVariant, child: SealedAreaSection(
), areas: ws.sealedAreas,
const SizedBox(width: 4), namesVisible: WorkspacePrefs.sealedNamesVisible.value,
Flexible(child: Text(a.name, overflow: TextOverflow.ellipsis)),
const SizedBox(width: ChainSpace.sm),
Tooltip(
message: a.running
? l.workspaceSealedRunningHint
: l.workspaceSealedStoppedHint,
child: Text(
a.running
? l.workspaceSealedRunning
: l.workspaceSealedStopped,
style: theme.textTheme.labelSmall?.copyWith(
// Secondary but readable onSurfaceVariant
// fell below comfortable contrast at this
// size (usertest finding).
color: a.running
? ChainColors.success
: theme.colorScheme.onSurface.withValues(alpha: 0.8),
),
),
),
],
),
), ),
); ),
} );
} }
return items; return items;
} }
@ -321,3 +300,151 @@ Color? parseAreaColor(String hex) {
if (v == null) return null; if (v == null) return null;
return Color(0xFF000000 | v); return Color(0xFF000000 | v);
} }
/// The sealed-areas block of the switcher menu. Public so the
/// widget test can pump both privacy modes directly.
///
/// [namesVisible] = the operator's Settings choice. When false the
/// block renders one aggregated row (lock + count); a deliberate
/// tap expands the named rows for THIS menu opening only nothing
/// is persisted from the reveal. Rows select via
/// `Navigator.pop(context, 's:<slug>')`, which hands the value to
/// the enclosing PopupMenuButton exactly like a regular item.
class SealedAreaSection extends StatefulWidget {
final List<SealedArea> areas;
final bool namesVisible;
const SealedAreaSection({
super.key,
required this.areas,
required this.namesVisible,
});
@override
State<SealedAreaSection> createState() => _SealedAreaSectionState();
}
class _SealedAreaSectionState extends State<SealedAreaSection> {
bool _revealed = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
if (!widget.namesVisible && !_revealed) {
return InkWell(
onTap: () => setState(() => _revealed = true),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
// Two lines instead of one row: popup menus cap their
// width, and action texts must never be cut off
// (usertest finding class). Count on top, the reveal
// action fully readable beneath it.
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(
l.workspaceSealedAggregate(widget.areas.length),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 2),
Padding(
padding: const EdgeInsets.only(left: 21),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l.workspaceSealedRevealAction,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
Icon(
Icons.expand_more,
size: 14,
color: theme.colorScheme.primary,
),
],
),
),
],
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final a in widget.areas)
InkWell(
onTap: () => Navigator.pop(context, '$_pSealed${a.slug}'),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row(
children: [
_ProjectDot(color: a.color),
const SizedBox(width: ChainSpace.sm),
Icon(
Icons.lock_outline,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Flexible(
child: Text(
a.name,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
),
const SizedBox(width: ChainSpace.sm),
Tooltip(
message: a.running
? l.workspaceSealedRunningHint
: l.workspaceSealedStoppedHint,
child: Text(
a.running
? l.workspaceSealedRunning
: l.workspaceSealedStopped,
style: theme.textTheme.labelSmall?.copyWith(
// Secondary but readable onSurfaceVariant
// fell below comfortable contrast at this
// size (usertest finding).
color: a.running
? ChainColors.success
: theme.colorScheme.onSurface
.withValues(alpha: 0.8),
),
),
),
],
),
),
),
],
);
}
}

View file

@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/data/sealed_areas.dart'; import 'package:chain_studio/data/sealed_areas.dart';
import 'package:chain_studio/data/workspace.dart'; import 'package:chain_studio/data/workspace.dart';
import 'package:chain_studio/data/workspace_prefs.dart';
import 'package:chain_studio/l10n/app_localizations.dart'; import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/widgets/chain_workspace_switcher.dart'; import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
@ -41,6 +42,48 @@ void main() {
Workspace.instance.debugSeed(projects: [_general, _clientA], active: ''); Workspace.instance.debugSeed(projects: [_general, _clientA], active: '');
}); });
tearDown(() {
WorkspacePrefs.sealedNamesVisible.value = false;
});
testWidgets('sealed areas stay aggregated until deliberately revealed', (
tester,
) async {
Workspace.instance.debugSeed(
projects: [_general],
active: '',
sealed: [_sealedGrid, _sealedLab],
);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher));
await tester.pumpAndSettle();
// No names on a casual glance only the aggregate row.
expect(find.text('grid'), findsNothing);
expect(find.text('lab'), findsNothing);
expect(find.text('2 sealed areas'), findsOneWidget);
await tester.tap(find.text('Show names'));
await tester.pumpAndSettle();
expect(find.text('grid'), findsOneWidget);
expect(find.text('lab'), findsOneWidget);
});
testWidgets('the Settings toggle restores the direct listing', (
tester,
) async {
WorkspacePrefs.sealedNamesVisible.value = true;
Workspace.instance.debugSeed(
projects: [_general],
active: '',
sealed: [_sealedGrid, _sealedLab],
);
await tester.pumpWidget(_host());
await tester.tap(find.byType(ChainWorkspaceSwitcher));
await tester.pumpAndSettle();
expect(find.text('grid'), findsOneWidget);
expect(find.text('lab'), findsOneWidget);
expect(find.text('2 sealed areas'), findsNothing);
});
testWidgets('shows "All projects" when no project is active', ( testWidgets('shows "All projects" when no project is active', (
tester, tester,
) async { ) async {
@ -113,12 +156,15 @@ void main() {
await tester.tap(find.byType(ChainWorkspaceSwitcher)); await tester.tap(find.byType(ChainWorkspaceSwitcher));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Sealed areas appear under the sealed header with a lock icon // Sealed areas appear under the sealed header, aggregated by
// and a running/stopped status word. // default (confidentiality); after the reveal every area shows
// its lock icon and running/stopped status word.
expect(find.text('SEALED AREAS'), findsOneWidget); expect(find.text('SEALED AREAS'), findsOneWidget);
expect(find.byIcon(Icons.lock_outline), findsWidgets);
await tester.tap(find.text('Show names'));
await tester.pumpAndSettle();
expect(find.text('Grid'), findsOneWidget); expect(find.text('Grid'), findsOneWidget);
expect(find.text('Bank'), findsOneWidget); expect(find.text('Bank'), findsOneWidget);
expect(find.byIcon(Icons.lock_outline), findsWidgets);
expect(find.text('running'), findsOneWidget); expect(find.text('running'), findsOneWidget);
expect(find.text('stopped'), findsOneWidget); expect(find.text('stopped'), findsOneWidget);
}); });
@ -145,3 +191,22 @@ void main() {
expect(find.byIcon(Icons.lock_outline), findsWidgets); expect(find.byIcon(Icons.lock_outline), findsWidgets);
}); });
} }
// Sealed-area confidentiality (usertest security finding): the
// switcher must not disclose sealed-area names often client
// identity on a casual glance. Aggregated row by default,
// deliberate reveal, Settings toggle for the direct listing.
const _sealedGrid = SealedArea(
slug: 'grid',
name: 'grid',
color: '#e0a458',
port: 51100,
running: false,
);
const _sealedLab = SealedArea(
slug: 'lab',
name: 'lab',
color: '#c25e5e',
port: 51101,
running: true,
);