diff --git a/CHANGELOG.md b/CHANGELOG.md index 0148005..9cd4326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep. ## Unreleased +### Fixed (usertest 2026-07-10 findings) + +- **Approval prompts localize.** An approval whose flow step gave no + `prompt:` (and legacy rows carrying the hub's old baked-in English + sentence) now renders the localized fallback "Freigabe für diesen + Schritt erforderlich" / "Approval required for this step" — no more + English inside the German approvals UI. +- **Approving without review data asks first.** When the approval step + attached no `show:` payload, "Freigeben" opens a calm confirmation + ("Ohne Prüfdaten freigeben?") explaining that the flow deliberately + attached no data, with an explicit "Trotzdem freigeben". +- **Studio error log moved to `~/.chain/logs/`.** Writes went to the + pre-rename `~/.fai/logs/studio-errors.log` while the Doctor page and + `chain doctor` read `~/.chain/…`. Studio now writes to `.chain` and + migrates the old file (+ rotation sibling) over once. +- **Honest audit wording (legal review).** DE strings no longer claim + "manipulationssicher" — the audit log is *manipulationserkennend* + (tamper-evident); "warum WORM-1 für KRITIS reicht" became a neutral + what-it-does-and-does-not sentence (EN too); the Doctor chain pill + says "Integritätskette v1" instead of "WORM-1"; the federation + enrollment hint says the CA *authenticates* the first connect. +- **WCAG-AA secondary text on dark.** De-emphasised text was 3.7:1 on + cards; the muted token is now ≥ 4.5:1 against canvas, cards and + elevated surfaces. + ### Added - **Live audit feed.** The Audit page subscribes to `streamEvents` and diff --git a/lib/data/chain_log.dart b/lib/data/chain_log.dart index 440f23a..d3411bf 100644 --- a/lib/data/chain_log.dart +++ b/lib/data/chain_log.dart @@ -51,7 +51,31 @@ class ChainLog { Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; - return p.join(home, '.fai', 'logs', 'studio-errors.log'); + final path = p.join(home, '.chain', 'logs', 'studio-errors.log'); + _migrateLegacyLog(home, path); + return path; + } + + // Pre-rename installs wrote to `~/.fai/logs/`. Move that file (and + // its rotation sibling) over once so the error trail survives the + // rename; never overwrite an existing new-path file. Best-effort + // and cheap enough to run per access (two stat calls after the + // first migration). + static void _migrateLegacyLog(String home, String newPath) { + try { + for (final suffix in const ['', '.1']) { + final legacy = File( + p.join(home, '.fai', 'logs', 'studio-errors.log$suffix'), + ); + final target = File('$newPath$suffix'); + if (legacy.existsSync() && !target.existsSync()) { + target.parent.createSync(recursive: true); + legacy.renameSync(target.path); + } + } + } catch (_) { + // Best-effort: a failed migration must not break logging. + } } /// Absolute path of the studio-errors log. Public so the diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6d4375a..d62b38f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -30,7 +30,7 @@ "welcomeTrustHeader": "TRUST-POSTURE", "welcomeTrustSandboxTitle": "Sandbox von Anfang an", "welcomeTrustSandboxBody": "Jedes Modul bringt eine explizite Berechtigungsliste mit — Netzwerk-Endpunkte, Dateien, Umgebungsvariablen. Der Hub setzt sie durch; ohne Operator-Freigabe verlässt nichts die Sandbox.", - "welcomeTrustAuditTitle": "Manipulationssicheres Audit-Log", + "welcomeTrustAuditTitle": "Manipulationserkennendes Audit-Log", "welcomeTrustAuditBody": "Flow-Läufe, Installationen, Deinstallationen, Freigabe-Entscheidungen — alles wird in ein hash-verkettetes Audit-Log geschrieben. Die Diagnose-Seite verifiziert die Kette bei jedem Laden komplett.", "welcomeTrustAirgapTitle": "Air-Gap-tauglich", "welcomeTrustAirgapBody": "Der gesamte Hub steckt in einem einzigen Binary für Linux, macOS und Windows. Sobald ein Modul installiert ist, läuft der Flow, der es nutzt, ohne weiteren Netzwerkzugriff — ideal für regulierte Umgebungen.", @@ -40,8 +40,8 @@ "welcomeDocArchitectureBlurb": "Hub, Modul, Flow — und wie die drei zusammenpassen.", "welcomeDocSecurityTitle": "Sandbox-Modell", "welcomeDocSecurityBlurb": "Was ein Modul deklariert, was der Operator deckelt, was der Hub durchsetzt.", - "welcomeDocAuditTitle": "Manipulationssicheres Audit-Log", - "welcomeDocAuditBlurb": "Wie die Hash-Kette funktioniert und warum WORM-1 für KRITIS reicht.", + "welcomeDocAuditTitle": "Manipulationserkennendes Audit-Log", + "welcomeDocAuditBlurb": "Wie die Hash-Kette nachträgliche Änderungen erkennbar macht — und was die Integritätsstufe WORM-1 leistet (und was nicht).", "welcomeDocFlowsTitle": "Flow-Komposition", "welcomeDocFlowsBlurb": "YAML-Grundlagen, Templating-Referenz, das Extract→Summarize-Beispiel.", "welcomeDocApprovalsTitle": "Freigaben", @@ -1046,6 +1046,9 @@ "approvalsPillExpired": "abgelaufen", "approvalsPayloadPreview": "ZU PRÜFENDE DATEN", "approvalsNoPayload": "Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setze es, um die Daten hinter dieser Entscheidung sichtbar zu machen.", + "approvalsNoDataConfirmTitle": "Ohne Prüfdaten freigeben?", + "approvalsNoDataConfirmBody": "Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Du kannst trotzdem freigeben — entscheidest dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.", + "approvalsNoDataConfirmAction": "Trotzdem freigeben", "approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich", "approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}", "approvalsApproveButton": "Freigeben", @@ -1193,7 +1196,7 @@ } } }, - "doctorChainPillOk": "WORM-1", + "doctorChainPillOk": "Integritätskette v1", "doctorChainPillTamper": "MANIPULIERT", "doctorVerifyNow": "Jetzt prüfen", "doctorRestart": "Neustart", @@ -1661,5 +1664,5 @@ "federationTokenLabel": "Bootstrap-Token (einmalig)", "federationConfigLabel": "Satelliten-Konfiguration (in den Satelliten einfügen)", "federationCopied": "In die Zwischenablage kopiert", - "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher." + "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dc8b8a7..e781046 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -49,7 +49,7 @@ "welcomeDocSecurityTitle": "Sandbox model", "welcomeDocSecurityBlurb": "What a module declares, what the operator caps, what the hub enforces.", "welcomeDocAuditTitle": "Tamper-evident audit log", - "welcomeDocAuditBlurb": "How the hash chain works and why WORM-1 is enough for KRITIS.", + "welcomeDocAuditBlurb": "How the hash chain makes later edits detectable — and what integrity level WORM-1 does (and does not) provide.", "welcomeDocFlowsTitle": "Flow composition", "welcomeDocFlowsBlurb": "YAML basics, templating reference, the extract→summarize example.", "welcomeDocApprovalsTitle": "Approvals", @@ -1064,6 +1064,9 @@ "approvalsPillExpired": "expired", "approvalsPayloadPreview": "DATA TO REVIEW", "approvalsNoPayload": "No data was attached for review. The flow's approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.", + "approvalsNoDataConfirmTitle": "Approve without review data?", + "approvalsNoDataConfirmBody": "This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.", + "approvalsNoDataConfirmAction": "Approve anyway", "approvalsRequestFallback": "Approval required for this step", "approvalsFlowStepMeta": "Flow: {flow} · Step: {step}", "@approvalsFlowStepMeta": { @@ -1217,7 +1220,7 @@ } } }, - "doctorChainPillOk": "WORM-1", + "doctorChainPillOk": "Integrity chain v1", "doctorChainPillTamper": "TAMPER", "doctorVerifyNow": "Verify now", "doctorRestart": "Restart", @@ -1700,5 +1703,5 @@ "federationTokenLabel": "Bootstrap token (single use)", "federationConfigLabel": "Satellite config (paste into the satellite)", "federationCopied": "Copied to clipboard", - "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite's first connect tamper-proof." + "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index aba6858..74e3d5c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -347,7 +347,7 @@ abstract class AppLocalizations { /// No description provided for @welcomeDocAuditBlurb. /// /// In en, this message translates to: - /// **'How the hash chain works and why WORM-1 is enough for KRITIS.'** + /// **'How the hash chain makes later edits detectable — and what integrity level WORM-1 does (and does not) provide.'** String get welcomeDocAuditBlurb; /// No description provided for @welcomeDocFlowsTitle. @@ -3254,6 +3254,24 @@ abstract class AppLocalizations { /// **'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'** String get approvalsNoPayload; + /// No description provided for @approvalsNoDataConfirmTitle. + /// + /// In en, this message translates to: + /// **'Approve without review data?'** + String get approvalsNoDataConfirmTitle; + + /// No description provided for @approvalsNoDataConfirmBody. + /// + /// In en, this message translates to: + /// **'This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.'** + String get approvalsNoDataConfirmBody; + + /// No description provided for @approvalsNoDataConfirmAction. + /// + /// In en, this message translates to: + /// **'Approve anyway'** + String get approvalsNoDataConfirmAction; + /// No description provided for @approvalsRequestFallback. /// /// In en, this message translates to: @@ -3527,7 +3545,7 @@ abstract class AppLocalizations { /// No description provided for @doctorChainPillOk. /// /// In en, this message translates to: - /// **'WORM-1'** + /// **'Integrity chain v1'** String get doctorChainPillOk; /// No description provided for @doctorChainPillTamper. @@ -4870,7 +4888,7 @@ abstract class AppLocalizations { /// No description provided for @federationEnrollmentHint. /// /// In en, this message translates to: - /// **'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'** + /// **'Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite\'s first connect.'** String get federationEnrollmentHint; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4d07d61..d7ddf32 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -110,7 +110,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Jedes Modul bringt eine explizite Berechtigungsliste mit — Netzwerk-Endpunkte, Dateien, Umgebungsvariablen. Der Hub setzt sie durch; ohne Operator-Freigabe verlässt nichts die Sandbox.'; @override - String get welcomeTrustAuditTitle => 'Manipulationssicheres Audit-Log'; + String get welcomeTrustAuditTitle => 'Manipulationserkennendes Audit-Log'; @override String get welcomeTrustAuditBody => @@ -145,11 +145,11 @@ class AppLocalizationsDe extends AppLocalizations { 'Was ein Modul deklariert, was der Operator deckelt, was der Hub durchsetzt.'; @override - String get welcomeDocAuditTitle => 'Manipulationssicheres Audit-Log'; + String get welcomeDocAuditTitle => 'Manipulationserkennendes Audit-Log'; @override String get welcomeDocAuditBlurb => - 'Wie die Hash-Kette funktioniert und warum WORM-1 für KRITIS reicht.'; + 'Wie die Hash-Kette nachträgliche Änderungen erkennbar macht — und was die Integritätsstufe WORM-1 leistet (und was nicht).'; @override String get welcomeDocFlowsTitle => 'Flow-Komposition'; @@ -1871,6 +1871,16 @@ class AppLocalizationsDe extends AppLocalizations { String get approvalsNoPayload => 'Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setze es, um die Daten hinter dieser Entscheidung sichtbar zu machen.'; + @override + String get approvalsNoDataConfirmTitle => 'Ohne Prüfdaten freigeben?'; + + @override + String get approvalsNoDataConfirmBody => + 'Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Du kannst trotzdem freigeben — entscheidest dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.'; + + @override + String get approvalsNoDataConfirmAction => 'Trotzdem freigeben'; + @override String get approvalsRequestFallback => 'Freigabe für diesen Schritt erforderlich'; @@ -2043,7 +2053,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get doctorChainPillOk => 'WORM-1'; + String get doctorChainPillOk => 'Integritätskette v1'; @override String get doctorChainPillTamper => 'MANIPULIERT'; @@ -2860,5 +2870,5 @@ class AppLocalizationsDe extends AppLocalizations { @override String get federationEnrollmentHint => - 'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher.'; + 'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a69c45e..60802b4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -150,7 +150,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get welcomeDocAuditBlurb => - 'How the hash chain works and why WORM-1 is enough for KRITIS.'; + 'How the hash chain makes later edits detectable — and what integrity level WORM-1 does (and does not) provide.'; @override String get welcomeDocFlowsTitle => 'Flow composition'; @@ -1882,6 +1882,16 @@ class AppLocalizationsEn extends AppLocalizations { String get approvalsNoPayload => 'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'; + @override + String get approvalsNoDataConfirmTitle => 'Approve without review data?'; + + @override + String get approvalsNoDataConfirmBody => + 'This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.'; + + @override + String get approvalsNoDataConfirmAction => 'Approve anyway'; + @override String get approvalsRequestFallback => 'Approval required for this step'; @@ -2052,7 +2062,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get doctorChainPillOk => 'WORM-1'; + String get doctorChainPillOk => 'Integrity chain v1'; @override String get doctorChainPillTamper => 'TAMPER'; @@ -2863,5 +2873,5 @@ class AppLocalizationsEn extends AppLocalizations { @override String get federationEnrollmentHint => - 'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'; + 'Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite\'s first connect.'; } diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index e13e168..9248df8 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -10,6 +10,24 @@ import '../theme/tokens.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; +/// The fixed English sentence pre-0.21 hubs baked into stored +/// approvals when the flow gave no `prompt:`. Newer hubs store the +/// empty prompt verbatim. +const _legacyHubPromptDefault = + 'Please review and approve this step before continuing.'; + +/// Reviewer-facing prompt with fallbacks: an empty prompt (the flow +/// gave none) renders the localized default, and the legacy English +/// default from old hub rows is mapped onto the same localized +/// default so it stops showing English inside a German UI. +String displayApprovalPrompt(AppLocalizations l, String prompt) { + final trimmed = prompt.trim(); + if (trimmed.isEmpty || trimmed == _legacyHubPromptDefault) { + return l.approvalsRequestFallback; + } + return prompt; +} + class ApprovalsPage extends StatefulWidget { const ApprovalsPage({super.key}); @@ -68,6 +86,29 @@ class _ApprovalsPageState extends State Future _approve(ApprovalRecord a) async { final l = AppLocalizations.of(context)!; + // No `show:` data attached → never approve on a reflex. Calmly + // explain and ask for a conscious confirmation first. + if (a.payloadPreview == null) { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l.approvalsNoDataConfirmTitle), + content: Text(l.approvalsNoDataConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l.buttonCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l.approvalsNoDataConfirmAction), + ), + ], + ), + ); + if (confirmed != true) return; + } + if (!mounted) return; try { await HubService.instance.approve(a.id, _reviewer); _toast(l.approvalsApprovedToast(a.flowName, a.stepId)); @@ -77,6 +118,7 @@ class _ApprovalsPageState extends State } } + Future _reject(ApprovalRecord a) async { final l = AppLocalizations.of(context)!; final reason = await _promptReason(context); @@ -558,9 +600,7 @@ class _ApprovalCard extends StatelessWidget { // step left the prompt empty, so the card is never reduced to // a cryptic flow id. Text( - approval.prompt.trim().isEmpty - ? l.approvalsRequestFallback - : approval.prompt, + displayApprovalPrompt(l, approval.prompt), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -850,7 +890,13 @@ class _HistoryDialog extends StatelessWidget { ), ), const SizedBox(height: 4), - SelectableText(record.prompt, style: theme.textTheme.bodyMedium), + SelectableText( + displayApprovalPrompt( + AppLocalizations.of(context)!, + record.prompt, + ), + style: theme.textTheme.bodyMedium, + ), if (record.payloadPreview != null) ...[ const SizedBox(height: ChainSpace.md), Text( diff --git a/lib/pages/store.dart b/lib/pages/store.dart index f1e0e03..19b7e5c 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -3252,7 +3252,7 @@ const List _kFallbackTodayStories = [ badgeEn: 'AUDIT', badgeDe: 'AUDIT', titleEn: 'Tamper-evident hash chain — built in', - titleDe: 'Manipulationssicher per Hash-Kette — eingebaut', + titleDe: 'Manipulation erkennbar per Hash-Kette — eingebaut', bodyEn: 'Every flow run, every install, every approval lands in ~/.chain/audit/ as a hash-chained event log. Any later edit invalidates the chain. CRA-ready out of the box — no compliance product to buy on top.', bodyDe: diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart index fdd010d..135d714 100644 --- a/lib/theme/tokens.dart +++ b/lib/theme/tokens.dart @@ -27,7 +27,9 @@ class ChainColors { static const surface = Color(0xFF18181B); // cards static const surfaceHigh = Color(0xFF27272A); // elevated static const border = Color(0xFF3F3F46); // 1px outlines - static const muted = Color(0xFF71717A); // de-emphasised text + // De-emphasised text. WCAG AA (≥4.5:1) against canvas, cards AND + // elevated surfaces — 0xFF71717A only reached 3.7:1 on cards. + static const muted = Color(0xFF8E8E97); static const text = Color(0xFFE4E4E7); // body static const textStrong = Color(0xFFFAFAFA); // headings