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 c40e018d..c2c03fe3 100644 Binary files a/test/features/settings/goldens/settings_page_account_status_canonical.png and b/test/features/settings/goldens/settings_page_account_status_canonical.png differ 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, + ), + ); + } +}