Refactor settings account login flow
This commit is contained in:
parent
be7331fa3d
commit
073967ee78
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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')),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 = '',
|
||||
|
||||
@ -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 |
@ -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',
|
||||
|
||||
235
test/runtime/settings_account_auth_flow_test.dart
Normal file
235
test/runtime/settings_account_auth_flow_test.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user