diff --git a/CHANGELOG.md b/CHANGELOG.md index 804e815..3f3c785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ lockstep. ## 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) - **One segment control everywhere.** The same single-select diff --git a/lib/data/workspace_prefs.dart b/lib/data/workspace_prefs.dart new file mode 100644 index 0000000..2ed030a --- /dev/null +++ b/lib/data/workspace_prefs.dart @@ -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 sealedNamesVisible = + ValueNotifier(false); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false; + } + + static Future setSealedNamesVisible(bool value) async { + sealedNamesVisible.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kSealedNamesKey, value); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b682023..c35637c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1799,6 +1799,11 @@ "runsPhaseUnknown": "Unbekannt", "navRuns": "Läufe", "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", "workspaceSealedStopped": "gestoppt", "workspaceSealedRunningHint": "Der abgeschottete Bereich läuft als eigener Hub-Prozess. Auswählen wechselt hinein.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fddce37..8a887a4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1838,6 +1838,11 @@ "runsPhaseUnknown": "Unknown", "navRuns": "Runs", "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", "workspaceSealedStopped": "stopped", "workspaceSealedRunningHint": "The sealed area runs as its own hub process. Selecting it switches in.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ed87ba2..65bd2a7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5545,6 +5545,30 @@ abstract class AppLocalizations { /// **'SEALED AREAS'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 256e206..a2d5768 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3280,6 +3280,28 @@ class AppLocalizationsDe extends AppLocalizations { @override 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 String get workspaceSealedRunning => 'läuft'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f9ad6e4..6b9c2b5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3274,6 +3274,28 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get workspaceSealedRunning => 'running'; diff --git a/lib/main.dart b/lib/main.dart index fb2f5a8..8586937 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'data/chain_log.dart'; import 'data/error_presentation.dart'; import 'data/hub.dart'; import 'data/sidebar_prefs.dart'; +import 'data/workspace_prefs.dart'; import 'data/workspace.dart'; import 'data/system_actions.dart'; import 'data/theme_plugin.dart'; @@ -78,6 +79,7 @@ Future main() async { final themePlugin = await _restoreOr(null, loadActiveThemePlugin); await _restoreOr(null, () async { await SidebarPrefs.load(); + await WorkspacePrefs.load(); return null; }); runApp( diff --git a/lib/widgets/chain_settings_dialog.dart b/lib/widgets/chain_settings_dialog.dart index 5ee7fd2..56de1a3 100644 --- a/lib/widgets/chain_settings_dialog.dart +++ b/lib/widgets/chain_settings_dialog.dart @@ -12,6 +12,7 @@ import '../data/hub.dart'; import '../data/hub_auth_token.dart'; import '../data/registry_token.dart'; import '../data/sidebar_prefs.dart'; +import '../data/workspace_prefs.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../pages/welcome.dart' show showFaiDoc; @@ -745,6 +746,20 @@ class _FaiSettingsDialogState extends State { theme, docSlug: 'security', ), + // Confidentiality: whether the workspace switcher lists + // sealed areas by name or aggregated (default). Mirrors the + // SidebarPrefs pattern. + ValueListenableBuilder( + 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( configuredChars: _registryTokenChars, onSave: _saveRegistryToken, diff --git a/lib/widgets/chain_workspace_switcher.dart b/lib/widgets/chain_workspace_switcher.dart index 48df296..8c86bfc 100644 --- a/lib/widgets/chain_workspace_switcher.dart +++ b/lib/widgets/chain_workspace_switcher.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart'; import '../data/hub.dart' show ProjectRef; import '../data/sealed_areas.dart'; +import '../data/workspace_prefs.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../theme/tokens.dart'; @@ -124,45 +125,23 @@ class ChainWorkspaceSwitcher extends StatelessWidget { ), ), ); - for (final a in ws.sealedAreas) { - items.add( - PopupMenuItem( - value: '$_pSealed${a.slug}', - 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)), - 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), - ), - ), - ), - ], - ), + // Area names often carry client/mandate identity, so the + // section starts aggregated ("2 sealed areas") and reveals + // names only on a deliberate tap — unless the operator turned + // the direct listing back on in Settings -> Security + // (usertest security finding). The disabled PopupMenuItem is + // just the host; the section handles its own taps and pops + // the menu route with the regular `s:` value. + items.add( + PopupMenuItem( + enabled: false, + padding: EdgeInsets.zero, + child: SealedAreaSection( + areas: ws.sealedAreas, + namesVisible: WorkspacePrefs.sealedNamesVisible.value, ), - ); - } + ), + ); } return items; } @@ -321,3 +300,151 @@ Color? parseAreaColor(String hex) { if (v == null) return null; 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:')`, which hands the value to +/// the enclosing PopupMenuButton exactly like a regular item. +class SealedAreaSection extends StatefulWidget { + final List areas; + final bool namesVisible; + + const SealedAreaSection({ + super.key, + required this.areas, + required this.namesVisible, + }); + + @override + State createState() => _SealedAreaSectionState(); +} + +class _SealedAreaSectionState extends State { + 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), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/test/workspace_switcher_test.dart b/test/workspace_switcher_test.dart index dcccf62..f5f4c50 100644 --- a/test/workspace_switcher_test.dart +++ b/test/workspace_switcher_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/data/sealed_areas.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/widgets/chain_workspace_switcher.dart'; @@ -41,6 +42,48 @@ void main() { 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', ( tester, ) async { @@ -113,12 +156,15 @@ void main() { await tester.tap(find.byType(ChainWorkspaceSwitcher)); await tester.pumpAndSettle(); - // Sealed areas appear under the sealed header with a lock icon - // and a running/stopped status word. + // Sealed areas appear under the sealed header, aggregated by + // default (confidentiality); after the reveal every area shows + // its lock icon and running/stopped status word. 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('Bank'), findsOneWidget); - expect(find.byIcon(Icons.lock_outline), findsWidgets); expect(find.text('running'), findsOneWidget); expect(find.text('stopped'), findsOneWidget); }); @@ -145,3 +191,22 @@ void main() { 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, +);