From 073967ee78ca1c6edd922e00b220f31a81f67141 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 12:45:14 +0800 Subject: [PATCH] Refactor settings account login flow --- .../desktop_settings_flow_test.dart | 153 +---- lib/features/account/account_page.dart | 560 ------------------ lib/features/settings/settings_page_core.dart | 541 +++++++++++++---- lib/models/app_models.dart | 10 - lib/runtime/account_runtime_client.dart | 7 +- ...ime_controllers_settings_account_impl.dart | 29 +- lib/runtime/runtime_models_account.dart | 12 + ...settings_page_account_status_canonical.png | Bin 28373 -> 27677 bytes .../settings/settings_page_core_test.dart | 209 +++++-- .../settings_account_auth_flow_test.dart | 235 ++++++++ 10 files changed, 863 insertions(+), 893 deletions(-) delete mode 100644 lib/features/account/account_page.dart create mode 100644 test/runtime/settings_account_auth_flow_test.dart diff --git a/integration_test/desktop_settings_flow_test.dart b/integration_test/desktop_settings_flow_test.dart index 3f1accaa..0a16af71 100644 --- a/integration_test/desktop_settings_flow_test.dart +++ b/integration_test/desktop_settings_flow_test.dart @@ -4,7 +4,6 @@ import 'package:integration_test/integration_test.dart'; import 'package:xworkmate/app/app_controller_desktop_core.dart'; import 'package:xworkmate/features/settings/settings_page.dart'; import 'package:xworkmate/i18n/app_language.dart'; -import 'package:xworkmate/models/app_models.dart'; import 'package:xworkmate/runtime/runtime_controllers_settings.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; @@ -14,8 +13,10 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets( - 'settings page keeps canonical account status and logout behavior aligned', + 'settings login card reads canonical values instead of stale draft data', (tester) async { + await tester.binding.setSurfaceSize(const Size(1600, 1200)); + addTearDown(() async => tester.binding.setSurfaceSize(null)); final fixtures = _buildSettingsPageFixtures(); final controller = fixtures.controller; final canonicalSettings = fixtures.canonicalSettings; @@ -23,22 +24,6 @@ void main() { final staleDraft = canonicalSettings.copyWith( accountBaseUrl: 'https://draft-accounts.svc.plus', accountUsername: 'draft@svc.plus', - acpBridgeServerModeConfig: canonicalSettings.acpBridgeServerModeConfig - .copyWith( - cloudSynced: canonicalSettings - .acpBridgeServerModeConfig - .cloudSynced - .copyWith( - accountBaseUrl: 'https://draft-accounts.svc.plus', - accountIdentifier: 'draft@svc.plus', - lastSyncAt: 987654321, - remoteServerSummary: - const AcpBridgeServerRemoteServerSummary( - endpoint: 'wss://draft-gateway.svc.plus', - hasAdvancedOverrides: true, - ), - ), - ), ); await controller.saveSettingsDraft(staleDraft); @@ -59,47 +44,20 @@ void main() { ); await tester.pump(const Duration(milliseconds: 300)); - final serviceUrlText = tester.widget( - find.byKey(const ValueKey('settings-account-summary-service-url')), + final baseUrlField = tester.widget( + find.byKey(const ValueKey('settings-account-base-url-field')), ); - final accountIdentifierText = tester.widget( - find.byKey( - const ValueKey('settings-account-summary-account-identifier'), - ), - ); - expect(serviceUrlText.data ?? '', contains('https://accounts.svc.plus')); - expect( - serviceUrlText.data ?? '', - isNot(contains('https://draft-accounts.svc.plus')), - ); - expect(accountIdentifierText.data ?? '', contains('canonical@svc.plus')); - expect( - accountIdentifierText.data ?? '', - isNot(contains('draft@svc.plus')), + final identifierField = tester.widget( + find.byKey(const ValueKey('settings-account-identifier-field')), ); - await controller.settingsController.syncAccountSettings( - baseUrl: controller.settings.accountBaseUrl, - ); - await tester.pump(); - + expect(baseUrlField.controller?.text, 'https://accounts.svc.plus'); expect( - controller.settingsController.syncedBaseUrls, - contains('https://accounts.svc.plus'), + baseUrlField.controller?.text, + isNot('https://draft-accounts.svc.plus'), ); - expect( - controller.settingsController.syncedBaseUrls, - isNot(contains('https://draft-accounts.svc.plus')), - ); - - await controller.settingsController.logoutAccount(); - await tester.pump(); - - expect(find.text('未登录'), findsOneWidget); - final loggedOutButton = tester.widget( - find.byKey(const ValueKey('settings-account-logout-button')), - ); - expect(loggedOutButton.onPressed, isNull); + expect(identifierField.controller?.text, 'canonical@svc.plus'); + expect(identifierField.controller?.text, isNot('draft@svc.plus')); }, ); } @@ -109,18 +67,7 @@ SettingsSnapshot _buildCanonicalSettings() { return defaults.copyWith( accountBaseUrl: 'https://accounts.svc.plus', accountUsername: 'canonical@svc.plus', - accountLocalMode: false, - acpBridgeServerModeConfig: defaults.acpBridgeServerModeConfig.copyWith( - cloudSynced: defaults.acpBridgeServerModeConfig.cloudSynced.copyWith( - accountBaseUrl: 'https://accounts.svc.plus', - accountIdentifier: 'canonical@svc.plus', - lastSyncAt: 123456789, - remoteServerSummary: const AcpBridgeServerRemoteServerSummary( - endpoint: 'wss://gateway.svc.plus', - hasAdvancedOverrides: false, - ), - ), - ), + accountLocalMode: true, ); } @@ -129,7 +76,7 @@ _SettingsPageFixtures _buildSettingsPageFixtures() { appLanguage: AppLanguage.zh, ); final settingsController = _FakeSettingsController() - ..seedSignedInState(canonicalSettings); + ..seedSignedOutState(canonicalSettings); final controller = _FakeSettingsPageController( settingsController: settingsController, settingsDraft: canonicalSettings, @@ -163,6 +110,7 @@ class _FakeSettingsPageController extends ChangeNotifier @override final _FakeSettingsController settingsController; + SettingsSnapshot _settingsDraft; @override @@ -176,22 +124,6 @@ class _FakeSettingsPageController extends ChangeNotifier notifyListeners(); } - Future saveSettings(SettingsSnapshot snapshot) async { - settingsController.snapshotInternal = snapshot; - _settingsDraft = snapshot; - notifyListeners(); - } - - @override - void navigateHome() {} - - @override - void openSettings({ - SettingsTab tab = SettingsTab.gateway, - SettingsDetailPage? detail, - SettingsNavigationContext? navigationContext, - }) {} - @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -200,66 +132,15 @@ class _FakeSettingsController extends SettingsController { _FakeSettingsController() : super(SecureConfigStore(enableSecureStorage: false)); - final List syncedBaseUrls = []; - - void seedSignedInState(SettingsSnapshot settings) { + void seedSignedOutState(SettingsSnapshot settings) { snapshotInternal = settings; lastSnapshotJsonInternal = settings.toJsonString(); - accountSessionTokenInternal = 'session-token'; - accountSessionInternal = const AccountSessionSummary( - userId: 'u-1', - email: 'canonical@svc.plus', - name: 'Canonical', - role: 'member', - mfaEnabled: false, - ); - accountSyncStateInternal = AccountSyncState.defaults().copyWith( - syncState: 'ready', - syncMessage: 'Remote defaults synced', - lastSyncAtMs: 123456789, - lastSyncSource: 'https://accounts.svc.plus', - syncedDefaults: AccountRemoteProfile.defaults().copyWith( - openclawUrl: 'wss://gateway.svc.plus', - apisixUrl: 'https://apisix.svc.plus', - ), - ); - accountStatusInternal = 'Signed in as canonical@svc.plus'; - accountBusyInternal = false; - pendingAccountMfaTicketInternal = ''; - pendingAccountBaseUrlInternal = ''; - } - - Future syncAccountSettings({String baseUrl = ''}) async { - syncedBaseUrls.add(baseUrl); - accountBusyInternal = true; - notifyListeners(); - accountSyncStateInternal = AccountSyncState.defaults().copyWith( - syncState: 'ready', - syncMessage: 'Remote defaults synced', - lastSyncAtMs: 123456789, - lastSyncSource: baseUrl, - syncedDefaults: AccountRemoteProfile.defaults().copyWith( - openclawUrl: 'wss://gateway.svc.plus', - apisixUrl: 'https://apisix.svc.plus', - ), - ); - accountBusyInternal = false; - final email = accountSessionInternal?.email.trim() ?? ''; - accountStatusInternal = email.isEmpty ? 'Signed in' : 'Signed in as $email'; - notifyListeners(); - return const AccountSyncResult( - state: 'ready', - message: 'Remote defaults synced', - ); - } - - Future logoutAccount() async { accountSessionTokenInternal = ''; accountSessionInternal = null; accountSyncStateInternal = null; accountStatusInternal = 'Signed out'; + accountBusyInternal = false; pendingAccountMfaTicketInternal = ''; pendingAccountBaseUrlInternal = ''; - notifyListeners(); } } diff --git a/lib/features/account/account_page.dart b/lib/features/account/account_page.dart deleted file mode 100644 index e1ca0792..00000000 --- a/lib/features/account/account_page.dart +++ /dev/null @@ -1,560 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/app_controller.dart'; -import '../../app/app_metadata.dart'; -import '../../i18n/app_language.dart'; -import '../../models/app_models.dart'; -import '../../runtime/runtime_controllers.dart'; -import '../../runtime/runtime_models.dart'; -import '../../widgets/section_tabs.dart'; -import '../../widgets/surface_card.dart'; -import '../../widgets/top_bar.dart'; - -class AccountPage extends StatefulWidget { - const AccountPage({super.key, required this.controller}); - - final AppController controller; - - @override - State createState() => _AccountPageState(); -} - -class _AccountPageState extends State { - AccountTab _tab = AccountTab.profile; - late final TextEditingController _accountBaseUrlController; - late final TextEditingController _accountUsernameController; - late final TextEditingController _accountPasswordController; - late final TextEditingController _accountMfaCodeController; - late final TextEditingController _accountWorkspaceController; - String _lastSavedAccountBaseUrl = ''; - String _lastSavedAccountUsername = ''; - String _lastSavedAccountWorkspace = ''; - - @override - void initState() { - super.initState(); - final settings = widget.controller.settings; - _lastSavedAccountBaseUrl = settings.accountBaseUrl; - _lastSavedAccountUsername = settings.accountUsername; - _lastSavedAccountWorkspace = settings.accountWorkspace; - _accountBaseUrlController = TextEditingController( - text: _lastSavedAccountBaseUrl, - ); - _accountUsernameController = TextEditingController( - text: _lastSavedAccountUsername, - ); - _accountPasswordController = TextEditingController(); - _accountMfaCodeController = TextEditingController(); - _accountWorkspaceController = TextEditingController( - text: _lastSavedAccountWorkspace, - ); - } - - @override - void dispose() { - _accountBaseUrlController.dispose(); - _accountUsernameController.dispose(); - _accountPasswordController.dispose(); - _accountMfaCodeController.dispose(); - _accountWorkspaceController.dispose(); - super.dispose(); - } - - void _syncControllers(SettingsSnapshot settings) { - if (_accountBaseUrlController.text == _lastSavedAccountBaseUrl && - settings.accountBaseUrl != _lastSavedAccountBaseUrl) { - _accountBaseUrlController.text = settings.accountBaseUrl; - } - if (_accountUsernameController.text == _lastSavedAccountUsername && - settings.accountUsername != _lastSavedAccountUsername) { - _accountUsernameController.text = settings.accountUsername; - } - if (_accountWorkspaceController.text == _lastSavedAccountWorkspace && - settings.accountWorkspace != _lastSavedAccountWorkspace) { - _accountWorkspaceController.text = settings.accountWorkspace; - } - _lastSavedAccountBaseUrl = settings.accountBaseUrl; - _lastSavedAccountUsername = settings.accountUsername; - _lastSavedAccountWorkspace = settings.accountWorkspace; - } - - Future _saveProfile(SettingsSnapshot settings) async { - final nextSettings = settings.copyWith( - accountBaseUrl: _accountBaseUrlController.text.trim(), - accountUsername: _accountUsernameController.text.trim(), - ); - await widget.controller.saveSettings(nextSettings); - _lastSavedAccountBaseUrl = nextSettings.accountBaseUrl; - _lastSavedAccountUsername = nextSettings.accountUsername; - } - - Future _saveWorkspace(SettingsSnapshot settings) async { - final nextSettings = settings.copyWith( - accountWorkspace: _accountWorkspaceController.text.trim(), - ); - await widget.controller.saveSettings(nextSettings); - _lastSavedAccountWorkspace = nextSettings.accountWorkspace; - } - - Future _loginAccount(SettingsSnapshot settings) async { - await _saveProfile(settings); - try { - await widget.controller.settingsController.loginAccount( - baseUrl: _accountBaseUrlController.text.trim(), - identifier: _accountUsernameController.text.trim(), - password: _accountPasswordController.text, - ); - } finally { - _accountPasswordController.clear(); - } - } - - Future _verifyAccountMfa() async { - try { - await widget.controller.settingsController.verifyAccountMfa( - baseUrl: _accountBaseUrlController.text.trim(), - code: _accountMfaCodeController.text.trim(), - ); - } finally { - _accountMfaCodeController.clear(); - } - } - - Future _syncAccountSettings(SettingsSnapshot settings) async { - await _saveProfile(settings); - await widget.controller.settingsController.syncAccountSettings( - baseUrl: _accountBaseUrlController.text.trim(), - ); - } - - Future _logoutAccount() async { - await widget.controller.settingsController.logoutAccount(); - _accountPasswordController.clear(); - _accountMfaCodeController.clear(); - } - - Future _cancelAccountMfa() async { - await widget.controller.settingsController.cancelAccountMfaChallenge(); - _accountPasswordController.clear(); - _accountMfaCodeController.clear(); - } - - Widget _buildSignedOutLoginCard(BuildContext context, SettingsSnapshot settings) { - final theme = Theme.of(context); - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 840), - child: SurfaceCard( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 36), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Icons.cloud_outlined, - size: 72, - color: theme.colorScheme.primary, - ), - const SizedBox(height: 16), - Text( - appText('账号登录', 'Account Sign In'), - style: theme.textTheme.headlineMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - Text( - appText('请先登录', 'Please sign in first'), - style: theme.textTheme.titleMedium?.copyWith( - color: theme.textTheme.bodyMedium?.color?.withValues( - alpha: 0.8, - ), - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 28), - TextFormField( - key: const ValueKey('account-base-url-field'), - controller: _accountBaseUrlController, - decoration: InputDecoration( - labelText: appText('服务地址', 'Service URL'), - prefixIcon: const Icon(Icons.dns_outlined), - ), - onFieldSubmitted: (_) => _saveProfile(settings), - ), - const SizedBox(height: 16), - TextFormField( - key: const ValueKey('account-username-field'), - controller: _accountUsernameController, - decoration: InputDecoration( - labelText: appText('邮箱或账号', 'Email or Username'), - prefixIcon: const Icon(Icons.person_outline_rounded), - ), - onFieldSubmitted: (_) => _saveProfile(settings), - ), - const SizedBox(height: 16), - TextFormField( - key: const ValueKey('account-password-field'), - controller: _accountPasswordController, - obscureText: true, - decoration: InputDecoration( - labelText: appText('密码', 'Password'), - prefixIcon: const Icon(Icons.lock_outline_rounded), - ), - onFieldSubmitted: (_) => _loginAccount(settings), - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - key: const ValueKey('account-login-button'), - onPressed: widget.controller.settingsController.accountBusy - ? null - : () => _loginAccount(settings), - child: Text(appText('登录', 'Sign In')), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildProfileCard( - BuildContext context, - SettingsSnapshot settings, - bool accountBusy, - bool accountSignedIn, - bool accountMfaRequired, - String signedInLabel, - String profileDescription, - String sessionStatusText, - String syncStatusText, - AccountSyncState? accountSyncState, - ) { - final cloudSync = settings.acpBridgeServerModeConfig.cloudSynced; - final remoteSummary = cloudSync.remoteServerSummary; - final syncSummaryText = remoteSummary.endpoint.trim().isEmpty - ? appText('还没有云端 ACP Bridge Server 摘要。', 'No cloud ACP Bridge Server summary yet.') - : remoteSummary.endpoint; - return SurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - accountSignedIn - ? signedInLabel - : settings.accountUsername.trim().isEmpty - ? appText('本地操作员', 'Local Operator') - : settings.accountUsername, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - Text( - profileDescription, - ), - const SizedBox(height: 16), - Text( - sessionStatusText, - key: const ValueKey('account-session-status'), - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 6), - Text( - syncStatusText, - key: const ValueKey('account-sync-status'), - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 16), - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - appText('云端 ACP Bridge Server 摘要', 'Cloud ACP Bridge Server Summary'), - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - Text( - '${appText('服务地址', 'Service URL')}: ${cloudSync.accountBaseUrl.trim().isEmpty ? settings.accountBaseUrl : cloudSync.accountBaseUrl}', - key: const ValueKey('account-acp-sync-summary-url'), - ), - const SizedBox(height: 6), - Text( - '${appText('同步目标', 'Synced Target')}: $syncSummaryText', - key: const ValueKey('account-acp-sync-summary-endpoint'), - ), - const SizedBox(height: 6), - Text( - '${appText('最近同步', 'Last Sync')}: ${accountSyncState == null || cloudSync.lastSyncAt <= 0 ? appText('尚未同步', 'Not synced yet') : DateTime.fromMillisecondsSinceEpoch(cloudSync.lastSyncAt).toLocal().toIso8601String()}', - ), - const SizedBox(height: 10), - FilledButton.tonal( - key: const ValueKey('account-open-settings-acp'), - onPressed: () => widget.controller.openSettings( - tab: SettingsTab.gateway, - ), - child: Text( - appText( - '前往设置中的 ACP Bridge Server', - 'Open ACP Bridge Server in Settings', - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - TextFormField( - key: const ValueKey('account-base-url-field'), - controller: _accountBaseUrlController, - readOnly: accountMfaRequired, - decoration: InputDecoration( - labelText: appText('服务地址', 'Service URL'), - ), - onFieldSubmitted: (_) => _saveProfile(settings), - ), - const SizedBox(height: 14), - TextFormField( - key: const ValueKey('account-username-field'), - controller: _accountUsernameController, - readOnly: accountMfaRequired, - decoration: InputDecoration( - labelText: appText('邮箱 / 用户名', 'Email / Username'), - ), - onFieldSubmitted: (_) => _saveProfile(settings), - ), - if (accountMfaRequired) ...[ - const SizedBox(height: 16), - TextFormField( - key: const ValueKey('account-mfa-code-field'), - controller: _accountMfaCodeController, - decoration: InputDecoration( - labelText: appText('双重验证代码', 'MFA Code'), - ), - onFieldSubmitted: (_) => _verifyAccountMfa(), - ), - ], - const SizedBox(height: 16), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - if (accountMfaRequired) - FilledButton.tonal( - key: const ValueKey('account-verify-mfa-button'), - onPressed: accountBusy ? null : _verifyAccountMfa, - child: Text(appText('验证并同步', 'Verify & Sync')), - ), - if (accountMfaRequired) - FilledButton.tonal( - key: const ValueKey('account-edit-button'), - onPressed: accountBusy ? null : _cancelAccountMfa, - child: Text( - appText('返回编辑', 'Back to Edit'), - ), - ), - if (accountSignedIn) - FilledButton.tonal( - key: const ValueKey('account-sync-button'), - onPressed: accountBusy - ? null - : () => _syncAccountSettings(settings), - child: Text( - appText('重新同步', 'Sync Again'), - ), - ), - if (accountSignedIn) - FilledButton.tonal( - key: const ValueKey('account-logout-button'), - onPressed: accountBusy ? null : _logoutAccount, - child: Text(appText('退出登录', 'Log Out')), - ), - ], - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final controller = widget.controller; - return AnimatedBuilder( - animation: Listenable.merge([ - controller, - controller.settingsController, - ]), - builder: (context, _) { - final settings = controller.settings; - final settingsController = controller.settingsController; - _syncControllers(settings); - final accountSession = settingsController.accountSession; - final accountSyncState = settingsController.accountSyncState; - final accountBusy = settingsController.accountBusy; - final accountSignedIn = settingsController.accountSignedIn; - final accountMfaRequired = settingsController.accountMfaRequired; - final accountSignedOutLoginMode = !accountSignedIn && !accountMfaRequired; - final signedInLabel = accountSession?.email.trim().isNotEmpty == true - ? accountSession!.email.trim() - : accountSession?.name.trim().isNotEmpty == true - ? accountSession!.name.trim() - : appText('当前账号', 'Current account'); - final sessionStatusText = accountSignedIn - ? appText('已登录:$signedInLabel', 'Signed in: $signedInLabel') - : accountMfaRequired - ? appText('等待双重验证', 'Waiting for MFA verification') - : appText('未登录', 'Signed out'); - final syncStatusText = accountSyncState == null - ? appText('idle · 尚未同步远程配置', 'idle · Remote config not synced yet') - : '${accountSyncState.syncState} · ${accountSyncState.syncMessage}'; - final profileDescription = accountSignedIn - ? appText( - '这里继续只负责账号身份、MFA、工作区与同步摘要。ACP Bridge Server 的三模式配置已统一收口到设置页。', - 'This page now focuses on identity, MFA, workspace, and sync summary only. ACP Bridge Server mode configuration now lives in Settings.', - ) - : accountMfaRequired - ? appText( - '请输入 MFA 验证码完成同步,也可以返回编辑账号信息。', - 'Enter the MFA code to finish sync, or return to edit account details.', - ) - : appText( - '登录后会同步云端默认配置;更细粒度的 Bridge Server、自托管和高级自定义请前往设置页。', - 'Signing in syncs the cloud defaults. For bridge server self-hosting and advanced overrides, use the Settings page.', - ); - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(32, 32, 32, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TopBar( - breadcrumbs: [ - AppBreadcrumbItem( - label: appText('主页', 'Home'), - icon: Icons.home_rounded, - onTap: controller.navigateHome, - ), - AppBreadcrumbItem(label: appText('账号', 'Account')), - AppBreadcrumbItem(label: _tab.label), - ], - title: appText('账号', 'Account'), - subtitle: appText( - '用户身份、工作区切换与登录会话。', - 'Identity, workspace switching, and sign-in sessions.', - ), - ), - const SizedBox(height: 24), - SectionTabs( - items: AccountTab.values.map((item) => item.label).toList(), - value: _tab.label, - size: SectionTabsSize.small, - onChanged: (value) => setState( - () => _tab = AccountTab.values.firstWhere( - (item) => item.label == value, - ), - ), - ), - const SizedBox(height: 24), - if (_tab == AccountTab.profile) - accountSignedOutLoginMode - ? _buildSignedOutLoginCard(context, settings) - : _buildProfileCard( - context, - settings, - accountBusy, - accountSignedIn, - accountMfaRequired, - signedInLabel, - profileDescription, - sessionStatusText, - syncStatusText, - accountSyncState, - ), - if (_tab == AccountTab.workspace) - SurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - settings.accountWorkspace, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - appText( - '$kProductBrandName 的工作区外壳', - 'Workspace shell for $kProductBrandName', - ), - ), - const SizedBox(height: 16), - TextFormField( - key: const ValueKey('account-workspace-field'), - controller: _accountWorkspaceController, - decoration: InputDecoration( - labelText: appText('工作区名称', 'Workspace Label'), - ), - onFieldSubmitted: (_) => _saveWorkspace(settings), - ), - const SizedBox(height: 16), - Align( - alignment: Alignment.centerLeft, - child: FilledButton( - onPressed: () => _saveWorkspace(settings), - child: Text(appText('保存工作区', 'Save Workspace')), - ), - ), - ], - ), - ), - if (_tab == AccountTab.sessions) - if (controller.sessions.isEmpty) - SurfaceCard( - child: Text( - appText( - '还没有 Gateway 会话。请先连接并开始一次对话。', - 'No gateway sessions yet. Connect and start a chat first.', - ), - ), - ) - else - ...controller.sessions.map( - (session) => Padding( - padding: const EdgeInsets.only(bottom: 14), - child: SurfaceCard( - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - session.label, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - '${session.surface ?? appText('会话', 'Session')} · ${session.kind ?? 'chat'}', - ), - ], - ), - ), - Text(session.model ?? appText('网关', 'gateway')), - ], - ), - ), - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index d34ab39c..c8ef609c 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -25,29 +25,115 @@ class SettingsPage extends StatefulWidget { final SettingsTab initialTab; final SettingsDetailPage? initialDetail; final SettingsNavigationContext? navigationContext; + @override State createState() => _SettingsPageState(); } class _SettingsPageState extends State { final TextEditingController _searchController = TextEditingController(); + late final TextEditingController _accountBaseUrlController; + late final TextEditingController _accountIdentifierController; + late final TextEditingController _accountPasswordController; + late final TextEditingController _accountMfaCodeController; _SettingsIntegrationTab _integrationTab = _SettingsIntegrationTab.accountStatus; + String _lastSavedAccountBaseUrl = ''; + String _lastSavedAccountIdentifier = ''; + + @override + void initState() { + super.initState(); + final settings = widget.controller.settings; + _lastSavedAccountBaseUrl = settings.accountBaseUrl; + _lastSavedAccountIdentifier = settings.accountUsername; + _accountBaseUrlController = TextEditingController( + text: _lastSavedAccountBaseUrl, + ); + _accountIdentifierController = TextEditingController( + text: _lastSavedAccountIdentifier, + ); + _accountPasswordController = TextEditingController(); + _accountMfaCodeController = TextEditingController(); + } @override void dispose() { _searchController.dispose(); + _accountBaseUrlController.dispose(); + _accountIdentifierController.dispose(); + _accountPasswordController.dispose(); + _accountMfaCodeController.dispose(); super.dispose(); } - Future _syncAccount(SettingsSnapshot settings) async { - await widget.controller.settingsController.syncAccountSettings( - baseUrl: settings.accountBaseUrl, + void _syncAccountControllers(SettingsSnapshot settings) { + if (_accountBaseUrlController.text == _lastSavedAccountBaseUrl && + settings.accountBaseUrl != _lastSavedAccountBaseUrl) { + _accountBaseUrlController.text = settings.accountBaseUrl; + } + if (_accountIdentifierController.text == _lastSavedAccountIdentifier && + settings.accountUsername != _lastSavedAccountIdentifier) { + _accountIdentifierController.text = settings.accountUsername; + } + _lastSavedAccountBaseUrl = settings.accountBaseUrl; + _lastSavedAccountIdentifier = settings.accountUsername; + } + + Future _saveAccountProfile(SettingsSnapshot settings) async { + final nextSettings = settings.copyWith( + accountBaseUrl: _accountBaseUrlController.text.trim(), + accountUsername: _accountIdentifierController.text.trim(), ); + await widget.controller.settingsController.saveSnapshot(nextSettings); + _lastSavedAccountBaseUrl = nextSettings.accountBaseUrl; + _lastSavedAccountIdentifier = nextSettings.accountUsername; + } + + Future _loginAccount(SettingsSnapshot settings) async { + final baseUrl = _accountBaseUrlController.text.trim(); + final identifier = _accountIdentifierController.text.trim(); + try { + await _saveAccountProfile(settings); + await widget.controller.settingsController.loginAccount( + baseUrl: baseUrl, + identifier: identifier, + password: _accountPasswordController.text, + ); + } finally { + _accountPasswordController.clear(); + } + } + + Future _syncAccount(SettingsSnapshot settings) async { + await _saveAccountProfile(settings); + await widget.controller.settingsController.syncAccountSettings( + baseUrl: _accountBaseUrlController.text.trim(), + ); + } + + Future _verifyAccountMfa(SettingsSnapshot settings) async { + try { + await _saveAccountProfile(settings); + await widget.controller.settingsController.verifyAccountMfa( + baseUrl: _accountBaseUrlController.text.trim(), + code: _accountMfaCodeController.text.trim(), + ); + } finally { + _accountMfaCodeController.clear(); + } + } + + Future _cancelAccountMfa() async { + await widget.controller.settingsController.cancelAccountMfaChallenge(); + _accountPasswordController.clear(); + _accountMfaCodeController.clear(); } Future _logoutAccount() async { await widget.controller.settingsController.logoutAccount(); + _accountPasswordController.clear(); + _accountMfaCodeController.clear(); } Future _disconnectManagedBase(SettingsSnapshot settings) async { @@ -60,7 +146,319 @@ class _SettingsPageState extends State { ), ), ); - await widget.controller.saveSettings(nextSettings); + await widget.controller.settingsController.saveSnapshot(nextSettings); + } + + Widget _buildTokenConfiguredSummary(AccountSyncState? accountState) { + final configured = [ + if (accountState?.tokenConfigured.openclaw == true) + appText('Gateway Token', 'Gateway Token'), + if (accountState?.tokenConfigured.apisix == true) + appText('AI Gateway Token', 'AI Gateway Token'), + if (accountState?.tokenConfigured.vault == true) 'Vault Token', + ]; + final summary = configured.isEmpty + ? appText('未配置', 'Not configured') + : configured.join(' / '); + return Text( + '${appText('已同步令牌', 'Synced Tokens')}: $summary', + key: const ValueKey('settings-account-summary-token-configured'), + ); + } + + Widget _buildSignedOutAccountCard( + BuildContext context, + SettingsSnapshot settings, + bool accountBusy, + ) { + final theme = Theme.of(context); + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.cloud_outlined, + size: 72, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + appText('账号登录', 'Account Sign In'), + style: theme.textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Text( + appText('请先登录', 'Please sign in first'), + style: theme.textTheme.titleMedium?.copyWith( + color: theme.textTheme.bodyMedium?.color?.withValues( + alpha: 0.8, + ), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 28), + TextFormField( + key: const ValueKey('settings-account-base-url-field'), + controller: _accountBaseUrlController, + decoration: InputDecoration( + labelText: appText('服务地址', 'Service URL'), + prefixIcon: const Icon(Icons.dns_outlined), + ), + onFieldSubmitted: (_) => _saveAccountProfile(settings), + ), + const SizedBox(height: 16), + TextFormField( + key: const ValueKey('settings-account-identifier-field'), + controller: _accountIdentifierController, + decoration: InputDecoration( + labelText: appText('邮箱或账号', 'Email or Username'), + prefixIcon: const Icon(Icons.person_outline_rounded), + ), + onFieldSubmitted: (_) => _saveAccountProfile(settings), + ), + const SizedBox(height: 16), + TextFormField( + key: const ValueKey('settings-account-password-field'), + controller: _accountPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: appText('密码', 'Password'), + prefixIcon: const Icon(Icons.lock_outline_rounded), + ), + onFieldSubmitted: (_) => _loginAccount(settings), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + key: const ValueKey('settings-account-login-button'), + onPressed: accountBusy ? null : () => _loginAccount(settings), + child: Text(appText('登录', 'Sign In')), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPendingMfaAccountCard( + BuildContext context, + SettingsSnapshot settings, + bool accountBusy, + ) { + final theme = Theme.of(context); + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.verified_user_outlined, + size: 72, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + appText('双重验证', 'Multi-Factor Authentication'), + style: theme.textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Text( + appText( + '请输入验证码完成登录并同步设置。', + 'Enter your code to finish signing in and sync settings.', + ), + style: theme.textTheme.titleMedium?.copyWith( + color: theme.textTheme.bodyMedium?.color?.withValues( + alpha: 0.8, + ), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 28), + TextFormField( + key: const ValueKey('settings-account-base-url-field'), + controller: _accountBaseUrlController, + readOnly: true, + decoration: InputDecoration( + labelText: appText('服务地址', 'Service URL'), + prefixIcon: const Icon(Icons.dns_outlined), + ), + ), + const SizedBox(height: 16), + TextFormField( + key: const ValueKey('settings-account-identifier-field'), + controller: _accountIdentifierController, + readOnly: true, + decoration: InputDecoration( + labelText: appText('邮箱或账号', 'Email or Username'), + prefixIcon: const Icon(Icons.person_outline_rounded), + ), + ), + const SizedBox(height: 16), + TextFormField( + key: const ValueKey('settings-account-mfa-code-field'), + controller: _accountMfaCodeController, + decoration: InputDecoration( + labelText: appText('双重验证代码', 'MFA Code'), + prefixIcon: const Icon(Icons.key_outlined), + ), + onFieldSubmitted: (_) => _verifyAccountMfa(settings), + ), + const SizedBox(height: 24), + Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: [ + FilledButton( + key: const ValueKey('settings-account-mfa-verify-button'), + onPressed: accountBusy + ? null + : () => _verifyAccountMfa(settings), + child: Text(appText('验证并同步', 'Verify & Sync')), + ), + FilledButton.tonal( + key: const ValueKey('settings-account-mfa-cancel-button'), + onPressed: accountBusy ? null : _cancelAccountMfa, + child: Text(appText('返回编辑', 'Back to Edit')), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildSignedInAccountCard( + BuildContext context, + SettingsSnapshot currentSettings, + AccountSessionSummary? accountSession, + AccountSyncState? accountState, + bool accountBusy, + bool accountSignedIn, + ) { + final cloudSync = currentSettings.acpBridgeServerModeConfig.cloudSynced; + final serviceUrl = cloudSync.accountBaseUrl.trim().isNotEmpty + ? cloudSync.accountBaseUrl.trim() + : currentSettings.accountBaseUrl.trim(); + final accountIdentifier = cloudSync.accountIdentifier.trim().isNotEmpty + ? cloudSync.accountIdentifier.trim() + : currentSettings.accountUsername.trim().isNotEmpty + ? currentSettings.accountUsername.trim() + : (accountSession?.email.trim() ?? ''); + final mfaEnabled = + accountSession?.totpEnabled == true || + accountSession?.mfaEnabled == true; + final syncScope = accountState?.profileScope.trim().isNotEmpty == true + ? accountState!.profileScope.trim() + : appText('待同步', 'Pending sync'); + final sessionLabel = appText( + '已登录:${accountSession?.email.trim().isNotEmpty == true ? accountSession!.email.trim() : appText('当前账号', 'Current account')}', + 'Signed in: ${accountSession?.email.trim().isNotEmpty == true ? accountSession!.email.trim() : appText('Current account', 'Current account')}', + ); + final syncLabel = accountState == null + ? appText('idle · 尚未同步远程配置', 'idle · Remote config not synced yet') + : '${accountState.syncState} · ${accountState.syncMessage}'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + accountSession?.email.trim().isNotEmpty == true + ? accountSession!.email.trim() + : appText('本地操作员', 'Local Operator'), + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + appText( + '这里继续只负责账号身份、MFA 与云端默认配置同步状态。设置页面主体层级保持不变,连接来源和覆盖策略仍在下方标签内管理。', + 'This card now owns identity, MFA, and cloud-default sync state while keeping the surrounding settings hierarchy unchanged.', + ), + ), + const SizedBox(height: 14), + Text(sessionLabel, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 4), + Text(syncLabel, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('登录状态摘要', 'Login Status Summary'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Text( + '${appText('服务地址', 'Service URL')}: ${serviceUrl.isEmpty ? appText('待配置', 'Pending') : serviceUrl}', + key: const ValueKey('settings-account-summary-service-url'), + ), + const SizedBox(height: 6), + Text( + '${appText('账户标识', 'Account Identifier')}: ${accountIdentifier.isEmpty ? appText('待登录', 'Not signed in') : accountIdentifier}', + key: const ValueKey( + 'settings-account-summary-account-identifier', + ), + ), + const SizedBox(height: 6), + Text( + '${appText('最近同步', 'Last Sync')}: ${_formatSyncTime(cloudSync.lastSyncAt)}', + key: const ValueKey('settings-account-summary-last-sync'), + ), + const SizedBox(height: 6), + Text( + '${appText('MFA 状态', 'MFA Status')}: ${mfaEnabled ? appText('已启用', 'Enabled') : appText('未启用', 'Disabled')}', + key: const ValueKey('settings-account-summary-mfa-status'), + ), + const SizedBox(height: 6), + Text( + '${appText('同步范围', 'Sync Scope')}: $syncScope', + key: const ValueKey('settings-account-summary-sync-scope'), + ), + const SizedBox(height: 6), + _buildTokenConfiguredSummary(accountState), + ], + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.tonal( + key: const ValueKey('settings-account-sync-button'), + onPressed: accountBusy + ? null + : () => _syncAccount(currentSettings), + child: Text(appText('重新同步', 'Sync Again')), + ), + FilledButton.tonal( + key: const ValueKey('settings-account-logout-button'), + onPressed: accountBusy || !accountSignedIn + ? null + : _logoutAccount, + child: Text(appText('退出登录', 'Log Out')), + ), + ], + ), + ], + ); } @override @@ -73,30 +471,17 @@ class _SettingsPageState extends State { ]), builder: (context, _) { final currentSettings = controller.settings; - final settingsDraft = controller.settingsDraft; + _syncAccountControllers(currentSettings); final accountState = controller.settingsController.accountSyncState; final accountBusy = controller.settingsController.accountBusy; final accountSignedIn = controller.settingsController.accountSignedIn; + final accountMfaRequired = + controller.settingsController.accountMfaRequired; final accountSession = controller.settingsController.accountSession; final cloudSync = currentSettings.acpBridgeServerModeConfig.cloudSynced; final remoteSummary = cloudSync.remoteServerSummary.endpoint.trim(); - final serviceUrl = cloudSync.accountBaseUrl.trim().isNotEmpty - ? cloudSync.accountBaseUrl.trim() - : currentSettings.accountBaseUrl.trim(); - final accountIdentifier = cloudSync.accountIdentifier.trim().isNotEmpty - ? cloudSync.accountIdentifier.trim() - : currentSettings.accountUsername.trim().isNotEmpty - ? currentSettings.accountUsername.trim() - : (accountSession?.email.trim() ?? ''); - final sessionLabel = accountSignedIn - ? appText( - '已登录:${accountSession?.email.trim().isNotEmpty == true ? accountSession!.email.trim() : appText('当前账号', 'Current account')}', - 'Signed in: ${accountSession?.email.trim().isNotEmpty == true ? accountSession!.email.trim() : appText('Current account', 'Current account')}', - ) - : appText('未登录', 'Signed out'); - final syncLabel = accountState == null - ? appText('idle · 尚未同步远程配置', 'idle · Remote config not synced yet') - : '${accountState.syncState} · ${accountState.syncMessage}'; + final accountSignedOutLoginMode = + !accountSignedIn && !accountMfaRequired; return SettingsPageBodyShell( padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), @@ -143,96 +528,26 @@ class _SettingsPageState extends State { if (_integrationTab == _SettingsIntegrationTab.accountStatus) SurfaceCard( key: const ValueKey('settings-account-status-card'), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - accountSession?.email.trim().isNotEmpty == true - ? accountSession!.email.trim() - : appText('本地操作员', 'Local Operator'), - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - Text( - appText( - '这里仅描述认证状态本身:登录、MFA、同步状态与当前账户身份。默认连接来源和高级覆盖在下面分别配置。', - 'Only authentication state is shown here: sign-in, MFA, sync state, and current account identity.', + child: accountSignedOutLoginMode + ? _buildSignedOutAccountCard( + context, + currentSettings, + accountBusy, + ) + : accountMfaRequired + ? _buildPendingMfaAccountCard( + context, + currentSettings, + accountBusy, + ) + : _buildSignedInAccountCard( + context, + currentSettings, + accountSession, + accountState, + accountBusy, + accountSignedIn, ), - ), - const SizedBox(height: 14), - Text( - sessionLabel, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 4), - Text( - syncLabel, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 16), - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - appText('登录状态摘要', 'Login Status Summary'), - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - Text( - '${appText('服务地址', 'Service URL')}: ${serviceUrl.isEmpty ? appText('待配置', 'Pending') : serviceUrl}', - key: const ValueKey( - 'settings-account-summary-service-url', - ), - ), - const SizedBox(height: 6), - Text( - '${appText('账户标识', 'Account Identifier')}: ${accountIdentifier.isEmpty ? appText('待登录', 'Not signed in') : accountIdentifier}', - key: const ValueKey( - 'settings-account-summary-account-identifier', - ), - ), - const SizedBox(height: 6), - Text( - '${appText('最近同步', 'Last Sync')}: ${_formatSyncTime(cloudSync.lastSyncAt)}', - key: const ValueKey( - 'settings-account-summary-last-sync', - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - FilledButton.tonal( - key: const ValueKey('settings-account-sync-button'), - onPressed: accountBusy - ? null - : () => _syncAccount(currentSettings), - child: Text(appText('重新同步', 'Sync Again')), - ), - FilledButton.tonal( - key: const ValueKey('settings-account-logout-button'), - onPressed: accountBusy || !accountSignedIn - ? null - : _logoutAccount, - child: Text(appText('退出登录', 'Log Out')), - ), - ], - ), - ], - ), ) else SurfaceCard( @@ -302,7 +617,7 @@ class _SettingsPageState extends State { key: const ValueKey('settings-base-sync-button'), onPressed: accountBusy ? null - : () => _syncAccount(settingsDraft), + : () => _syncAccount(currentSettings), child: Text(appText('重新同步', 'Sync Again')), ), FilledButton.tonal( @@ -311,7 +626,7 @@ class _SettingsPageState extends State { ), onPressed: accountBusy ? null - : () => _disconnectManagedBase(settingsDraft), + : () => _disconnectManagedBase(currentSettings), child: Text(appText('断开', 'Disconnect')), ), ], diff --git a/lib/models/app_models.dart b/lib/models/app_models.dart index 012b1d6d..55afd0b8 100644 --- a/lib/models/app_models.dart +++ b/lib/models/app_models.dart @@ -437,16 +437,6 @@ class SettingsNavigationContext { final bool? prefersGatewaySetupCode; } -enum AccountTab { profile, workspace, sessions } - -extension AccountTabCopy on AccountTab { - String get label => switch (this) { - AccountTab.profile => appText('资料', 'Profile'), - AccountTab.workspace => appText('工作区', 'Workspace'), - AccountTab.sessions => appText('会话', 'Sessions'), - }; -} - class QuickAction { const QuickAction({ required this.title, diff --git a/lib/runtime/account_runtime_client.dart b/lib/runtime/account_runtime_client.dart index aeb15542..23d917de 100644 --- a/lib/runtime/account_runtime_client.dart +++ b/lib/runtime/account_runtime_client.dart @@ -250,6 +250,9 @@ class AccountRuntimeClient { AccountSessionSummary _accountSessionSummaryFromUserJson( Map user, ) { + final mfa = _asMap(user['mfa']); + final totpEnabled = mfa['totpEnabled'] as bool? ?? false; + final totpPending = mfa['totpPending'] as bool? ?? false; return AccountSessionSummary( userId: _stringValue(user['id']), email: _stringValue(user['email']), @@ -257,7 +260,9 @@ class AccountRuntimeClient { ? _stringValue(user['name']) : _stringValue(user['username']), role: _stringValue(user['role']), - mfaEnabled: user['mfaEnabled'] as bool? ?? false, + mfaEnabled: user['mfaEnabled'] as bool? ?? totpEnabled, + totpEnabled: totpEnabled, + totpPending: totpPending, ); } diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index 07e371c3..6bf0925c 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -132,15 +132,7 @@ Future completeAccountSignInSettingsInternal( return; } final user = _asMap(payload['user']); - final sessionSummary = AccountSessionSummary( - userId: _stringValue(user['id']), - email: _stringValue(user['email']), - name: _stringValue(user['name']).isNotEmpty - ? _stringValue(user['name']) - : _stringValue(user['username']), - role: _stringValue(user['role']), - mfaEnabled: user['mfaEnabled'] == true, - ); + final sessionSummary = _accountSessionSummaryFromUserPayload(user); await controller.storeInternal.saveAccountSessionToken(token); await controller.storeInternal.saveAccountSessionExpiresAtMs( _parseExpiresAtMs(payload['expiresAt']), @@ -517,6 +509,25 @@ Future cancelAccountMfaChallengeSettingsInternal( controller.notifyListeners(); } +AccountSessionSummary _accountSessionSummaryFromUserPayload( + Map user, +) { + final mfa = _asMap(user['mfa']); + final totpEnabled = mfa['totpEnabled'] as bool? ?? false; + final totpPending = mfa['totpPending'] as bool? ?? false; + return AccountSessionSummary( + userId: _stringValue(user['id']), + email: _stringValue(user['email']), + name: _stringValue(user['name']).isNotEmpty + ? _stringValue(user['name']) + : _stringValue(user['username']), + role: _stringValue(user['role']), + mfaEnabled: user['mfaEnabled'] as bool? ?? totpEnabled, + totpEnabled: totpEnabled, + totpPending: totpPending, + ); +} + String normalizeAccountBaseUrlSettingsInternal( String raw, { String fallback = '', diff --git a/lib/runtime/runtime_models_account.dart b/lib/runtime/runtime_models_account.dart index 9853c674..789c79ca 100644 --- a/lib/runtime/runtime_models_account.dart +++ b/lib/runtime/runtime_models_account.dart @@ -8,6 +8,8 @@ class AccountSessionSummary { required this.name, required this.role, required this.mfaEnabled, + this.totpEnabled = false, + this.totpPending = false, }); final String userId; @@ -15,6 +17,8 @@ class AccountSessionSummary { final String name; final String role; final bool mfaEnabled; + final bool totpEnabled; + final bool totpPending; AccountSessionSummary copyWith({ String? userId, @@ -22,6 +26,8 @@ class AccountSessionSummary { String? name, String? role, bool? mfaEnabled, + bool? totpEnabled, + bool? totpPending, }) { return AccountSessionSummary( userId: userId ?? this.userId, @@ -29,6 +35,8 @@ class AccountSessionSummary { name: name ?? this.name, role: role ?? this.role, mfaEnabled: mfaEnabled ?? this.mfaEnabled, + totpEnabled: totpEnabled ?? this.totpEnabled, + totpPending: totpPending ?? this.totpPending, ); } @@ -39,6 +47,8 @@ class AccountSessionSummary { 'name': name, 'role': role, 'mfaEnabled': mfaEnabled, + 'totpEnabled': totpEnabled, + 'totpPending': totpPending, }; } @@ -49,6 +59,8 @@ class AccountSessionSummary { name: json['name'] as String? ?? '', role: json['role'] as String? ?? '', mfaEnabled: json['mfaEnabled'] as bool? ?? false, + totpEnabled: json['totpEnabled'] as bool? ?? false, + totpPending: json['totpPending'] as bool? ?? false, ); } } diff --git a/test/features/settings/goldens/settings_page_account_status_canonical.png b/test/features/settings/goldens/settings_page_account_status_canonical.png index c40e018d955c26accdb1f6f53b68453df926ce1a..c2c03fe38c1e113471b49ca71950472333ca645c 100644 GIT binary patch literal 27677 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 a5G13%j>TOuHnT!|vbAc0cHq)5U5 literal 28373 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®yDJ2Wf3bO<b53qYPY{298`X? zbr*nqjpv9bddB;|a`w}Lp3Lb+LUEIVao3!1o}_IP%rNrju6<2hF)nZjvzjT_ypamU zm*?B!99SlxaV*W~ytv_ITbj@?6gIgse)Ku6BaKv3_C)KIvuOOUs**5mdVq5mY1jL4 zINR$+AIQMCRFXsKUEa|km&Cdmrx=jVGA}5yt6U;IQ8E=fsZm?8DVfsfV;ar|L{Gcb zev#tZY`}o)@895e?t1?wM=j?bu^#e$6`DKvL~AC^<*?wxLW37`1nIzAl`YadP zZNrs|y}Cu8wb?SaJ#-SL6vs;wQPdCgGKa>wDFy)+Rs2 zPqt7poAuCz$jF*{#9m3gvg1J6)wW4Ox;633(a?Oz7J55qw(AoTbx;}UsVI+j%+8cu z)|*YiQQ_e7#HAlBP~V4h=C5C^5+?iX(w9T0HX2S#{C;sDzVN%u&askTYVh=Y_%Euu zeunMO*Z=G?Kh=P4ELspE^>c~^?0ke4c>m0ulmg2|5T@3tx_qz)Ow{4?l1SItldXH_ zL4}D)n3+_fZ5IxB5WQmu$2NZ_Gf8nJpB5^>If}+Rr(>LJ7UavHXq7S=AZA}VU$Zm& zl4h(8`c;(}Rx@63A&jC4E8!&*w>0rSxGS5&N;iXah^Jz*V491TcJ;)r);QIrT)rU- zbF=ex2~~JH(d*QfdV@OmTezByyg22MLHh%ill6R zV8o_1rIZ@gcprFlf|h*zx9H)&2ba%ukJF0mFqtC;g{fUGHIA6eQdPvv8UoCFpl8#% z6|}MKRD4yvnI(R-K$bzhRLbh+)}N}Iu*39Dy2t;@fB`-l{vS&8Y*>ERke^OtnEQ*< zTJ_&F;s5`{jT^iIrhjVzezp5wZI&zD?_5(Hvs^)C>lW%EZhz;6+_%;rfvFrQq+lzy zNxyW|sz$rfB2mIJ*vPYRHW+=5AQ%j0y=!VZd*K9JT2k6v*z5>kp`@gxO=bYeR(E)P z=TLcge$wgH{G^&!tl)(TC-LUah#ujalDwKZMbE@&g{3Mu@2Tn@UAdFCp!tAq;exQ! zBjdBVR1X%PI@qMGZmpG6H2G~4gzsNIST8SV@|-;mA*{7w2y20>Fv7y|5eSa0-)e9j z-9gvS9yJ{}H-ndGX(gbmV^h=;1ozO|^nK{uT|68zNL5WdCdA*r6|ysDUyG&zV~e@a z;1KlI12M*^<(3Q{1$gNL*m-UwtJkrU(pb+E?4066vYsRmV@p4?XhA;luL&Cv)vy0!UPG&#h zsp+fuSb^il&4G}Enho!9(w)520^un)mGZiPP~W!cmwdfBsRS>U55ub}g4%X@gHbAW z-j@^Qr-(X*H)&}_Yc^N%ofEjDbR65fw1dOaC&{qaI}aG`r0px@tg9Sf5Livze=SP| zsbo%osn?MyFtL^JDU}~fwJ~WdGeelIG}{yGikEf?CS>P zl)n}Fk<^*rS-Q7A6l}llDJ-2=`2EmV+OS_=0LjA>8<%AVO*~1>R0MBpW(UZvqkG4g zC55squv;{OQYqx;3X;s+S2NKl6QXmjvGs`I2K48T`Iqt7MQV8E?7m6*&x3Q+z zX|A6@$F+k$um9rqqpb#gSf=m%?(vJtAji4UL7G-#J=M6yp0!oR_44Cm5L6a-`iIx- zoRQy;kG?*WA~KaJHFmg>J@eB8b^t)O(!lA76cX{M{wd8j)N!_YW+dhb`FxB&)_GqK z5>vf7m?e1RU$NY+<{SKFiiyctsnX;pzYX~)({Qd>=Y6kP%JQ4`TB@vKMpcfdZ7G;u zPzwS4i7n5!!i5cMN+n zG2t|`+E;metmtwhRwcV&H630#zW&}0r}Qpj2tBp0m#1rImwQ~dJIJnm)x++!w<;;C z3CFO3g7XM6@pOcic&VV^cf{aUs~e&!EMBk2kCInxcl?eh!fTzW$Xc%`17z6fvN}h zyJrS`dVh?BgN~-S{KLd4nZQaN#>7Axk$TlN#QwcGB+d`vYvt)y*0_vD1Y<&Lp+5_! z*Ih%~$`%9`w#7~b#(4cdC7=I{j^6nn=JQ{I&a|JP)3p z>OT$qGjxs(^}%}Ge>kXZlI8cKcPu0}$Ag>^V{YQu{>1I`+ zvMC{4aHjp$?oNPd3fHDiy|1dr8y>)F0p85|PsD7l0qjlk zm=ZKvew04I$DCe0d3uhEL@^oL1^GeOjGfP1nfqO$;O73+Y+XcJG)6u{6i-i950N`m$^WyEzv$einSOVYol&}~@`r(##X z{=)EKrPRhcf-sG+teyq#C#K30CKZ48^!8I-2%x$uc%EA~RAWQl7L$Vm&xZC@iy2JU z3z{YYvCpeNgB{!q`{!it$IXVG}4Uj=`C&TS4+HdqM{N& zkrgznY=DqiO}P;~BP}zEMaExANvRnXr+Ngeb|INpT9UEfcs_Q3!%yu0$To6iXZL5R z=UqF*D*QqefDoU-v`R293hwNexS6y-?nv(Xeu?~#k+b-EB`o0V??CWBXZrpPto*wr zeP6tpeBF;oj+-YRw?eFe3gys?0eDv{!UQJO%3IsFx%HZBdVfN|_;$QwR8}{T za(L57mo~pH%zTqBr0!AwK;bPG+^wLzNBwX8jF^Xuk@DPa`>5dOR(4wJrJ@DxuKp~+ zS}mL9mHdYEwB-01UfeGaej>zN9-xlHs}0|GsWrQwap&b0+VuK9%Id@NRD3hZ45_9( z6&4q7T0{$c2U@sRusEl8I)OM6r{?=bQ67TTd@n!D66er;;5*vGPWNvNI*l-3fN~?m zfe;CHb_eqpQ}=Sld5=yWXb<{d@3bCaBk^WXIE;FI!_Le)OPJb9blRnIt~h_aM4anUIgYX5KTU3plN+q$RiJ=NV-o-)fa!=siBy3LdZC9RyA%*u9V z4z$%Y5r;0{F_`UC1-=erPX2OJBX-*-8^E2k=td(e0QauKhbnH-*w*HLj{PTQY zbio>Zajj*kpju9FhX~Bzlv@DzfgV$|brO*vQxdv0$$b0!7v z=Q`zI7l}TS!JGZKELTEQBiLwwEuZ0vO(yN^orrtp& z8kS~`O*Ff2FEi?*BLRWYqOUQL@QcTJP1&4v*Bvt_+GmUG+ZI_#rjt^yW_iRtLWalF z?=rhHEv;7q<-x|0FgNdT*M4FDfmz|(`()NOqxR7nM45u+4 z$?NHtUc`jm4K1L)NimUo2|ohQ_PXl~&E$0C`<#*O+^QfqtdyJnK$reXD3(yx5{gh^ zCPu*_L|8=1SbUK*#wictxeUXCn;~S@;?9lR;12Ko2N)!Ck3{*QYuh zGi}VHo`x7@1^ds0FrS=;-0#a+o=F-t8pgXbAuW}Sb(n-n%&>^0XKZ|}U|b)~LCqz= zcox~|7+Fmv-t7O`1~V?AOyRK}VF9}0Hc=Be zc)}nKC?;<&1HT;4exOiNXuWx}Jd8-Ja`E=&RcP2PZaP#Tx6VE1=R>X?%k2;HBqs8v z4~QU-6|MV~*!82V$my~MfmfvGP%ybzp7LUT6g&1}dzNF(rt5?R0TtG?uz<);x4CG1 z=eFc=X~37o>z|bT1x5UxQ0Xt^YUR1p@P!XwhrV)m8y~lfq9>G&4uPS{bnog*#5?uK z7DiKGb~=y%?jFRJU$V;8+u|{B5b)W0UAN72tzvp(V}WF(r1TU#cCrE&J9){ft#zTp z9jXI|P^E$J^N}hq|K{fM6`I?FxY?ey<_g3bJ#a^*s}s05bo!a~@MD~GXg70t`p(kU z{~6`KJBshqC2@`wPF#};U1MJsr`Q-Z;CC^eLKy#?5Qd&qqL@|FfSm-?T@O*hPW#<_ zkxSOng+X39s(hf&r-ApbKTOaW9li8br@GlHi{DiQ(dE}}Z?6-ZjTDpE#ouX<$GsBr^v6_*_J={Zn%M(!dgFKhjKHsDazf;Q?VXj zBw>ZFM&L?BI)*{;K$k;bcU-c3qacT?Z_j>{i=^G36@;E@2_(^K_r^4A1P4IQ4eqHna9<~#z;S?qciYcoe zAGkwRqG;P;U0SaSW{rj_8Tj^c!C2Ze51k-(MykVBMVch9@7b7@U{6>i?(zI7us3K< zFp+^{E>nC6`o^L{XCvSUKj0_g>G754OaGdq^gtljWolQ4jdj*c4Dq)+k}wEr`u%n8 zqaTV*>SpjjG9SuRD94Alh48`8oN%Qo-VMhaedl#z%1F3@Z)yB}K%g^;nwvL2$@7?@ zR}*}NY7m7Jp*O9<9z|bYf_s;#`uVj#P;JAYWMj+$c<9~H|EwPkvu(ip(x0XhN_~m; zy1@Y>>3&Y3NQo`UQ=~=%y8kB?im32~rUgX(QHWkj`ND@R18tys;i)xzA zRlmWq487xeP6ckb-uQLE!q3(2+p%Wpi$C_7nZ*L6o-t>ozm5D&u=9T&r^ zE*In|2u9jk9$u0`ylE-uO$>D|sU5vXx=@^i$l3+o;bwAfb3{i#U0mZz;*M(w)@iGL zss`HMe6RvuHSlPS>_N*4Nb)TrVe6VR_>|=Dxx@~S9t?5TGP>PGYtEQ81^St-mlYlC%}P@kV=hF0EPu}ImHbI~l_uCHKKZ~N+1uOB z@22-(%Cm*(>~jQdNr+NQ0+@4v(JAR<{hB;gb;J0zSEX%VwIKwcv+ZZqooUzgS+9fx zp+`$o?=DKaeOjMn)^r<727B~iiL>qJ&Z$IbD+BE(7AJu0j02=?pW~IJJ&$cM08&rH zgACpL1;7l*Zn-9az}=Ow^H^vbM!QWzz5VOq#v2=?opF^nblQ@l0fP<7O<}|(vX=sL zbQB}BZyQWeQ>G21eJZjWuEoa#U=aFX(5ELn*SzlG8SfK?c`Z|~`OMdXN=4k~3BpZxQH;rQ526(p$0Mh~ zq~j{uw*x8Hjb#n>G+lS9^d`Oac9`2fTrO@Saw&#*#IcNng|DC7XqbGVh(R@uz7&+ zbN4c=;irEISJ=@fkm^ANCT{)7a{+75WLi1^2g>!L=fZ}Mjc%XNP|u?YkBN)K_5?u` zoAUKt$n9e2^v|rikELtSFMkRSg&&P!J>Px6vSb%%VGCc+{~ug>%>VRC-U zI_O8d!3b)~_tDwr`rkZ9?5PRqCWwfy`+*eQdI*#MC1?KKJ<)^4K$w7P0w1D+MVNq@a1CukKX{Z0qSll+>UL=p>>PQL=l%8 zFq+k9glCGZGs67=UcV-})~$5yvxD>XNEmOZ^8QDuc^~+7YdL??U-(vdW8-1laTKdV zYEUO3$?BkI);fmey=s29!YQ2&(%x51_phjKgq96Z#UNYkuY0{`rvI9te;aV!`yDGt z+dS$Wa{I`=FB1tbMjJFZmzKB2IlvAAULjR5Fcn28h~r7VIl1gclnkUa3Y5S_L+n3whBubvDN5TTg%4|)Ylt-dAGpG z7>h*iPaCdMaSfLWz0XbKhsReMewbdMEAmLjY~`5F;y|9A5%$!!2_k!7gQ3hulC1$i zqN9sp+!@jBTm$<446=oe@luq#f&jkvf2*O=TsO*^A<-Mk7j%p*o<*lt~p=B3r31uC01+_PA*fa{V zV&%FC(EzJO@)OK=9Jiiy%e@Zz-+p+u) z=Gh@JLf0y3aF`~o6u#N4L(&NLNH^Gdr@Ml-GZ=Ij0^tzfkFi5M3}>Xo92Bu-da>)h z1`nu}_ls4K_NEFafipEw=Xq3#a!1aMCdK*i@WV(`orTwGTEnB zokv9Gr!s~jWsCim$xu=*eR6MAv>3TS9^)d+o~vNxG}ZU+Z83I;VSKn&|J?Q2-(}G_ z{y*$eEQ{$@>TuhO7h=V27FEv?S54*J+{Q&NJ#g@DuOoXccXHyqWxGuJ)$o5n(%2XpOU27BEuDqcU#keLBJeLppnT^G6A);Vz2rLr{>4LCz5OZz zs|frNfe7*N?p<3XudH7d4{SvK@x9gdDgvtrtRk?Az$yZ(2&^KoiohxY|BeWhf$k*m VKL%g&tdxxItocQ=veP&3{SQ5gx6}Xt diff --git a/test/features/settings/settings_page_core_test.dart b/test/features/settings/settings_page_core_test.dart index b3a335d6..ec84c740 100644 --- a/test/features/settings/settings_page_core_test.dart +++ b/test/features/settings/settings_page_core_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller_desktop_core.dart'; import 'package:xworkmate/features/settings/settings_page.dart'; import 'package:xworkmate/i18n/app_language.dart'; -import 'package:xworkmate/models/app_models.dart'; import 'package:xworkmate/runtime/runtime_controllers_settings.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; @@ -13,10 +12,77 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Settings page account status', () { + testWidgets('reads canonical login form values instead of a stale draft', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1600, 1200)); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + final fixtures = _buildSettingsPageFixtures( + seed: _SettingsAccountSeed.signedOut, + ); + final controller = fixtures.controller; + final canonicalSettings = fixtures.canonicalSettings; + + final staleDraft = canonicalSettings.copyWith( + accountBaseUrl: 'https://draft-accounts.svc.plus', + accountUsername: 'draft@svc.plus', + ); + await controller.saveSettingsDraft(staleDraft); + + await tester.pumpWidget(_buildSettingsPageApp(controller)); + await tester.pump(const Duration(milliseconds: 300)); + + final baseUrlField = tester.widget( + find.byKey(const ValueKey('settings-account-base-url-field')), + ); + final identifierField = tester.widget( + find.byKey(const ValueKey('settings-account-identifier-field')), + ); + + expect(baseUrlField.controller?.text, 'https://accounts.svc.plus'); + expect( + baseUrlField.controller?.text, + isNot('https://draft-accounts.svc.plus'), + ); + expect(identifierField.controller?.text, 'canonical@svc.plus'); + expect(identifierField.controller?.text, isNot('draft@svc.plus')); + }); + + testWidgets('renders MFA verification controls in the settings card', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1600, 1200)); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + final fixtures = _buildSettingsPageFixtures( + seed: _SettingsAccountSeed.mfaRequired, + ); + final controller = fixtures.controller; + + await tester.pumpWidget(_buildSettingsPageApp(controller)); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + find.byKey(const ValueKey('settings-account-mfa-code-field')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('settings-account-mfa-verify-button')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('settings-account-mfa-cancel-button')), + findsOneWidget, + ); + }); + testWidgets( 'reads canonical settings instead of a stale draft and syncs from the active account URL', (tester) async { - final fixtures = _buildSettingsPageFixtures(); + await tester.binding.setSurfaceSize(const Size(1600, 1200)); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + final fixtures = _buildSettingsPageFixtures( + seed: _SettingsAccountSeed.signedIn, + ); final controller = fixtures.controller; final canonicalSettings = fixtures.canonicalSettings; @@ -41,23 +107,8 @@ void main() { ), ); await controller.saveSettingsDraft(staleDraft); - expect(controller.settings.accountBaseUrl, 'https://accounts.svc.plus'); - expect(controller.settingsController.accountSignedIn, isTrue); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(platform: TargetPlatform.macOS), - home: Scaffold( - body: RepaintBoundary( - key: const ValueKey('settings-page-boundary'), - child: SizedBox( - width: 1600, - height: 1200, - child: SettingsPage(controller: controller), - ), - ), - ), - ), - ); + + await tester.pumpWidget(_buildSettingsPageApp(controller)); await tester.pump(const Duration(milliseconds: 300)); final serviceUrlText = tester.widget( @@ -68,9 +119,6 @@ void main() { const ValueKey('settings-account-summary-account-identifier'), ), ); - final syncButton = tester.widget( - find.byKey(const ValueKey('settings-account-sync-button')), - ); final serviceUrlTextContent = serviceUrlText.data ?? serviceUrlText.textSpan?.toPlainText() ?? ''; @@ -86,7 +134,6 @@ void main() { ); expect(accountIdentifierTextContent, contains('canonical@svc.plus')); expect(accountIdentifierTextContent, isNot(contains('draft@svc.plus'))); - expect(syncButton.onPressed, isNotNull); await controller.settingsController.syncAccountSettings( baseUrl: controller.settings.accountBaseUrl, @@ -105,37 +152,24 @@ void main() { await controller.settingsController.logoutAccount(); await tester.pump(); - expect(find.text('未登录'), findsOneWidget); - final loggedOutButton = tester.widget( - find.byKey(const ValueKey('settings-account-logout-button')), + expect( + find.byKey(const ValueKey('settings-account-login-button')), + findsOneWidget, ); - expect(loggedOutButton.onPressed, isNull); }, ); - testWidgets('renders the signed-in account status card consistently', ( + testWidgets('renders the signed-out login card consistently', ( tester, ) async { - final fixtures = _buildSettingsPageFixtures(); - final controller = fixtures.controller; await tester.binding.setSurfaceSize(const Size(1600, 1200)); addTearDown(() async => tester.binding.setSurfaceSize(null)); - - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(platform: TargetPlatform.macOS), - home: Scaffold( - body: RepaintBoundary( - key: const ValueKey('settings-page-boundary'), - child: SizedBox( - width: 1600, - height: 1200, - child: SettingsPage(controller: controller), - ), - ), - ), - ), + final fixtures = _buildSettingsPageFixtures( + seed: _SettingsAccountSeed.signedOut, ); + final controller = fixtures.controller; + + await tester.pumpWidget(_buildSettingsPageApp(controller)); await tester.pump(const Duration(milliseconds: 300)); await expectLater( @@ -146,6 +180,22 @@ void main() { }); } +Widget _buildSettingsPageApp(_FakeSettingsPageController controller) { + return MaterialApp( + theme: AppTheme.light(platform: TargetPlatform.macOS), + home: Scaffold( + body: RepaintBoundary( + key: const ValueKey('settings-page-boundary'), + child: SizedBox( + width: 1600, + height: 1200, + child: SettingsPage(controller: controller), + ), + ), + ), + ); +} + SettingsSnapshot _buildCanonicalSettings() { final defaults = SettingsSnapshot.defaults(); return defaults.copyWith( @@ -166,12 +216,23 @@ SettingsSnapshot _buildCanonicalSettings() { ); } -_SettingsPageFixtures _buildSettingsPageFixtures() { +enum _SettingsAccountSeed { signedOut, mfaRequired, signedIn } + +_SettingsPageFixtures _buildSettingsPageFixtures({ + required _SettingsAccountSeed seed, +}) { final canonicalSettings = _buildCanonicalSettings().copyWith( appLanguage: AppLanguage.zh, ); - final settingsController = _FakeSettingsController() - ..seedSignedInState(canonicalSettings); + final settingsController = _FakeSettingsController(); + switch (seed) { + case _SettingsAccountSeed.signedOut: + settingsController.seedSignedOutState(canonicalSettings); + case _SettingsAccountSeed.mfaRequired: + settingsController.seedMfaRequiredState(canonicalSettings); + case _SettingsAccountSeed.signedIn: + settingsController.seedSignedInState(canonicalSettings); + } final controller = _FakeSettingsPageController( settingsController: settingsController, settingsDraft: canonicalSettings, @@ -219,22 +280,6 @@ class _FakeSettingsPageController extends ChangeNotifier notifyListeners(); } - Future saveSettings(SettingsSnapshot snapshot) async { - settingsController.snapshotInternal = snapshot; - _settingsDraft = snapshot; - notifyListeners(); - } - - @override - void navigateHome() {} - - @override - void openSettings({ - SettingsTab tab = SettingsTab.gateway, - SettingsDetailPage? detail, - SettingsNavigationContext? navigationContext, - }) {} - @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -245,6 +290,30 @@ class _FakeSettingsController extends SettingsController { final List syncedBaseUrls = []; + void seedSignedOutState(SettingsSnapshot settings) { + snapshotInternal = settings.copyWith(accountLocalMode: true); + lastSnapshotJsonInternal = snapshotInternal.toJsonString(); + accountSessionTokenInternal = ''; + accountSessionInternal = null; + accountSyncStateInternal = null; + accountStatusInternal = 'Signed out'; + accountBusyInternal = false; + pendingAccountMfaTicketInternal = ''; + pendingAccountBaseUrlInternal = ''; + } + + void seedMfaRequiredState(SettingsSnapshot settings) { + snapshotInternal = settings.copyWith(accountLocalMode: true); + lastSnapshotJsonInternal = snapshotInternal.toJsonString(); + accountSessionTokenInternal = ''; + accountSessionInternal = null; + accountSyncStateInternal = null; + accountStatusInternal = 'MFA required'; + accountBusyInternal = false; + pendingAccountMfaTicketInternal = 'pending-ticket'; + pendingAccountBaseUrlInternal = settings.accountBaseUrl; + } + void seedSignedInState(SettingsSnapshot settings) { snapshotInternal = settings; lastSnapshotJsonInternal = settings.toJsonString(); @@ -261,6 +330,12 @@ class _FakeSettingsController extends SettingsController { syncMessage: 'Remote defaults synced', lastSyncAtMs: 123456789, lastSyncSource: 'https://accounts.svc.plus', + profileScope: 'tenant-shared', + tokenConfigured: const AccountTokenConfigured( + openclaw: true, + vault: false, + apisix: true, + ), syncedDefaults: AccountRemoteProfile.defaults().copyWith( openclawUrl: 'wss://gateway.svc.plus', apisixUrl: 'https://apisix.svc.plus', @@ -281,6 +356,12 @@ class _FakeSettingsController extends SettingsController { syncMessage: 'Remote defaults synced', lastSyncAtMs: 123456789, lastSyncSource: baseUrl, + profileScope: 'tenant-shared', + tokenConfigured: const AccountTokenConfigured( + openclaw: true, + vault: false, + apisix: true, + ), syncedDefaults: AccountRemoteProfile.defaults().copyWith( openclawUrl: 'wss://gateway.svc.plus', apisixUrl: 'https://apisix.svc.plus', diff --git a/test/runtime/settings_account_auth_flow_test.dart b/test/runtime/settings_account_auth_flow_test.dart new file mode 100644 index 00000000..23d4abbd --- /dev/null +++ b/test/runtime/settings_account_auth_flow_test.dart @@ -0,0 +1,235 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/account_runtime_client.dart'; +import 'package:xworkmate/runtime/runtime_controllers.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SettingsController account auth flow', () { + test( + 'login persists session summary and synced profile metadata', + () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-account-auth-login-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => root.path, + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + final controller = SettingsController( + store, + accountClientFactory: (_) => _SuccessfulAccountRuntimeClient(), + ); + addTearDown(() async { + controller.dispose(); + store.dispose(); + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + await store.initialize(); + await controller.initialize(); + await controller.saveSnapshot( + controller.snapshot.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + ), + ); + + await controller.loginAccount( + baseUrl: 'https://accounts.svc.plus', + identifier: 'review@svc.plus', + password: '***REMOVED-CREDENTIAL***', + ); + + expect(controller.accountSignedIn, isTrue); + expect(controller.accountStatus, 'Signed in as review@svc.plus'); + expect(controller.accountSession?.email, 'review@svc.plus'); + expect(controller.accountSession?.totpEnabled, isTrue); + expect(controller.accountSession?.totpPending, isFalse); + expect(controller.accountSyncState?.syncState, 'ready'); + expect(controller.accountSyncState?.profileScope, 'tenant-shared'); + expect(controller.accountSyncState?.tokenConfigured.apisix, isTrue); + expect(await store.loadAccountSessionToken(), 'session-token'); + }, + ); + + test('mfa challenge transitions to verified signed-in session', () async { + final root = await Directory.systemTemp.createTemp( + 'xworkmate-account-auth-mfa-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => root.path, + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + final client = _MfaAccountRuntimeClient(); + final controller = SettingsController( + store, + accountClientFactory: (_) => client, + ); + addTearDown(() async { + controller.dispose(); + store.dispose(); + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + await store.initialize(); + await controller.initialize(); + await controller.saveSnapshot( + controller.snapshot.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'review@svc.plus', + ), + ); + + await controller.loginAccount( + baseUrl: 'https://accounts.svc.plus', + identifier: 'review@svc.plus', + password: '***REMOVED-CREDENTIAL***', + ); + + expect(controller.accountSignedIn, isFalse); + expect(controller.accountMfaRequired, isTrue); + expect(controller.accountStatus, 'MFA required'); + + await controller.verifyAccountMfa( + baseUrl: 'https://accounts.svc.plus', + code: '123456', + ); + + expect(client.lastVerifiedCode, '123456'); + expect(controller.accountSignedIn, isTrue); + expect(controller.accountMfaRequired, isFalse); + expect(controller.accountSession?.email, 'review@svc.plus'); + expect(controller.accountSyncState?.syncState, 'ready'); + }); + }); +} + +class _SuccessfulAccountRuntimeClient extends AccountRuntimeClient { + _SuccessfulAccountRuntimeClient() + : super(baseUrl: 'https://accounts.svc.plus'); + + @override + Future> login({ + required String identifier, + required String password, + }) async { + expect(identifier, 'review@svc.plus'); + expect(password, '***REMOVED-CREDENTIAL***'); + return { + 'token': 'session-token', + 'expiresAt': '2026-04-12T00:00:00Z', + 'user': { + 'id': 'u-1', + 'email': 'review@svc.plus', + 'name': 'Review', + 'role': 'readonly', + 'mfaEnabled': true, + 'mfa': {'totpEnabled': true, 'totpPending': false}, + }, + }; + } + + @override + Future loadSession({required String token}) async { + expect(token, 'session-token'); + return const AccountSessionSummary( + userId: 'u-1', + email: 'review@svc.plus', + name: 'Review', + role: 'readonly', + mfaEnabled: true, + totpEnabled: true, + totpPending: false, + ); + } + + @override + Future loadProfile({required String token}) async { + expect(token, 'session-token'); + return AccountProfileResponse( + profile: AccountRemoteProfile.defaults().copyWith( + apisixUrl: 'https://apisix.svc.plus', + ), + profileScope: 'tenant-shared', + tokenConfigured: const AccountTokenConfigured( + openclaw: true, + vault: false, + apisix: true, + ), + ); + } +} + +class _MfaAccountRuntimeClient extends AccountRuntimeClient { + _MfaAccountRuntimeClient() : super(baseUrl: 'https://accounts.svc.plus'); + + String lastVerifiedCode = ''; + + @override + Future> login({ + required String identifier, + required String password, + }) async { + return {'mfaRequired': true, 'mfaTicket': 'ticket-123'}; + } + + @override + Future> verifyMfa({ + required String mfaToken, + required String code, + }) async { + expect(mfaToken, 'ticket-123'); + lastVerifiedCode = code; + return { + 'token': 'session-token', + 'expiresAt': '2026-04-12T00:00:00Z', + 'user': { + 'id': 'u-1', + 'email': 'review@svc.plus', + 'name': 'Review', + 'role': 'readonly', + 'mfaEnabled': true, + 'mfa': {'totpEnabled': true, 'totpPending': false}, + }, + }; + } + + @override + Future loadSession({required String token}) async { + return const AccountSessionSummary( + userId: 'u-1', + email: 'review@svc.plus', + name: 'Review', + role: 'readonly', + mfaEnabled: true, + totpEnabled: true, + totpPending: false, + ); + } + + @override + Future loadProfile({required String token}) async { + return AccountProfileResponse( + profile: AccountRemoteProfile.defaults(), + profileScope: 'tenant-shared', + tokenConfigured: const AccountTokenConfigured( + openclaw: true, + vault: false, + apisix: true, + ), + ); + } +}