From 038715e51c7245f1c0cbe921af4634ad53eb9345 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 3 Jun 2026 17:24:22 +0200 Subject: [PATCH 1/6] Merge pull request #617 from joshuakrueger-dfx/joshua/fix-eip7702-rs-padding fix(sell): zero-pad EIP-7702 authorization r/s to 32 bytes --- .../real_unit_sell_payment_info_service.dart | 4 +- ...ell_payment_info_service_confirm_test.dart | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart index e6ec146f..87158095 100644 --- a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart +++ b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart @@ -113,8 +113,8 @@ class RealUnitSellPaymentInfoService extends DFXAuthService { chainId: paymentInfo.eip7702.domain.chainId, address: paymentInfo.eip7702.delegatorAddress, nonce: paymentInfo.eip7702.userNonce, - r: '0x${authorizationSignature.r.toRadixString(16)}', - s: '0x${authorizationSignature.s.toRadixString(16)}', + r: '0x${authorizationSignature.r.toRadixString(16).padLeft(64, '0')}', + s: '0x${authorizationSignature.s.toRadixString(16).padLeft(64, '0')}', yParity: authorizationSignature.yParity, ), ), diff --git a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart index aacbe635..55695662 100644 --- a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart +++ b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart @@ -174,4 +174,59 @@ void main() { ); }); }); + + // The EIP-7702 authorization r/s components must always be serialized as full + // 32-byte (64 hex char) big-endian values. `BigInt.toRadixString(16)` drops + // leading zero bytes, so any signature whose r or s is < 2^248 used to emit + // < 64 hex chars (an invalid 32-byte field). The fix mirrors the BitBox + // reference with `.padLeft(64, '0')`. This sweep signs across many nonces so + // at least one deterministic signature lands on a leading-zero byte, and + // asserts every emitted r/s is exactly 64 hex chars. + group('confirmPayment EIP-7702 r/s 32-byte padding', () { + SellPaymentInfo infoForNonce(int nonce) { + final json = _validEip7702Json()..['userNonce'] = nonce; + return SellPaymentInfo( + id: 42, + eip7702: Eip7702Data.fromJson(json), + amount: 100, + exchangeRate: 1.0, + rate: 1.0, + beneficiary: const BeneficiaryDto(iban: 'CH...'), + estimatedAmount: 100.0, + currency: Currency.chf, + depositAddress: '0xdeposit', + tokenAddress: '0xtoken', + chainId: 1, + ethBalance: 0.1, + requiredGasEth: 0.001, + ); + } + + test('authorization r and s are always 0x + 64 hex chars across nonces', + () async { + for (var nonce = 0; nonce < 96; nonce++) { + Map? body; + final client = MockClient((request) async { + body = jsonDecode(request.body) as Map; + return http.Response('{}', 200); + }); + + await build(client).confirmPayment(infoForNonce(nonce)); + + final authorization = (body!['eip7702'] + as Map)['authorization'] as Map; + final r = authorization['r'] as String; + final s = authorization['s'] as String; + + expect(r.startsWith('0x'), isTrue); + expect(s.startsWith('0x'), isTrue); + expect(r.substring(2).length, 64, + reason: 'r must be a full 32-byte component (nonce=$nonce)'); + expect(s.substring(2).length, 64, + reason: 's must be a full 32-byte component (nonce=$nonce)'); + // Value is unchanged, only left-zero-padded. + expect(BigInt.parse(r.substring(2), radix: 16), isA()); + } + }); + }); } From 1d4ddee8e628c68c3319449aa5c9a380a07ee986 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 3 Jun 2026 17:24:48 +0200 Subject: [PATCH 2/6] fix(kyc): render pending page for dfxApproval instead of a blank screen (#618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `KycViewManager`'s `KycSuccess` step switch had no `KycStep.dfxApproval` case. When `_continueKyc` emits `KycSuccess(currentStep: KycStep.dfxApproval)` (backend `processStatus: InProgress` with a `DfxApproval` current step), it fell through to `(_) => const Scaffold()` — a **blank white screen** with no refresh/back, stranding the user. Fix: - Route `dfxApproval` to the existing `KycPendingPage` (semantically "DFX is reviewing"; it already has a refresh action). - Remove the catch-all `(_)` arm so the inner switch is **exhaustive over `KycStep`** — a future enum value becomes a compile error (forced handling) rather than another silent blank. ## Test Adds the first `KycViewManager` widget test: drives the real `_continueKyc` path to `KycSuccess(dfxApproval)` and asserts `KycPendingPage` renders (not a blank `Scaffold`). ## Verification (local, CI-equivalent, Flutter 3.41.6) `generate_localization` → `generate_release_info` → `build_runner` → `analyze` → `test`: - `flutter analyze` on the changed files: **No issues found**. - Widget test **passes with the fix**; **fails when the routing change is reverted** (renders blank `Scaffold` → `KycPendingPage` not found), confirming it catches the regression. Fixes the K1 item of #613. --- lib/screens/kyc/kyc_page_manager.dart | 6 +- test/screens/kyc/kyc_page_manager_test.dart | 130 ++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 test/screens/kyc/kyc_page_manager_test.dart diff --git a/lib/screens/kyc/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart index 66a2d6f6..93694a3f 100644 --- a/lib/screens/kyc/kyc_page_manager.dart +++ b/lib/screens/kyc/kyc_page_manager.dart @@ -76,7 +76,11 @@ class KycViewManager extends StatelessWidget { KycStep.twoFa => const Kyc2FaPage(), KycStep.ident => KycIdentPage(accessToken: urlOrToken ?? ''), KycStep.financialData => KycFinancialDataPage(url: urlOrToken ?? ''), - (_) => const Scaffold(), + // Exhaustive over KycStep so a new value is a compile error here + // (forced handling) rather than a silent blank Scaffold. dfxApproval + // was the missing case that fell through to the old blank fallback. + KycStep.dfxApproval => + const KycPendingPage(pendingStep: KycStep.dfxApproval), }, KycState() => const Scaffold(), }, diff --git a/test/screens/kyc/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart new file mode 100644 index 00000000..cf2f56b2 --- /dev/null +++ b/test/screens/kyc/kyc_page_manager_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_session_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_step_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/user_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_pending_page.dart'; + +import '../../helper/helper.dart'; + +class _MockDfxKycService extends Mock implements DfxKycService {} + +class _MockRealUnitRegistrationService extends Mock + implements RealUnitRegistrationService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockAWallet extends Mock implements AWallet {} + +UserKycDto _kycHeader({KycLevel level = KycLevel.level0}) => + UserKycDto(hash: 'h', level: level, dataComplete: false); + +UserDto _user({String? mail = 'test@example.com'}) => + UserDto(mail: mail, kyc: _kycHeader()); + +KycLevelDto _kycStatus({ + required KycLevel level, + List steps = const [], + KycProcessStatus processStatus = KycProcessStatus.inProgress, +}) => KycLevelDto(kycLevel: level, kycSteps: steps, processStatus: processStatus); + +KycSessionDto _session({ + required KycLevel level, + required List steps, + KycStepSessionDto? currentStep, + KycProcessStatus processStatus = KycProcessStatus.inProgress, +}) => KycSessionDto( + kycLevel: level, + kycSteps: steps, + currentStep: currentStep, + processStatus: processStatus, +); + +KycStepSessionDto _currentStep( + KycStepName name, { + String url = 'https://example.com/session', + UrlType urlType = UrlType.browser, + KycStepStatus status = KycStepStatus.inProgress, +}) => KycStepSessionDto( + session: KycSessionInfoDto(url: url, type: urlType), + name: name, + status: status, + sequenceNumber: 0, + isCurrent: true, +); + +void main() { + late _MockDfxKycService kycService; + late _MockRealUnitRegistrationService registrationService; + late _MockAppStore appStore; + late _MockAWallet wallet; + + setUp(() { + kycService = _MockDfxKycService(); + registrationService = _MockRealUnitRegistrationService(); + appStore = _MockAppStore(); + wallet = _MockAWallet(); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.walletType).thenReturn(WalletType.software); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => RealUnitRegistrationInfoDto( + state: RealUnitRegistrationState.alreadyRegistered, + ), + ); + }); + + // An in-progress `dfxApproval` step used to land on a blank white Scaffold + // (the `(_) => const Scaffold()` fallback in KycViewManager). It must render + // the existing pending page instead. + testWidgets( + 'KycSuccess(dfxApproval) renders KycPendingPage, not a blank Scaffold', + (tester) async { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus( + level: KycLevel.level50, + processStatus: KycProcessStatus.inProgress, + ), + ); + when(() => kycService.getUser()).thenAnswer((_) async => _user()); + when(() => kycService.continueKyc()).thenAnswer( + (_) async => _session( + level: KycLevel.level50, + steps: const [], + currentStep: _currentStep(KycStepName.dfxApproval), + ), + ); + + final cubit = KycCubit(kycService, registrationService, appStore); + await tester.pumpApp( + BlocProvider.value( + value: cubit, + child: const KycViewManager(), + ), + ); + + cubit.markLegalDisclaimerAccepted(); + await cubit.checkKyc(); + await tester.pumpAndSettle(); + + // Sanity: the cubit really reached the bug-triggering state. + expect(cubit.state, isA()); + expect((cubit.state as KycSuccess).currentStep, KycStep.dfxApproval); + + // Regression assertion: the pending page renders (not a blank screen). + expect(find.byType(KycPendingPage), findsOneWidget); + + await cubit.close(); + }, + ); +} From 837a3dc28437be81d3d65f29d7657e1bb8f0b991 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 3 Jun 2026 17:25:12 +0200 Subject: [PATCH 3/6] fix(wallet): fully purge seed + key on wallet delete (#621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The user-facing **"Delete Wallet"** only cleared `walletAccountInfos` (`WalletStorage.deleteWallet`). The `walletInfos` row — which holds the **AES-GCM-encrypted seed** — and the **mnemonic encryption key** in `flutter_secure_storage` both survived. So after a user deletes their wallet, the full mnemonic remained recoverable on the device (resale / GDPR right-to-erasure risk); only the current-wallet pointer was cleared, masking the residue. Fix (non-breaking — the account-only `deleteWallet` is preserved for the onboarding-regenerate flow, which deliberately relies on the seed row surviving): - `WalletStorage.deleteWalletCompletely(id)` — deletes accounts **and** the `walletInfos` seed row in a transaction (FK-safe). - `SecureStorage.deleteMnemonicKey()` — removes the AES-GCM key. - `WalletRepository.purgeWallet(id)` = `deleteWalletCompletely` + `deleteMnemonicKey`. - `WalletService.deleteCurrentWallet` now calls `purgeWallet`. > Verified the onboarding-regenerate path does **not** call `deleteCurrentWallet`/`deleteWallet` at runtime (it defers the DB write), so it is unaffected. ## Test - `purgeWallet` removes the seed row **and** the mnemonic key. - `deleteWallet` (account-only) still leaves the row + key intact (regenerate contract guard). - The service delete now verifies `purgeWallet` is called and `deleteWallet` is not. ## Verification (local, CI-equivalent, Flutter 3.41.6) - `flutter analyze` on the 6 changed files: **No issues found**. - All tests **pass with the fix**; the service test **fails when `deleteCurrentWallet` is reverted** to `deleteWallet`, confirming the regression is caught. Fixes the S2 item of #612. --- .../repository/wallet_repository.dart | 8 +++++ lib/packages/service/wallet_service.dart | 4 ++- lib/packages/storage/secure_storage.dart | 8 +++++ lib/packages/storage/wallet_storage.dart | 8 +++++ .../repository/wallet_repository_test.dart | 29 +++++++++++++++++++ .../packages/service/wallet_service_test.dart | 6 +++- .../packages/storage/secure_storage_test.dart | 6 ++++ 7 files changed, 67 insertions(+), 2 deletions(-) diff --git a/lib/packages/repository/wallet_repository.dart b/lib/packages/repository/wallet_repository.dart index 13b9d582..e2ffbce7 100644 --- a/lib/packages/repository/wallet_repository.dart +++ b/lib/packages/repository/wallet_repository.dart @@ -44,6 +44,14 @@ class WalletRepository { Future deleteWallet(int id) => _appDatabase.deleteWallet(id); + /// Full purge for the user-facing delete: removes the encrypted-seed row AND + /// the AES-GCM mnemonic key, so no recoverable seed material remains on + /// device. + Future purgeWallet(int id) async { + await _appDatabase.deleteWalletCompletely(id); + await _secureStorage.deleteMnemonicKey(); + } + Future _decryptWalletInfo(WalletInfo info) async { final key = await _secureStorage.getOrCreateMnemonicKey(); final decryptedSeed = SecureStorage.decryptSeed(key, info.seed); diff --git a/lib/packages/service/wallet_service.dart b/lib/packages/service/wallet_service.dart index 02322e93..a0a0fa70 100644 --- a/lib/packages/service/wallet_service.dart +++ b/lib/packages/service/wallet_service.dart @@ -274,7 +274,9 @@ class WalletService { Future deleteCurrentWallet() async { final id = _settingsRepository.currentWalletId!; - await _repository.deleteWallet(id); + // Full purge (seed row + mnemonic key), not an account-only delete — so no + // recoverable seed survives delete. + await _repository.purgeWallet(id); await _settingsRepository.removeCurrentWalletId(); } diff --git a/lib/packages/storage/secure_storage.dart b/lib/packages/storage/secure_storage.dart index 8472bae4..55c78b27 100644 --- a/lib/packages/storage/secure_storage.dart +++ b/lib/packages/storage/secure_storage.dart @@ -165,6 +165,14 @@ class SecureStorage { return key; } + /// Removes the AES-GCM key that decrypts stored seeds. Once gone, any + /// surviving encrypted seed is permanently undecryptable; a fresh key is + /// lazily minted on next creation. + // @no-integration-test: forwards to FlutterSecureStorage (Android Keystore / + // iOS Keychain) over a platform channel; real keystore removal is only + // verifiable on-device — the unit test mocks the plugin. + Future deleteMnemonicKey() => _secureStorage.delete(key: _mnemonicEncryptionKey); + static String encryptSeed(Uint8List key, String plaintext) { final iv = _secureRandomBytes(12); final cipher = GCMBlockCipher(AESEngine()) diff --git a/lib/packages/storage/wallet_storage.dart b/lib/packages/storage/wallet_storage.dart index 5eb8c83a..81317d76 100644 --- a/lib/packages/storage/wallet_storage.dart +++ b/lib/packages/storage/wallet_storage.dart @@ -28,6 +28,14 @@ extension WalletStorage on AppDatabase { Future deleteWallet(int walletId) => (delete(walletAccountInfos)..where((row) => row.wallet.equals(walletId))).go(); + /// Deletes the `walletInfos` row itself (the encrypted-seed record) after + /// clearing its dependent `walletAccountInfos` rows (FK in + /// [WalletAccountInfos.wallet]). + Future deleteWalletCompletely(int walletId) => transaction(() async { + await (delete(walletAccountInfos)..where((row) => row.wallet.equals(walletId))).go(); + await (delete(walletInfos)..where((row) => row.id.equals(walletId))).go(); + }); + Future get hasWallet => select(walletInfos).get().then((result) => result.isNotEmpty); } diff --git a/test/packages/repository/wallet_repository_test.dart b/test/packages/repository/wallet_repository_test.dart index f049bca4..b9ab2ded 100644 --- a/test/packages/repository/wallet_repository_test.dart +++ b/test/packages/repository/wallet_repository_test.dart @@ -145,5 +145,34 @@ void main() { final afterAccounts = await db.getWalletAccounts(walletId); expect(afterAccounts, isEmpty); }); + + test('purgeWallet removes the walletInfos seed row AND the mnemonic key', () async { + // The user-facing delete must leave no recoverable seed material — + // neither the encrypted row nor the AES key. + when(() => secureStorage.deleteMnemonicKey()).thenAnswer((_) async {}); + + final walletId = await repo.createWallet(walletName, WalletType.software, seed, address); + await db.insertWalletAccount(walletId, 'acc-0', 0); + expect(await db.getWalletById(walletId), isNotNull); + + await repo.purgeWallet(walletId); + + expect(await db.getWalletById(walletId), isNull); // encrypted seed row gone + expect(await db.getWalletAccounts(walletId), isEmpty); // accounts gone + verify(() => secureStorage.deleteMnemonicKey()).called(1); // AES key removed + }); + + test('deleteWallet (account-only) leaves the seed row and mnemonic key intact', () async { + // Onboarding-regenerate contract: the account-only primitive must NOT + // wipe the seed row or the AES key. + final walletId = await repo.createWallet(walletName, WalletType.software, seed, address); + await db.insertWalletAccount(walletId, 'acc-0', 0); + + await repo.deleteWallet(walletId); + + expect(await db.getWalletById(walletId), isNotNull); // row survives + expect(await db.getWalletAccounts(walletId), isEmpty); // accounts gone + verifyNever(() => secureStorage.deleteMnemonicKey()); // key untouched + }); }); } diff --git a/test/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart index 91ea64d9..24f19cfa 100644 --- a/test/packages/service/wallet_service_test.dart +++ b/test/packages/service/wallet_service_test.dart @@ -57,6 +57,7 @@ void main() { when(() => settings.saveCurrentWalletId(any())).thenAnswer((_) async => true); when(() => settings.removeCurrentWalletId()).thenAnswer((_) async => true); when(() => repo.deleteWallet(any())).thenAnswer((_) async {}); + when(() => repo.purgeWallet(any())).thenAnswer((_) async {}); when(() => repo.updateAddress(any(), any())).thenAnswer((_) async {}); }); @@ -440,7 +441,10 @@ void main() { await service.deleteCurrentWallet(); - verify(() => repo.deleteWallet(8)).called(1); + // User-facing delete must fully purge (seed row + mnemonic key), not + // the account-only delete. + verify(() => repo.purgeWallet(8)).called(1); + verifyNever(() => repo.deleteWallet(any())); verify(() => settings.removeCurrentWalletId()).called(1); }); }); diff --git a/test/packages/storage/secure_storage_test.dart b/test/packages/storage/secure_storage_test.dart index 19508ca6..96df3098 100644 --- a/test/packages/storage/secure_storage_test.dart +++ b/test/packages/storage/secure_storage_test.dart @@ -120,6 +120,12 @@ void main() { verify(() => mockStorage.delete(key: 'pin.salt')).called(1); }); + test('deleteMnemonicKey deletes the mnemonic encryption key', () async { + await secureStorage.deleteMnemonicKey(); + + verify(() => mockStorage.delete(key: 'wallet.mnemonic.encryption.key')).called(1); + }); + test('getPinSalt returns null when no salt is stored', () async { when( () => mockStorage.read(key: 'pin.salt'), From f25287532a30286e141c2afd25da7886c8447fe3 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 3 Jun 2026 17:25:50 +0200 Subject: [PATCH 4/6] fix(legal): show an error with retry when a legal document fails to load (#630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What When a legal document's markdown asset fails to load, `LegalDocumentPage` caught the error and set `_markdownContent = ''`, which `build` treats as the *loaded* state → the user got a **blank page with no message and no way to retry**. This adds an explicit error state with a localized message and a **Retry** action. ## Why Part of #629 (audit finding **#19**). The failure was silent: `catch (_) { _markdownContent = ''; }` is indistinguishable from a successfully loaded empty document, so a missing/unreadable legal asset (e.g. a packaging slip-up or I/O error) leaves the user staring at an empty screen. ## Changes - `legal_document_page.dart`: track `_loadFailed`; on load failure render a centered error message + `Retry` button (`_retryLoad` re-runs the load) instead of an empty `Markdown`. The load is also now logged via `developer.log`. - New i18n key `legalDocumentLoadFailed` (EN + DE). - `legal_document_page_test.dart` (new): regression test — asset-load failure must surface an error + retry affordance (was red before the fix), plus a control for the loaded body and a retry-path test. `rootBundle.clear()` per test keeps the asset cache from leaking between cases. - `legal_document_golden_test.dart`: the existing default golden rendered `buildSubject()` with no content, which under the new behaviour would race into the error state. Switched it to render an empty document deterministically (`initialMarkdownContent: ''`) — same committed baseline, no async load. ## Test plan - `flutter analyze` → **0 issues** - `flutter test test/screens/legal/legal_document_page_test.dart` → red on `develop`, **green** with the fix - `flutter test test/goldens/screens/legal/legal_document_golden_test.dart` → **green** (baseline unchanged) - full `flutter test --coverage` run locally (only the known `home_golden` macOS pixel drift remains, which is green on CI) No production behaviour changes for the loaded/loading paths — only the failure path gains an error UI. --- assets/languages/strings_de.arb | 2 + assets/languages/strings_en.arb | 2 + .../legal/subpages/legal_document_page.dart | 74 +++++++++- .../legal/legal_document_golden_test.dart | 8 +- .../legal/legal_document_page_test.dart | 129 ++++++++++++++++++ 5 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 test/screens/legal/legal_document_page_test.dart diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 3246a214..57203d78 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -148,6 +148,8 @@ "legalDisclaimerTitle": "Wichtige rechtliche Hinweise für Investoren & Bestätigung des Wohnsitzes", "legalDisclaimerTitle2": "Weitere rechtliche Hinweise", "legalDisclaimerYes": "Zustimmen", + "legalDocumentLoadFailed": "Dokument konnte nicht geladen werden", + "legalDocumentLoadFailedDescription": "Beim Laden des Dokuments ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", "legalDocuments": "Rechtsdokumente", "location": "Ort", "logout": "Abmelden", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 4b92637c..a988c277 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -148,6 +148,8 @@ "legalDisclaimerTitle": "Important legal notices for investors & confirmation of residence", "legalDisclaimerTitle2": "Further legal notices", "legalDisclaimerYes": "Agree", + "legalDocumentLoadFailed": "Could not load the document", + "legalDocumentLoadFailedDescription": "Something went wrong while loading this document. Please try again.", "legalDocuments": "Legal documents", "location": "Location", "logout": "Logout", diff --git a/lib/screens/legal/subpages/legal_document_page.dart b/lib/screens/legal/subpages/legal_document_page.dart index a4f71b40..4994b730 100644 --- a/lib/screens/legal/subpages/legal_document_page.dart +++ b/lib/screens/legal/subpages/legal_document_page.dart @@ -1,3 +1,5 @@ +import 'dart:developer' as developer; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -7,6 +9,7 @@ import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/screens/web_view/web_view_page.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; class LegalDocumentParams { @@ -41,6 +44,7 @@ class LegalDocumentPage extends StatefulWidget { class _LegalDocumentPageState extends State { String? _markdownContent; + bool _loadFailed = false; @override void initState() { @@ -54,16 +58,32 @@ class _LegalDocumentPageState extends State { Future _loadMarkdown() async { final code = context.read().state.language.code; + final assetPath = 'assets/legal/${widget.params.assetBaseName}_$code.md'; try { - final content = await rootBundle.loadString( - 'assets/legal/${widget.params.assetBaseName}_$code.md', - ); + // cache: false so Retry after a transient failure actually re-reads the + // asset instead of replaying rootBundle's cached error for this key. + final content = await rootBundle.loadString(assetPath, cache: false); if (mounted) setState(() => _markdownContent = content); - } catch (_) { - if (mounted) setState(() => _markdownContent = ''); + } catch (e, stackTrace) { + developer.log( + 'Failed to load legal document "${widget.params.assetBaseName}"', + name: 'realunit_wallet.legal', + error: e, + stackTrace: stackTrace, + level: 1000, // SEVERE + ); + if (mounted) setState(() => _loadFailed = true); } } + void _retryLoad() { + setState(() { + _loadFailed = false; + _markdownContent = null; + }); + _loadMarkdown(); + } + String? get _pdfUrl { if (widget.params.pdfUrls == null) return null; final code = context.read().state.language.code; @@ -75,7 +95,9 @@ class _LegalDocumentPageState extends State { appBar: AppBar( title: Text(widget.params.title), ), - body: _markdownContent != null + body: _loadFailed + ? _buildError(context) + : _markdownContent != null ? Column( children: [ Expanded( @@ -118,6 +140,46 @@ class _LegalDocumentPageState extends State { ), ], ) + // Documents load from a bundled asset (effectively synchronous), so the + // brief null frame stays blank rather than flashing a spinner. : const SizedBox.shrink(), ); + + // Mirrors the canonical full-page error view in + // settings_currencies_page.dart (`_ErrorView`); keep the two in sync. + Widget _buildError(BuildContext context) => Center( + key: const ValueKey('legalDocumentLoadError'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: RealUnitColors.neutral500, + ), + const SizedBox(height: 12), + Text( + S.of(context).legalDocumentLoadFailed, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + S.of(context).legalDocumentLoadFailedDescription, + style: const TextStyle(color: RealUnitColors.neutral500), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + OutlinedButton( + key: const ValueKey('legalDocumentRetryButton'), + onPressed: _retryLoad, + child: Text(S.of(context).retry), + ), + ], + ), + ), + ); } diff --git a/test/goldens/screens/legal/legal_document_golden_test.dart b/test/goldens/screens/legal/legal_document_golden_test.dart index b48d8a7b..1d7f5841 100644 --- a/test/goldens/screens/legal/legal_document_golden_test.dart +++ b/test/goldens/screens/legal/legal_document_golden_test.dart @@ -27,11 +27,15 @@ void main() { ); group('$LegalDocumentPage', () { + // Empty-content placeholder: renders the empty Markdown body before any + // document text is present. Passing an empty string via the + // @visibleForTesting hook keeps this deterministic (no async asset load, + // so it never races into the new load-error state). goldenTest( - 'initial state before markdown loads', + 'empty document placeholder', fileName: 'legal_document_page_default', constraints: const BoxConstraints.tightFor(width: 390, height: 844), - builder: () => wrapForGolden(buildSubject()), + builder: () => wrapForGolden(buildSubject(initialMarkdownContent: '')), ); // The page reads its markdown from rootBundle in production; for the diff --git a/test/screens/legal/legal_document_page_test.dart b/test/screens/legal/legal_document_page_test.dart new file mode 100644 index 00000000..0314e7e7 --- /dev/null +++ b/test/screens/legal/legal_document_page_test.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/legal/subpages/legal_document_page.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/styles/language.dart'; + +import '../../helper/helper.dart'; + +void main() { + late MockSettingsBloc settingsBloc; + + setUp(() { + // The page loads its document from rootBundle, which caches results per + // asset path. Clear it between tests so a missing-asset load fails fresh + // each time instead of returning a sibling test's cached future. + rootBundle.clear(); + settingsBloc = MockSettingsBloc(); + when(() => settingsBloc.state).thenReturn(const SettingsState(language: Language.en)); + }); + + Widget host({ + String assetBaseName = 'terms', + String? initialMarkdownContent, + }) => BlocProvider.value( + value: settingsBloc, + child: LegalDocumentPage( + params: LegalDocumentParams( + title: 'Terms', + assetBaseName: assetBaseName, + ), + initialMarkdownContent: initialMarkdownContent, + ), + ); + + group('$LegalDocumentPage', () { + testWidgets( + 'shows an error with a retry action when the document asset fails to load ' + '(regression for #19: silent blank page)', + (tester) async { + // No bundled asset for this base name -> rootBundle.loadString throws. + await tester.pumpApp(host(assetBaseName: 'missing_legal_asset_test')); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('legalDocumentLoadError')), + findsOneWidget, + reason: 'a failed asset load must surface an error state, not a blank page', + ); + expect( + find.byKey(const ValueKey('legalDocumentRetryButton')), + findsOneWidget, + reason: 'the error state must offer a retry affordance', + ); + expect( + find.byIcon(Icons.error_outline), + findsOneWidget, + reason: 'the error state should render the standard error icon', + ); + expect( + find.text(S.current.legalDocumentLoadFailedDescription), + findsOneWidget, + reason: 'the error state should explain what went wrong', + ); + }, + ); + + testWidgets('renders the markdown body when content is provided', (tester) async { + await tester.pumpApp(host(initialMarkdownContent: '# Hello')); + await tester.pumpAndSettle(); + + expect(find.byType(Markdown), findsOneWidget); + expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsNothing); + }); + + testWidgets('retry re-runs the load and stays in the error state while the asset is missing', ( + tester, + ) async { + await tester.pumpApp(host(assetBaseName: 'missing_legal_asset_test')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('legalDocumentRetryButton'))); + await tester.pumpAndSettle(); + + // The asset is still missing, so we expect the error state to persist + // without throwing — exercising the retry path. + expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsOneWidget); + }); + + testWidgets('retry recovers and renders the document once the asset loads', ( + tester, + ) async { + const baseName = 'recoverable_legal_asset_test'; + const assetKey = 'assets/legal/${baseName}_en.md'; + var failNext = true; + + // Mock the asset channel so the first load fails and, after retry, the + // second load succeeds — exercising the full error -> retry -> loaded path. + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMessageHandler('flutter/assets', (ByteData? message) async { + final key = utf8.decode( + message!.buffer.asUint8List(message.offsetInBytes, message.lengthInBytes), + ); + if (key == assetKey && !failNext) { + return ByteData.sublistView(Uint8List.fromList(utf8.encode('# Recovered'))); + } + return null; // missing asset -> loadString throws + }); + addTearDown(() => messenger.setMockMessageHandler('flutter/assets', null)); + + await tester.pumpApp(host(assetBaseName: baseName)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsOneWidget); + + failNext = false; + await tester.tap(find.byKey(const ValueKey('legalDocumentRetryButton'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('legalDocumentLoadError')), findsNothing); + expect(find.byType(Markdown), findsOneWidget); + }); + }); +} From 07a181fb853862ce6d1ebb1093510bddfe4d7957 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:52:13 +0200 Subject: [PATCH 5/6] feat(handbook): downloadable legal documents (PDF + DOCX) as derived export (#659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements [#658](https://github.com/RealUnitCH/app/issues/658). Adds a **"Rechtsdokumente — Downloads"** (nav `L`) section to the handbook that exposes RealUnit's three in-app legal documents as a **derived PDF + DOCX export** of the repo's Markdown — the same upstream/downstream model the store-listing and mail sections already use. `assets/legal/*.md` stays the single source of truth. ### In scope (exactly the 3 repo-local docs) | Document | Source | ARB title key | |---|---|---| | Datenschutzbestimmungen | `assets/legal/privacy_policy_.md` | `legalDisclaimerCheckboxPrivacyPolicy` | | Nutzungsbedingungen | `assets/legal/terms_of_use_.md` | `termsOfUse` | | Registrierungsvereinbarung | `assets/legal/registration_agreement_.md` | `legalDisclaimerCheckboxRegistrationAgreement` | Languages are **discovered by glob** (never hardcoded) → today `de` + `en` = 3 docs × 2 langs = **6 PDF + 6 DOCX**. A future `_fr.md` appears automatically. DFX, Aktionariat and the externally-hosted corporate PDFs are intentionally **out of scope** (no Markdown source in the repo). ### Per (document, language): three access paths Each card carries, consistent with the existing handbook `.test` cards (same `permalink` anchor + `🔗 Link` copy-button pattern): - a **PDF** download and a **DOCX** download, - a **direct link / permalink** to the entry (`id="legal--"`, copy-to-clipboard button), and - a `↗` link to the **Markdown source** on `develop`. > Direct-links per the follow-up request: each document now also has a Direktlink/permalink built exactly like the other handbook sections (`a.name.permalink` + generic `.copy-link[data-target]` JS), so the look stays consistent across the handbook. ### The critical determinism split - **HTML block** (download list) — produced by the pure-stdlib `scripts/assemble-handbook-legal.py`, **deterministic → committed → sync-gated** (CI re-runs the generator and fails on a non-empty `git diff` of `index.html`). - **PDF/DOCX binaries** — produced by `scripts/build-legal-downloads.sh` via pandoc (weasyprint PDF engine), **non-deterministic → git-ignored → built only inside the image → never committed, never sync-gated** (same treatment as the assembled screenshots). ### Changes - `scripts/assemble-handbook-legal.py` — deterministic generator (mirrors `assemble-handbook-store-listing.py`). - `scripts/templates/legal-downloads.html.tmpl` — section template. - `scripts/build-legal-downloads.sh` — pandoc PDF/DOCX builder (image-only). - `Dockerfile.handbook` — new `legal-docs-builder` stage chained on `store-listing-builder` (so both rewritten blocks survive); installs `font-dejavu` so weasyprint can render. - `handbook.nginx.conf` — `/legal/*.{pdf,docx}` served with `Content-Disposition: attachment`, behind the existing Basic-Auth gate. - `.github/workflows/handbook-build-check.yaml` — paths, the legal HTML sync gate, and download-presence smoke checks. - `.gitignore` — `docs/handbook/legal/`. - `docs/handbook/de/index.html` — markers, nav entry, CSS, and the committed (generated) block. ### Local verification - `docker build -f Dockerfile.handbook` is **green**; the `legal-docs-builder` stage produces **12 files**. - PDFs are valid (`%PDF-`); DOCX are valid OOXML (zip with `word/document.xml` + `word/styles.xml`) — i.e. real **editable Word documents**, the artifact requested for legal review. - DOCX content matches the Markdown (German text incl. umlauts intact, e.g. "Geltungsbereich", "RealUnit Schweiz AG"). - Generator is **idempotent** (re-run = byte-identical); the legal sync gate is in sync. - Container serves the downloads (200/401 behind auth) with the attachment disposition. Draft until CI is green. --- .github/workflows/handbook-build-check.yaml | 37 ++++ .gitignore | 8 + Dockerfile.handbook | 31 +++ docs/handbook/de/index.html | 157 ++++++++++++++ handbook.nginx.conf | 16 ++ scripts/assemble-handbook-legal.py | 218 ++++++++++++++++++++ scripts/build-legal-downloads.sh | 59 ++++++ scripts/templates/legal-downloads.html.tmpl | 31 +++ 8 files changed, 557 insertions(+) create mode 100755 scripts/assemble-handbook-legal.py create mode 100755 scripts/build-legal-downloads.sh create mode 100644 scripts/templates/legal-downloads.html.tmpl diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index 7877d723..093997db 100644 --- a/.github/workflows/handbook-build-check.yaml +++ b/.github/workflows/handbook-build-check.yaml @@ -23,12 +23,20 @@ on: - "scripts/assemble-handbook-screenshots.sh" - "scripts/assemble-handbook-store-listing.py" - "scripts/templates/store-listing.html.tmpl" + - "scripts/assemble-handbook-legal.py" + - "scripts/build-legal-downloads.sh" + - "scripts/templates/legal-downloads.html.tmpl" - "test/goldens/**" # Store-listing section is a derived export of the Fastlane metadata — # changing it must re-run the generator and re-commit the handbook. - "ios/fastlane/metadata/**" - "ios/fastlane/screenshots/**" - "android/fastlane/metadata/**" + # Legal-downloads section is a derived export of the in-app legal Markdown + # (and the ARB titles) — changing either must re-run the generator and + # re-commit the handbook HTML block. + - "assets/legal/**" + - "assets/languages/**" - "Dockerfile.handbook" - "handbook.nginx.conf" - "handbook.htpasswd" @@ -99,6 +107,23 @@ jobs: exit 1 fi + - name: Verify handbook legal-downloads section is in sync with assets/legal + # The legal-downloads block in docs/handbook/de/index.html is a derived + # export of assets/legal/*.md + the ARB titles. Re-run the (pure-stdlib, + # deterministic) generator; if the working tree changes, the committed + # handbook is stale — someone edited a legal .md or its ARB title without + # re-running the generator. Only the HTML block is gated here; the + # pandoc-produced PDF/DOCX are non-deterministic and intentionally + # git-ignored, so build-legal-downloads.sh is NOT run in this gate. + run: | + set -euo pipefail + python3 scripts/assemble-handbook-legal.py /tmp/legal-out + if ! git diff --quiet docs/handbook/de/index.html; then + echo "::error::docs/handbook/de/index.html legal-downloads block is stale — re-run scripts/assemble-handbook-legal.py and commit." + git diff docs/handbook/de/index.html + exit 1 + fi + - name: Build handbook image (no push) run: docker build -f Dockerfile.handbook -t realunit-handbook:pr-check . @@ -139,4 +164,16 @@ jobs: fi done + # Legal downloads must be present (built by the legal-docs-builder + # stage via pandoc). Same 200/401-vs-404 logic: 404 means the PDF/DOCX + # was not produced. Covers PDF + DOCX and both languages. + for f in legal/privacy_policy_de.pdf legal/privacy_policy_de.docx legal/terms_of_use_en.pdf legal/registration_agreement_de.docx; do + code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/${f}") + if [ "$code" = "404" ]; then + echo "legal download ${f} missing from /usr/share/nginx/html/legal/" >&2 + docker logs handbook + exit 1 + fi + done + docker stop handbook diff --git a/.gitignore b/.gitignore index a8b8014a..ce510890 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,14 @@ docs/handbook/mails/ # directory is only populated transiently for local previews. docs/handbook/screenshots/ +# Built at handbook build time from assets/legal/*.md via pandoc +# (scripts/build-legal-downloads.sh, see Dockerfile.handbook). The PDF/DOCX are +# NON-deterministic (pandoc embeds timestamps/metadata), so — unlike the +# committed legal-downloads HTML block in de/index.html — they are never +# committed and never sync-gated; they exist only inside the image. This +# directory holds the assembly output for local previews only. +docs/handbook/legal/ + # Scratch directories produced when reproducing the handbook CI's # mail-preview generation locally (see docs/handbook/README.md → "E-Mail # Previews → Lokal regenerieren"). Mirrors the names the CI uses so a diff --git a/Dockerfile.handbook b/Dockerfile.handbook index d2343c78..49abbf12 100644 --- a/Dockerfile.handbook +++ b/Dockerfile.handbook @@ -53,6 +53,32 @@ COPY android/fastlane/metadata/ ./android/fastlane/metadata/ COPY docs/handbook/de/index.html ./docs/handbook/de/index.html RUN python3 ./scripts/assemble-handbook-store-listing.py /out && cp ./docs/handbook/de/index.html /out/index.html +# Legal-downloads section: derived export of the in-app legal Markdown +# (assets/legal/*.md). Two separate concerns, by design: +# - assemble-handbook-legal.py rewrites the deterministic block in index.html (committed + sync-gated upstream). +# - build-legal-downloads.sh renders the NON-deterministic PDF/DOCX via pandoc +# (weasyprint PDF engine — no TeX), git-ignored like the screenshots. +# This stage takes the store-listing-rewritten index.html as input so BOTH the +# store-listing and the legal-downloads blocks survive into the final image. +FROM alpine:3.20 AS legal-docs-builder +WORKDIR /work +# font-dejavu (+ a rebuilt fontconfig cache) is required: weasyprint renders via +# Pango, which aborts ("No fonts configured in FontConfig" → "Error producing +# PDF") on a bare Alpine that ships no font files. DejaVu covers the Latin/German +# glyphs the legal texts use. +RUN apk add --no-cache python3 pandoc weasyprint bash font-dejavu \ + && fc-cache -f +COPY scripts/assemble-handbook-legal.py ./scripts/ +COPY scripts/build-legal-downloads.sh ./scripts/ +COPY scripts/templates/legal-downloads.html.tmpl ./scripts/templates/ +COPY assets/legal/ ./assets/legal/ +COPY assets/languages/ ./assets/languages/ +COPY --from=store-listing-builder /out/index.html ./docs/handbook/de/index.html +RUN python3 ./scripts/assemble-handbook-legal.py /out \ + && bash ./scripts/build-legal-downloads.sh /out \ + && cp ./docs/handbook/de/index.html /out/index.html + FROM nginx:1.27.5-alpine COPY docs/handbook/ /usr/share/nginx/html/ @@ -62,6 +88,11 @@ COPY --from=screenshots-builder /out/ /usr/share/nginx/html/screenshots/ COPY --from=store-listing-builder /out/ios/ /usr/share/nginx/html/store/ios/ COPY --from=store-listing-builder /out/android/ /usr/share/nginx/html/store/android/ COPY --from=store-listing-builder /out/index.html /usr/share/nginx/html/de/index.html +# Legal-downloads PDFs/DOCX + the index.html with BOTH the store-listing and the +# legal-downloads blocks rewritten (this copy overwrites the store-listing one +# above, so the legal stage's index.html is the authoritative final version). +COPY --from=legal-docs-builder /out/legal/ /usr/share/nginx/html/legal/ +COPY --from=legal-docs-builder /out/index.html /usr/share/nginx/html/de/index.html COPY handbook.nginx.conf /etc/nginx/conf.d/default.conf COPY handbook.htpasswd /etc/nginx/handbook.htpasswd diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index f74f68c7..021f2748 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -608,6 +608,40 @@ .store-screenshots .src { font-size: 0.85em; } + /* Legal-downloads section (#spec-legal-downloads) — see + scripts/assemble-handbook-legal.py. Reuses the .test card chrome + (border + :target highlight) so each (document, language) entry has the + same direct-link affordance as the screenshot cards. */ + .test.legal-doc .downloads { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 12px 14px; + } + .test.legal-doc .doc-title { + flex-basis: 100%; + font-size: 13px; + color: var(--ink-2); + } + a.dl-btn { + text-decoration: none; + font-size: 12.5px; + font-weight: 600; + color: var(--brand); + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 6px; + padding: 5px 12px; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; + } + a.dl-btn:hover { + background: var(--surface); + border-color: var(--brand); + } @@ -689,6 +723,9 @@
  • SStore-Listing
  • +
  • + LRechtsdokumente +
  • @@ -2411,6 +2448,126 @@

    10" Tablet (2560×1440)

    + + + + diff --git a/handbook.nginx.conf b/handbook.nginx.conf index 8d208f49..3cfa0332 100644 --- a/handbook.nginx.conf +++ b/handbook.nginx.conf @@ -78,6 +78,22 @@ server { return 302 /de/; } + # Legal downloads (/legal/*.pdf, /legal/*.docx) — the derived PDF/DOCX export + # of assets/legal/*.md. Force a download disposition (nginx's default + # mime.types has no .docx mapping; it would otherwise serve as + # application/octet-stream, which downloads but without an explicit + # filename). add_header is all-or-nothing per location, so the server-level + # security headers are restated here. auth_basic is inherited — these stay + # behind the same Basic-Auth gate as the rest of the handbook. + location ~* \.(pdf|docx)$ { + add_header Content-Disposition "attachment" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Cache-Control "private, no-store" always; + try_files $uri =404; + } + location / { try_files $uri $uri/ =404; } diff --git a/scripts/assemble-handbook-legal.py b/scripts/assemble-handbook-legal.py new file mode 100755 index 00000000..471ad7d3 --- /dev/null +++ b/scripts/assemble-handbook-legal.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Generate the handbook legal-downloads block from the in-app legal Markdown. + +The Markdown under assets/legal/ is the single source of truth for the three +RealUnit documents that the app renders in-app (LegalDocumentPage reads +assets/legal/_.md via rootBundle). The handbook section at +handbook.realunit.app is a *derived export* of those files — never hand-edited. +This mirrors the upstream/downstream relationship that the store-listing and +mails/ sections already have. + +Usage: + scripts/assemble-handbook-legal.py + +Used by: + - Dockerfile.handbook (legal-docs-builder stage → rewrites docs/handbook/de/ + index.html; the PDF/DOCX binaries are produced separately by + scripts/build-legal-downloads.sh — see the determinism note below) + - .github/workflows/handbook-build-check.yaml (sync gate: re-run + git diff) + - local previews (run it, then open docs/handbook/de/index.html) + +What it does: + 1. Discovers the document set: for each base in BASES, globs + assets/legal/_*.md to find the available languages. Errors out if a + base has zero languages. + 2. Resolves each document's title from assets/languages/strings_.arb + (the mapped ARB key per base, see TITLE_KEYS) so the handbook titles stay + in lockstep with the in-app titles. Falls back to the `de` title if a + language's ARB lacks the key. + 3. Renders scripts/templates/legal-downloads.html.tmpl into + /legal-downloads.html and substitutes the rendered block between the + / markers in + docs/handbook/de/index.html in place (idempotent). + +What it deliberately does NOT do: + - It does NOT invoke pandoc and does NOT emit any PDF/DOCX. Those binaries are + non-deterministic (embedded timestamps, tool-version metadata) and are + produced only inside the image by scripts/build-legal-downloads.sh, git- + ignored like the screenshots. Keeping pandoc out of this script is what lets + the rendered HTML block be deterministic and therefore sync-gateable. + + Every value interpolated into the HTML (titles from ARB, discovered language + codes) is HTML-escaped; the document bases are a fixed allowlist (BASES). +""" +import html +import re +import sys +from pathlib import Path + +BEGIN = "" +END = "" + +# The exact three in-app documents rendered from repo-local Markdown. DFX, +# Aktionariat and the externally-hosted corporate PDFs are out of scope — they +# have no Markdown source in the repo and cannot be a derived export. +BASES = ["privacy_policy", "terms_of_use", "registration_agreement"] + +# Maps each document base to the ARB key the app uses for its title, so the +# handbook label matches what the user sees in-app. +TITLE_KEYS = { + "privacy_policy": "legalDisclaimerCheckboxPrivacyPolicy", + "terms_of_use": "termsOfUse", + "registration_agreement": "legalDisclaimerCheckboxRegistrationAgreement", +} + +# Languages are discovered, never hardcoded; this only validates that a token +# extracted from a filename looks like a language code before it is used in an +# id / href / download path. +_LANG_RE = re.compile(r"^[a-z0-9-]+$") +_PLACEHOLDER = re.compile(r"\{\{ (\w+) \}\}") + +REPO = "https://github.com/RealUnitCH/app" + + +def _esc(value: str) -> str: + """HTML-escape a value for safe interpolation into text or an attribute.""" + return html.escape(value, quote=True) + + +def load_arb(repo: Path, lang: str) -> dict: + """Load assets/languages/strings_.arb (JSON). Errors if missing.""" + import json + + path = repo / "assets/languages" / f"strings_{lang}.arb" + if not path.is_file(): + raise SystemExit(f"error: ARB file missing for discovered language '{lang}': {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def discover(repo: Path) -> "dict[str, list[str]]": + """For each base, glob assets/legal/_*.md → sorted language list.""" + legal = repo / "assets/legal" + result = {} + for base in BASES: + langs = [] + for md in legal.glob(f"{base}_*.md"): + lang = md.name[len(base) + 1 : -len(".md")] + if not _LANG_RE.match(lang): + raise SystemExit(f"error: unexpected language token '{lang}' from {md.name}") + langs.append(lang) + if not langs: + raise SystemExit(f"error: no source Markdown found for '{base}' (assets/legal/{base}_*.md)") + result[base] = sorted(langs) + return result + + +def render_rows(doc_langs: "dict[str, list[str]]", titles: "dict[str, dict[str, str]]") -> str: + """Build the per-(base, lang) download cards. Deterministic ordering: + BASES order, then languages sorted alphabetically.""" + parts = [] + for base in BASES: + de_title = _esc(titles[base]["de"]) + parts.append( + f'

    {de_title} ' + f'

    ' + ) + parts.append('
    ') + for lang in doc_langs[base]: + anchor = f"legal-{base}-{lang}" + stem = f"{base}_{lang}" + title = _esc(titles[base][lang]) + lang_e = _esc(lang) + parts.append( + f' ' + ) + parts.append('
    ') + return "\n".join(parts) + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + return 2 + + repo = Path(__file__).resolve().parent.parent + out = Path(sys.argv[1]) + + # 1) Discover documents + languages from the filesystem (never hardcoded). + doc_langs = discover(repo) + + # 2) Resolve titles from the ARB files, falling back to `de`. Every document + # must have a `de` source, since `de` is the per-document title and + # heading fallback language (render_rows() uses titles[base]["de"]). + for base in BASES: + if "de" not in doc_langs[base]: + raise SystemExit( + f"error: '{base}' has no `de` document (assets/legal/{base}_de.md) — " + "`de` is the per-document title/heading fallback language" + ) + all_langs = sorted({lang for langs in doc_langs.values() for lang in langs}) + arbs = {lang: load_arb(repo, lang) for lang in all_langs} + titles = {} + for base in BASES: + key = TITLE_KEYS[base] + de_title = arbs["de"].get(key) + if not de_title: + raise SystemExit(f"error: ARB key '{key}' (for '{base}') missing from strings_de.arb") + titles[base] = {} + for lang in doc_langs[base]: + titles[base][lang] = arbs[lang].get(key) or de_title + + # 3) Render the block from the template (single pass: each placeholder + # resolved exactly once, a substituted value is never re-scanned). + template = Path(__file__).parent / "templates/legal-downloads.html.tmpl" + ctx = {"rows": render_rows(doc_langs, titles)} + unknown = [] + + def substitute(match): + key = match.group(1) + if key not in ctx: + unknown.append(key) + return match.group(0) + return ctx[key] + + rendered = _PLACEHOLDER.sub(substitute, template.read_text(encoding="utf-8").strip("\n")) + if unknown: + print(f"error: unknown template placeholder(s): {sorted(set(unknown))}", file=sys.stderr) + return 1 + + out.mkdir(parents=True, exist_ok=True) + (out / "legal-downloads.html").write_text(rendered + "\n", encoding="utf-8") + + # 4) Substitute the block in docs/handbook/de/index.html in place (idempotent). + index = repo / "docs/handbook/de/index.html" + content = index.read_text(encoding="utf-8") + if BEGIN not in content or END not in content: + print( + f"error: {index} is missing the {BEGIN} / {END} markers — add them once " + "(see issue #658) before running this script.", + file=sys.stderr, + ) + return 1 + b = content.index(BEGIN) + e = content.index(END) + len(END) + new = content[:b] + BEGIN + "\n" + rendered + "\n" + END + content[e:] + index.write_text(new, encoding="utf-8") + + n = sum(len(v) for v in doc_langs.values()) + print(f"rendered legal-downloads block ({n} sources across {len(BASES)} docs) into {out}; synced {index}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build-legal-downloads.sh b/scripts/build-legal-downloads.sh new file mode 100755 index 00000000..1cd7a1e7 --- /dev/null +++ b/scripts/build-legal-downloads.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Build the downloadable PDF + DOCX of the in-app legal documents from their +# Markdown sources under assets/legal/, into /legal/. +# +# Usage: +# scripts/build-legal-downloads.sh +# +# Run ONLY inside the handbook image's legal-docs-builder stage (needs pandoc + +# weasyprint). The output is intentionally NON-deterministic — pandoc embeds +# timestamps and tool-version metadata — so it is treated like the assembled +# screenshots: generated only in the image, git-ignored, never committed, and +# never sync-gated. The deterministic HTML block (the download links) is the +# separate concern of scripts/assemble-handbook-legal.py. +# +# weasyprint is used as the PDF engine (HTML/CSS based) deliberately, to avoid +# pulling a full TeX Live into the image just for PDF rendering. +# +# Document discovery mirrors assemble-handbook-legal.py: the same three bases, +# languages discovered by glob (never hardcoded), so a future assets/legal/ +# _.md is picked up automatically. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +out="$1" +# Resolve the repo root from this script's location (scripts/..), so the script +# works regardless of the caller's working directory. +script_dir="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$script_dir/.." && pwd)" +legal_src="$repo/assets/legal" +legal_out="$out/legal" + +bases="privacy_policy terms_of_use registration_agreement" + +mkdir -p "$legal_out" + +count=0 +for base in $bases; do + found=0 + for md in "$legal_src/$base"_*.md; do + # Guard against a literal no-match glob. + [ -e "$md" ] || continue + found=1 + stem="$(basename "$md" .md)" + pandoc "$md" -o "$legal_out/$stem.docx" + pandoc "$md" --pdf-engine=weasyprint -o "$legal_out/$stem.pdf" + count=$((count + 2)) + done + if [ "$found" -eq 0 ]; then + echo "error: no source Markdown found for '$base' ($legal_src/${base}_*.md)" >&2 + exit 1 + fi +done + +echo "built $count legal download files into $legal_out" diff --git a/scripts/templates/legal-downloads.html.tmpl b/scripts/templates/legal-downloads.html.tmpl new file mode 100644 index 00000000..2e468421 --- /dev/null +++ b/scripts/templates/legal-downloads.html.tmpl @@ -0,0 +1,31 @@ + From 1a659e7825fdfa797f2925260e7ebe443ef25e65 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:19:05 +0200 Subject: [PATCH 6/6] fix(buy): relabel payment button to its actual action (request payment instructions) (#661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements [#660](https://github.com/RealUnitCH/app/issues/660). **Pure i18n label fix.** The buy-payment button told customers to transfer the money *first* and click *afterwards*, but the click is exactly what **requests the payment instructions** (`PUT /v1/realunit/buy/{id}/confirm` → `requestPaymentInstructions` on the API side, sets `WAITING_FOR_PAYMENT`, returns a reference). The customer should click first, receive the payment slip by email, then pay. Tester feedback: Bojan Jankovic, 28.05.2026 — "Einzig den Text auf dem Button sollte man ändern." ### Change (label only) | Lang | Old | New | |------|-----|-----| | de | `Klicken Sie hier, sobald Sie die Überweisung getätigt haben` | `Zahlungsanweisungen per E-Mail anfordern` | | en | `Click here once you have made the transfer` | `Request payment instructions by email` | Only the `buyPaymentConfirm` value in `assets/languages/strings_de.arb` + `strings_en.arb` (key keeps its alphabetical position). `lib/generated/i18n.dart` is git-ignored and regenerated in CI (`dart run tool/generate_localization.dart`). ### Strictly out of scope (untouched) `confirmPayment` / `BuyConfirmCubit` logic, the `/confirm` endpoint and any API behaviour, `buyPaymentInformationDescription`, `buyExecutedDescription` / `PaymentExecutedSheet`, and the error strings `buyPaymentConfirmFailed` / `buyPaymentConfirmFailedAktionariat`. ### Tests & goldens - No widget test asserts the old literal or the `buyPaymentConfirm` key (verified across all of `test/`) — no test code change needed. The button consumer (`payment_information_details.dart`) uses the generated getter, so the new label takes effect and `onPressed` still calls `confirmPayment`. - The `buy_payment_info_loaded` golden renders this button and changes; **`golden-regenerate.yaml` was triggered on this branch immediately after push** (not waiting for Visual Regression to fail). The other 6 buy goldens don't render this button and are unaffected. 3-subagent review: Quality + Logic both PASS_CLEAN. Draft until CI (incl. regenerated golden) is green. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- assets/languages/strings_de.arb | 2 +- assets/languages/strings_en.arb | 2 +- .../home/goldens/macos/home_page_loaded.png | Bin 27426 -> 27435 bytes 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 57203d78..e3f21595 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -28,7 +28,7 @@ "buyExecutedReference": "Ihre Referenz", "buyExecutedTitle": "Vielen Dank.", "buyMinAmount": "Mindestbetrag: ${amount} ${currency}", - "buyPaymentConfirm": "Klicken Sie hier, sobald Sie die Überweisung getätigt haben", + "buyPaymentConfirm": "Zahlungsanweisungen per E-Mail anfordern", "buyPaymentConfirmFailed": "Es gibt ein technisches Problem. Bitte versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", "buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", "buyPaymentInformation": "Zahlungsinformationen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index a988c277..56e4db7f 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -28,7 +28,7 @@ "buyExecutedReference": "Your reference", "buyExecutedTitle": "Thank you.", "buyMinAmount": "Minimum amount: ${amount} ${currency}", - "buyPaymentConfirm": "Click here once you have made the transfer", + "buyPaymentConfirm": "Request payment instructions by email", "buyPaymentConfirmFailed": "There is a technical problem. Please try again later. If the error persists, contact our support team.", "buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.", "buyPaymentInformation": "Payment information", diff --git a/test/goldens/screens/home/goldens/macos/home_page_loaded.png b/test/goldens/screens/home/goldens/macos/home_page_loaded.png index 4dd0c5683cf608e96da57292ca78d7fc0b85f1ed..84de9124736de948b97c23e0dc5d0f8dccbcd585 100644 GIT binary patch literal 27435 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