feat(workspace): sealed-area names are confidential by default
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
ed680c507a
commit
588f437395
11 changed files with 370 additions and 41 deletions
|
|
@ -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:<slug>` value.
|
||||
items.add(
|
||||
PopupMenuItem<String>(
|
||||
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:<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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue