diff --git a/assets/icon-apps/home_yanruan.png b/assets/icon-apps/home_yanruan.png new file mode 100644 index 0000000..004af94 Binary files /dev/null and b/assets/icon-apps/home_yanruan.png differ diff --git a/docs/superpowers/plans/2026-07-29-forced-default-password-change.md b/docs/superpowers/plans/2026-07-29-forced-default-password-change.md new file mode 100644 index 0000000..4d1143f --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-forced-default-password-change.md @@ -0,0 +1,178 @@ +# Forced Default Password Change Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Require users who log in with `Cc@12345678` to change that default password from a non-dismissible home-page dialog and then sign in again. + +**Architecture:** Add a pure password-policy utility for the default-password trigger and the 8–16-character validation rule. Pass the login password through every successful login path to `MainPage`; it displays a reusable forced-change dialog after the first frame, calls the existing password API, and uses centralized logout to remove all credentials and session state. + +**Tech Stack:** Flutter, Dart, `flutter_test`, `SharedPreferences`, existing `AuthApi` and `SessionService`. + +--- + +### Task 1: Add the password-policy utility + +**Files:** +- Create: `lib/services/password_policy.dart` +- Create: `test/services/password_policy_test.dart` + +- [ ] **Step 1: Write failing policy tests** + +```dart +test('recognizes the configured default password', () { + expect(PasswordPolicy.requiresChange('Cc@12345678'), isTrue); + expect(PasswordPolicy.requiresChange('Cc@12345679'), isFalse); +}); + +test('accepts a valid password and rejects invalid character sets', () { + expect(PasswordPolicy.validate('Aa@12345'), isNull); + expect(PasswordPolicy.validate('Aa$12345'), isNotNull); +}); +``` + +- [ ] **Step 2: Run the policy tests to verify failure** + +Run: `flutter test test/services/password_policy_test.dart` + +Expected: FAIL because `PasswordPolicy` does not exist. + +- [ ] **Step 3: Implement the policy** + +```dart +class PasswordPolicy { + static const defaultPassword = 'Cc@12345678'; + + static bool requiresChange(String? password) => password == defaultPassword; + + static String? validate(String password) { + final valid = RegExp(r'^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[!#@*&])[A-Za-z\d!#@*&]{8,16}$'); + return valid.hasMatch(password) ? null : '密码长度为8-16位,且必须包含大写字母、小写字母、数字和特殊符号(!#@*&)。'; + } +} +``` + +- [ ] **Step 4: Run the policy tests to verify success** + +Run: `flutter test test/services/password_policy_test.dart` + +Expected: PASS. + +### Task 2: Add the reusable forced-change dialog + +**Files:** +- Modify: `lib/customWidget/custom_alert_dialog.dart` +- Create: `lib/pages/mine/force_change_password_dialog.dart` +- Create: `test/pages/mine/force_change_password_dialog_test.dart` + +- [ ] **Step 1: Write failing widget tests** + +```dart +await ForceChangePasswordDialog.show( + context, + oldPassword: 'Cc@12345678', + onUpdatePassword: (_, __) async => true, +); +expect(find.text('请修改默认密码'), findsOneWidget); +``` + +Verify the dialog cannot be dismissed by the back action, rejects invalid passwords without calling the updater, and returns success after a valid update. + +- [ ] **Step 2: Run the widget test to verify failure** + +Run: `flutter test test/pages/mine/force_change_password_dialog_test.dart` + +Expected: FAIL because the dialog API does not exist. + +- [ ] **Step 3: Implement dialog and alert extension** + +Add optional `customContent` and `onConfirmAsync` fields to `CustomAlertDialog`. Build the three password fields inside `ForceChangePasswordDialog`; use `force: true`, `PasswordPolicy.validate`, and an injected updater so the modal remains visible for validation or API failures. + +- [ ] **Step 4: Run the widget test to verify success** + +Run: `flutter test test/pages/mine/force_change_password_dialog_test.dart` + +Expected: PASS. + +### Task 3: Trigger forced change after every login path + +**Files:** +- Modify: `lib/pages/main_tab.dart` +- Modify: `lib/pages/user/login_page.dart` +- Modify: `lib/pages/user/choose_userFirm_page.dart` +- Create: `test/pages/main_tab_forced_password_change_test.dart` + +- [ ] **Step 1: Write a failing trigger test** + +```dart +expect(MainPage.shouldForcePasswordChange('Cc@12345678'), isTrue); +expect(MainPage.shouldForcePasswordChange('new-password'), isFalse); +``` + +- [ ] **Step 2: Run the trigger test to verify failure** + +Run: `flutter test test/pages/main_tab_forced_password_change_test.dart` + +Expected: FAIL because `MainPage.shouldForcePasswordChange` does not exist. + +- [ ] **Step 3: Implement the trigger and logout flow** + +Give `MainPage` optional `loginPassword` constructor data. Schedule the forced dialog after the first frame only when `PasswordPolicy.requiresChange(loginPassword)` is true. Submit `id`, current password, and new password through `AuthApi.changePassWord`; after success call `AuthService.logout()` and replace the route stack with `LoginPage`. Pass the entered password through direct login and selected-firm login. + +- [ ] **Step 4: Run the trigger test to verify success** + +Run: `flutter test test/pages/main_tab_forced_password_change_test.dart` + +Expected: PASS. + +### Task 4: Reuse policy and centralized logout in manual password changes + +**Files:** +- Modify: `lib/pages/mine/mine_set_pwd_page.dart` +- Modify: `lib/services/auth_service.dart` +- Test: `test/services/password_policy_test.dart` + +- [ ] **Step 1: Extend policy tests for length boundaries** + +```dart +test('rejects passwords outside the 8 to 16 character range', () { + expect(PasswordPolicy.validate('Aa@1234'), isNotNull); + expect(PasswordPolicy.validate('Aa@12345678901234'), isNotNull); +}); +``` + +- [ ] **Step 2: Run policy tests to verify failure** + +Run: `flutter test test/services/password_policy_test.dart` + +Expected: FAIL until exact length checking is implemented. + +- [ ] **Step 3: Share validation and credential cleanup** + +Replace `MineSetPwdPage`’s local validation with `PasswordPolicy.validate`. Change `AuthService.logout()` to remove `savePhone` and `savePass` in addition to its existing state, then use it after any successful manual password change. + +- [ ] **Step 4: Run policy tests to verify success** + +Run: `flutter test test/services/password_policy_test.dart` + +Expected: PASS. + +### Task 5: Format and verify + +**Files:** +- Modify: all changed Dart files only for formatting + +- [ ] **Step 1: Format changed sources** + +Run: `dart format lib/services/password_policy.dart lib/services/auth_service.dart lib/customWidget/custom_alert_dialog.dart lib/pages/mine/force_change_password_dialog.dart lib/pages/mine/mine_set_pwd_page.dart lib/pages/main_tab.dart lib/pages/user/login_page.dart lib/pages/user/choose_userFirm_page.dart test/services/password_policy_test.dart test/pages/mine/force_change_password_dialog_test.dart test/pages/main_tab_forced_password_change_test.dart` + +- [ ] **Step 2: Run focused regression tests** + +Run: `flutter test test/services/password_policy_test.dart test/pages/mine/force_change_password_dialog_test.dart test/pages/main_tab_forced_password_change_test.dart test/services/session_service_test.dart` + +Expected: PASS with zero failures. + +- [ ] **Step 3: Run static analysis** + +Run: `flutter analyze lib/services/password_policy.dart lib/services/auth_service.dart lib/customWidget/custom_alert_dialog.dart lib/pages/mine/force_change_password_dialog.dart lib/pages/mine/mine_set_pwd_page.dart lib/pages/main_tab.dart lib/pages/user/login_page.dart lib/pages/user/choose_userFirm_page.dart` + +Expected: exit code 0, or only diagnostics demonstrably unrelated to changed code. diff --git a/test/pages/main_tab_forced_password_change_test.dart b/test/pages/main_tab_forced_password_change_test.dart new file mode 100644 index 0000000..365b1ea --- /dev/null +++ b/test/pages/main_tab_forced_password_change_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:qhd_prevention/pages/main_tab.dart'; + +void main() { + test('requires a change only for the default password', () { + expect(MainPage.shouldForcePasswordChange('Cc@12345678'), isTrue); + expect(MainPage.shouldForcePasswordChange('new-password'), isFalse); + expect(MainPage.shouldForcePasswordChange(null), isFalse); + }); +} diff --git a/test/pages/mine/force_change_password_dialog_test.dart b/test/pages/mine/force_change_password_dialog_test.dart new file mode 100644 index 0000000..09a6b44 --- /dev/null +++ b/test/pages/mine/force_change_password_dialog_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:qhd_prevention/pages/mine/force_change_password_dialog.dart'; + +void main() { + testWidgets('requires a valid matching password before updating', ( + tester, + ) async { + var updateCalls = 0; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: + (context) => TextButton( + onPressed: + () => ForceChangePasswordDialog.show( + context, + oldPassword: 'Cc@12345678', + onUpdatePassword: (_, __) async { + updateCalls++; + return true; + }, + ), + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('请修改默认密码'), findsOneWidget); + final firstField = tester.widget(find.byType(TextField).first); + final decoration = firstField.decoration!; + expect(decoration.border, isA()); + expect(decoration.enabledBorder, isA()); + expect(decoration.focusedBorder, isA()); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('请修改默认密码'), findsOneWidget); + + await tester.enterText(find.byType(TextField).at(1), 'Aa@12345'); + await tester.enterText(find.byType(TextField).at(2), 'different'); + await tester.tap(find.text('提交')); + await tester.pump(); + + expect(updateCalls, 0); + expect(find.text('新密码和确认密码不一致'), findsNothing); + }); + + testWidgets('submits a valid new password and closes the dialog', ( + tester, + ) async { + String? submittedOldPassword; + String? submittedNewPassword; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: + (context) => TextButton( + onPressed: + () => ForceChangePasswordDialog.show( + context, + oldPassword: 'Cc@12345678', + onUpdatePassword: (oldPassword, newPassword) async { + submittedOldPassword = oldPassword; + submittedNewPassword = newPassword; + return true; + }, + ), + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(1), 'Aa@12345'); + await tester.enterText(find.byType(TextField).at(2), 'Aa@12345'); + await tester.tap(find.text('提交')); + await tester.pumpAndSettle(); + + expect(submittedOldPassword, 'Cc@12345678'); + expect(submittedNewPassword, 'Aa@12345'); + expect(find.text('请修改默认密码'), findsNothing); + }); +} diff --git a/test/services/auth_service_test.dart b/test/services/auth_service_test.dart new file mode 100644 index 0000000..1646626 --- /dev/null +++ b/test/services/auth_service_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:qhd_prevention/services/auth_service.dart'; + +void main() { + test('stops login processing when the login API reports failure', () { + expect( + AuthService.isLoginResponseSuccessful({ + 'success': false, + 'errCode': 'BIZ_ERROR', + 'errMessage': '解密失败', + 'data': null, + }), + isFalse, + ); + }); +} diff --git a/test/services/password_policy_test.dart b/test/services/password_policy_test.dart new file mode 100644 index 0000000..b3b7a78 --- /dev/null +++ b/test/services/password_policy_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:qhd_prevention/services/password_policy.dart'; + +void main() { + test('recognizes the configured default password', () { + expect(PasswordPolicy.requiresChange('Cc@12345678'), isTrue); + expect(PasswordPolicy.requiresChange('Cc@12345679'), isFalse); + }); + + test('accepts a valid password and rejects invalid character sets', () { + expect(PasswordPolicy.validate('Aa@12345'), isNull); + expect(PasswordPolicy.validate(r'Aa$12345'), isNotNull); + }); + + test('rejects passwords outside the 8 to 16 character range', () { + expect(PasswordPolicy.validate('Aa@1234'), isNotNull); + expect(PasswordPolicy.validate('Aa@12345678901234'), isNotNull); + }); +} diff --git a/test/services/session_service_test.dart b/test/services/session_service_test.dart new file mode 100644 index 0000000..fe85c0e --- /dev/null +++ b/test/services/session_service_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:qhd_prevention/services/SessionService.dart'; + +void main() { + test('preserves the nine-company device-management flag from login data', () { + final user = UserData.fromJson({'nineCompanyFlag': true}); + + expect(user.nineCompanyFlag, isTrue); + expect(user.toJson()['nineCompanyFlag'], isTrue); + }); +}