Refactor settings account login flow

This commit is contained in:
Haitao Pan 2026-04-11 12:45:14 +08:00
parent be7331fa3d
commit 073967ee78
10 changed files with 863 additions and 893 deletions

View File

@ -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<Text>(
find.byKey(const ValueKey('settings-account-summary-service-url')),
final baseUrlField = tester.widget<TextFormField>(
find.byKey(const ValueKey('settings-account-base-url-field')),
);
final accountIdentifierText = tester.widget<Text>(
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<TextFormField>(
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<FilledButton>(
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<void> 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<String> syncedBaseUrls = <String>[];
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<AccountSyncResult> 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<void> logoutAccount() async {
accountSessionTokenInternal = '';
accountSessionInternal = null;
accountSyncStateInternal = null;
accountStatusInternal = 'Signed out';
accountBusyInternal = false;
pendingAccountMfaTicketInternal = '';
pendingAccountBaseUrlInternal = '';
notifyListeners();
}
}

View File

@ -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<AccountPage> createState() => _AccountPageState();
}
class _AccountPageState extends State<AccountPage> {
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<void> _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<void> _saveWorkspace(SettingsSnapshot settings) async {
final nextSettings = settings.copyWith(
accountWorkspace: _accountWorkspaceController.text.trim(),
);
await widget.controller.saveSettings(nextSettings);
_lastSavedAccountWorkspace = nextSettings.accountWorkspace;
}
Future<void> _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<void> _verifyAccountMfa() async {
try {
await widget.controller.settingsController.verifyAccountMfa(
baseUrl: _accountBaseUrlController.text.trim(),
code: _accountMfaCodeController.text.trim(),
);
} finally {
_accountMfaCodeController.clear();
}
}
Future<void> _syncAccountSettings(SettingsSnapshot settings) async {
await _saveProfile(settings);
await widget.controller.settingsController.syncAccountSettings(
baseUrl: _accountBaseUrlController.text.trim(),
);
}
Future<void> _logoutAccount() async {
await widget.controller.settingsController.logoutAccount();
_accountPasswordController.clear();
_accountMfaCodeController.clear();
}
Future<void> _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(<Listenable>[
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')),
],
),
),
),
),
],
),
);
},
);
}
}

View File

@ -25,29 +25,115 @@ class SettingsPage extends StatefulWidget {
final SettingsTab initialTab;
final SettingsDetailPage? initialDetail;
final SettingsNavigationContext? navigationContext;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
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<void> _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<void> _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<void> _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<void> _syncAccount(SettingsSnapshot settings) async {
await _saveAccountProfile(settings);
await widget.controller.settingsController.syncAccountSettings(
baseUrl: _accountBaseUrlController.text.trim(),
);
}
Future<void> _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<void> _cancelAccountMfa() async {
await widget.controller.settingsController.cancelAccountMfaChallenge();
_accountPasswordController.clear();
_accountMfaCodeController.clear();
}
Future<void> _logoutAccount() async {
await widget.controller.settingsController.logoutAccount();
_accountPasswordController.clear();
_accountMfaCodeController.clear();
}
Future<void> _disconnectManagedBase(SettingsSnapshot settings) async {
@ -60,7 +146,319 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
);
await widget.controller.saveSettings(nextSettings);
await widget.controller.settingsController.saveSnapshot(nextSettings);
}
Widget _buildTokenConfiguredSummary(AccountSyncState? accountState) {
final configured = <String>[
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<SettingsPage> {
]),
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<SettingsPage> {
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<SettingsPage> {
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<SettingsPage> {
),
onPressed: accountBusy
? null
: () => _disconnectManagedBase(settingsDraft),
: () => _disconnectManagedBase(currentSettings),
child: Text(appText('断开', 'Disconnect')),
),
],

View File

@ -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,

View File

@ -250,6 +250,9 @@ class AccountRuntimeClient {
AccountSessionSummary _accountSessionSummaryFromUserJson(
Map<String, dynamic> 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,
);
}

View File

@ -132,15 +132,7 @@ Future<void> 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<void> cancelAccountMfaChallengeSettingsInternal(
controller.notifyListeners();
}
AccountSessionSummary _accountSessionSummaryFromUserPayload(
Map<String, dynamic> 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 = '',

View File

@ -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,
);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -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<TextFormField>(
find.byKey(const ValueKey('settings-account-base-url-field')),
);
final identifierField = tester.widget<TextFormField>(
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<Text>(
@ -68,9 +119,6 @@ void main() {
const ValueKey('settings-account-summary-account-identifier'),
),
);
final syncButton = tester.widget<FilledButton>(
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<FilledButton>(
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<void> 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<String> syncedBaseUrls = <String>[];
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',

View File

@ -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<Map<String, dynamic>> login({
required String identifier,
required String password,
}) async {
expect(identifier, 'review@svc.plus');
expect(password, '***REMOVED-CREDENTIAL***');
return <String, dynamic>{
'token': 'session-token',
'expiresAt': '2026-04-12T00:00:00Z',
'user': <String, dynamic>{
'id': 'u-1',
'email': 'review@svc.plus',
'name': 'Review',
'role': 'readonly',
'mfaEnabled': true,
'mfa': <String, dynamic>{'totpEnabled': true, 'totpPending': false},
},
};
}
@override
Future<AccountSessionSummary> 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<AccountProfileResponse> 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<Map<String, dynamic>> login({
required String identifier,
required String password,
}) async {
return <String, dynamic>{'mfaRequired': true, 'mfaTicket': 'ticket-123'};
}
@override
Future<Map<String, dynamic>> verifyMfa({
required String mfaToken,
required String code,
}) async {
expect(mfaToken, 'ticket-123');
lastVerifiedCode = code;
return <String, dynamic>{
'token': 'session-token',
'expiresAt': '2026-04-12T00:00:00Z',
'user': <String, dynamic>{
'id': 'u-1',
'email': 'review@svc.plus',
'name': 'Review',
'role': 'readonly',
'mfaEnabled': true,
'mfa': <String, dynamic>{'totpEnabled': true, 'totpPending': false},
},
};
}
@override
Future<AccountSessionSummary> 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<AccountProfileResponse> loadProfile({required String token}) async {
return AccountProfileResponse(
profile: AccountRemoteProfile.defaults(),
profileScope: 'tenant-shared',
tokenConfigured: const AccountTokenConfigured(
openclaw: true,
vault: false,
apisix: true,
),
);
}
}