neoagent 2.3.1-beta.4 → 2.3.1-beta.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +45 -0
- package/docs/capabilities.md +2 -2
- package/docs/configuration.md +12 -5
- package/docs/hardware.md +1 -1
- package/docs/integrations.md +2 -3
- package/flutter_app/.metadata +42 -0
- package/flutter_app/README.md +21 -0
- package/flutter_app/analysis_options.yaml +32 -0
- package/flutter_app/android/app/build.gradle.kts +109 -0
- package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
- package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
- package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
- package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
- package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
- package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
- package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
- package/flutter_app/android/build.gradle.kts +24 -0
- package/flutter_app/android/ci-release.keystore +0 -0
- package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
- package/flutter_app/android/gradle.properties +3 -0
- package/flutter_app/android/key.properties +4 -0
- package/flutter_app/android/settings.gradle.kts +26 -0
- package/flutter_app/assets/branding/app_icon_1024.png +0 -0
- package/flutter_app/assets/branding/app_icon_128.png +0 -0
- package/flutter_app/assets/branding/app_icon_192.png +0 -0
- package/flutter_app/assets/branding/app_icon_256.png +0 -0
- package/flutter_app/assets/branding/app_icon_32.png +0 -0
- package/flutter_app/assets/branding/app_icon_512.png +0 -0
- package/flutter_app/assets/branding/app_icon_64.png +0 -0
- package/flutter_app/assets/branding/tray_icon_template.png +0 -0
- package/flutter_app/lib/features/location/location_service.dart +119 -0
- package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
- package/flutter_app/lib/main.dart +99 -0
- package/flutter_app/lib/main_account_settings.dart +1250 -0
- package/flutter_app/lib/main_admin.dart +886 -0
- package/flutter_app/lib/main_app_shell.dart +1682 -0
- package/flutter_app/lib/main_chat.dart +3352 -0
- package/flutter_app/lib/main_controller.dart +6781 -0
- package/flutter_app/lib/main_devices.dart +2301 -0
- package/flutter_app/lib/main_integrations.dart +1129 -0
- package/flutter_app/lib/main_launcher.dart +959 -0
- package/flutter_app/lib/main_launcher_entry.dart +5 -0
- package/flutter_app/lib/main_models.dart +3546 -0
- package/flutter_app/lib/main_navigation.dart +193 -0
- package/flutter_app/lib/main_operations.dart +4851 -0
- package/flutter_app/lib/main_recordings.dart +870 -0
- package/flutter_app/lib/main_runtime.dart +806 -0
- package/flutter_app/lib/main_settings.dart +2024 -0
- package/flutter_app/lib/main_shared.dart +2861 -0
- package/flutter_app/lib/main_theme.dart +204 -0
- package/flutter_app/lib/main_voice_assistant.dart +957 -0
- package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
- package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
- package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
- package/flutter_app/lib/src/android_app_installer.dart +22 -0
- package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
- package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
- package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
- package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
- package/flutter_app/lib/src/app_release_updater.dart +511 -0
- package/flutter_app/lib/src/backend_client.dart +1833 -0
- package/flutter_app/lib/src/desktop_companion.dart +2 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
- package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
- package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
- package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
- package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
- package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
- package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
- package/flutter_app/lib/src/health_bridge.dart +136 -0
- package/flutter_app/lib/src/live_voice_capture.dart +85 -0
- package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
- package/flutter_app/lib/src/network/app_http_client.dart +53 -0
- package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
- package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
- package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
- package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
- package/flutter_app/lib/src/oauth_launcher.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
- package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
- package/flutter_app/lib/src/recording_bridge.dart +232 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
- package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
- package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
- package/flutter_app/lib/src/recording_payloads.dart +86 -0
- package/flutter_app/lib/src/theme/palette.dart +81 -0
- package/flutter_app/lib/src/widget_bridge.dart +49 -0
- package/flutter_app/linux/CMakeLists.txt +128 -0
- package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
- package/flutter_app/linux/runner/CMakeLists.txt +26 -0
- package/flutter_app/linux/runner/main.cc +6 -0
- package/flutter_app/linux/runner/my_application.cc +144 -0
- package/flutter_app/linux/runner/my_application.h +18 -0
- package/flutter_app/linux/runner/resources/app_icon.png +0 -0
- package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
- package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
- package/flutter_app/macos/Podfile +42 -0
- package/flutter_app/macos/Podfile.lock +87 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
- package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
- package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
- package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
- package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
- package/flutter_app/macos/Runner/Info.plist +36 -0
- package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
- package/flutter_app/macos/Runner/Release.entitlements +12 -0
- package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
- package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
- package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
- package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
- package/flutter_app/patch_strings.py +12 -0
- package/flutter_app/pubspec.lock +1088 -0
- package/flutter_app/pubspec.yaml +53 -0
- package/flutter_app/test/messaging_access_summary_test.dart +22 -0
- package/flutter_app/test/recording_payloads_test.dart +53 -0
- package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
- package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
- package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
- package/flutter_app/tool/generate_desktop_branding.py +219 -0
- package/flutter_app/web/favicon.png +0 -0
- package/flutter_app/web/favicon.svg +12 -0
- package/flutter_app/web/icons/Icon-192.png +0 -0
- package/flutter_app/web/icons/Icon-512.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
- package/flutter_app/web/index.html +39 -0
- package/flutter_app/web/manifest.json +35 -0
- package/flutter_app/windows/CMakeLists.txt +108 -0
- package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
- package/flutter_app/windows/runner/CMakeLists.txt +41 -0
- package/flutter_app/windows/runner/Runner.rc +121 -0
- package/flutter_app/windows/runner/flutter_window.cpp +533 -0
- package/flutter_app/windows/runner/flutter_window.h +37 -0
- package/flutter_app/windows/runner/main.cpp +53 -0
- package/flutter_app/windows/runner/resource.h +16 -0
- package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
- package/flutter_app/windows/runner/runner.exe.manifest +14 -0
- package/flutter_app/windows/runner/utils.cpp +65 -0
- package/flutter_app/windows/runner/utils.h +19 -0
- package/flutter_app/windows/runner/win32_window.cpp +299 -0
- package/flutter_app/windows/runner/win32_window.h +102 -0
- package/lib/install_helpers.js +31 -0
- package/lib/manager.js +227 -6
- package/package.json +3 -1
- package/server/db/database.js +110 -0
- package/server/http/middleware.js +55 -2
- package/server/http/routes.js +1 -0
- package/server/index.js +3 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/canvaskit/wimp.wasm +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +74324 -73132
- package/server/routes/integrations.js +108 -1
- package/server/routes/memory.js +11 -2
- package/server/{http/routes → routes}/screenHistory.js +2 -2
- package/server/routes/settings.js +75 -2
- package/server/{http/routes → routes}/triggers.js +2 -2
- package/server/routes/wearable.js +67 -0
- package/server/services/ai/models.js +30 -0
- package/server/services/ai/providers/githubCopilot.js +97 -0
- package/server/services/ai/providers/openai.js +2 -1
- package/server/services/ai/providers/openaiCodex.js +31 -0
- package/server/services/ai/settings.js +20 -0
- package/server/services/ai/toolSelector.js +14 -1
- package/server/services/ai/tools.js +77 -4
- package/server/services/desktop/screenRecorder.js +65 -9
- package/server/services/integrations/env.js +5 -0
- package/server/services/integrations/figma/provider.js +1 -0
- package/server/services/integrations/github/common.js +106 -0
- package/server/services/integrations/github/provider.js +499 -0
- package/server/services/integrations/github/repos.js +1124 -0
- package/server/services/integrations/google/provider.js +1 -0
- package/server/services/integrations/home_assistant/provider.js +325 -26
- package/server/services/integrations/manager.js +88 -12
- package/server/services/integrations/microsoft/provider.js +1 -0
- package/server/services/integrations/oauth_provider.js +25 -8
- package/server/services/integrations/provider_config_store.js +85 -0
- package/server/services/integrations/registry.js +4 -0
- package/server/services/integrations/spotify/provider.js +1 -0
- package/server/services/integrations/trello/provider.js +842 -0
- package/server/services/manager.js +46 -1
- package/server/services/mcp/client.js +120 -23
- package/server/services/memory/manager.js +39 -2
- package/server/services/messaging/access_policy.js +10 -0
- package/server/services/messaging/manager.js +49 -0
- package/server/services/messaging/meshtastic.js +260 -0
- package/server/services/messaging/meshtastic_env.js +100 -0
- package/server/services/messaging/meshtastic_protocol.js +476 -0
- package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
- package/server/services/tasks/runtime.js +1 -1
- package/server/services/voice/openaiClient.js +4 -1
- package/server/services/voice/openaiSpeech.js +6 -1
- package/server/services/voice/providers.js +52 -12
- package/server/services/voice/runtimeManager.js +136 -19
- package/server/services/voice/turnRunner.js +29 -9
- package/server/services/wearable/firmware_manifest.js +370 -0
- package/server/services/wearable/gateway.js +350 -0
- package/server/services/wearable/protocol.js +45 -0
- package/server/services/wearable/service.js +244 -0
- package/server/utils/local_secrets.js +56 -0
- package/server/utils/logger.js +37 -9
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
part of 'main.dart';
|
|
2
|
+
|
|
3
|
+
class _PasswordStrengthInfo {
|
|
4
|
+
const _PasswordStrengthInfo({
|
|
5
|
+
required this.score,
|
|
6
|
+
required this.label,
|
|
7
|
+
required this.message,
|
|
8
|
+
required this.color,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
final int score;
|
|
12
|
+
final String label;
|
|
13
|
+
final String message;
|
|
14
|
+
final Color color;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_PasswordStrengthInfo _passwordStrengthInfo({
|
|
18
|
+
required String password,
|
|
19
|
+
String username = '',
|
|
20
|
+
String email = '',
|
|
21
|
+
}) {
|
|
22
|
+
final value = password.trim();
|
|
23
|
+
if (value.isEmpty) {
|
|
24
|
+
return _PasswordStrengthInfo(
|
|
25
|
+
score: 0,
|
|
26
|
+
label: 'Empty',
|
|
27
|
+
message: 'Use 8+ characters. Longer passphrases work well.',
|
|
28
|
+
color: _borderLight,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
final lower = RegExp(r'[a-z]').hasMatch(value);
|
|
32
|
+
final upper = RegExp(r'[A-Z]').hasMatch(value);
|
|
33
|
+
final digits = RegExp(r'[0-9]').hasMatch(value);
|
|
34
|
+
final symbols = RegExp(r'[^A-Za-z0-9]').hasMatch(value);
|
|
35
|
+
final variety = <bool>[
|
|
36
|
+
lower,
|
|
37
|
+
upper,
|
|
38
|
+
digits,
|
|
39
|
+
symbols,
|
|
40
|
+
].where((item) => item).length;
|
|
41
|
+
final normalized = value.toLowerCase();
|
|
42
|
+
final userHints = <String>{
|
|
43
|
+
username.trim().toLowerCase(),
|
|
44
|
+
email.trim().toLowerCase(),
|
|
45
|
+
email.trim().toLowerCase().split('@').first,
|
|
46
|
+
}.where((item) => item.length >= 3);
|
|
47
|
+
final containsUserInfo = userHints.any(normalized.contains);
|
|
48
|
+
final obviousPattern =
|
|
49
|
+
RegExp(r'(.)\1\1').hasMatch(value) ||
|
|
50
|
+
normalized.contains('password') ||
|
|
51
|
+
normalized.contains('1234') ||
|
|
52
|
+
normalized.contains('qwerty');
|
|
53
|
+
|
|
54
|
+
var score = 0;
|
|
55
|
+
if (value.length >= 8) score += 1;
|
|
56
|
+
if (value.length >= 12) score += 1;
|
|
57
|
+
if (variety >= 3) score += 1;
|
|
58
|
+
if (variety == 4 || value.length >= 16) score += 1;
|
|
59
|
+
if (containsUserInfo || obviousPattern) score -= 1;
|
|
60
|
+
score = score.clamp(0, 4);
|
|
61
|
+
|
|
62
|
+
if (value.length < 8) {
|
|
63
|
+
return _PasswordStrengthInfo(
|
|
64
|
+
score: 1,
|
|
65
|
+
label: 'Weak',
|
|
66
|
+
message: 'Use at least 8 characters.',
|
|
67
|
+
color: _danger,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (containsUserInfo) {
|
|
71
|
+
return _PasswordStrengthInfo(
|
|
72
|
+
score: 2,
|
|
73
|
+
label: 'Fair',
|
|
74
|
+
message: 'Do not include your username or email.',
|
|
75
|
+
color: _warning,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (obviousPattern) {
|
|
79
|
+
return _PasswordStrengthInfo(
|
|
80
|
+
score: 2,
|
|
81
|
+
label: 'Fair',
|
|
82
|
+
message: 'Avoid repeated characters and obvious sequences.',
|
|
83
|
+
color: _warning,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (score >= 4) {
|
|
87
|
+
return _PasswordStrengthInfo(
|
|
88
|
+
score: 4,
|
|
89
|
+
label: 'Strong',
|
|
90
|
+
message: 'Strong password.',
|
|
91
|
+
color: _success,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (score >= 3) {
|
|
95
|
+
return _PasswordStrengthInfo(
|
|
96
|
+
score: 3,
|
|
97
|
+
label: 'Good',
|
|
98
|
+
message: 'Good password. A little more length makes it stronger.',
|
|
99
|
+
color: _success,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return _PasswordStrengthInfo(
|
|
103
|
+
score: 2,
|
|
104
|
+
label: 'Fair',
|
|
105
|
+
message: 'Add more length or another character type.',
|
|
106
|
+
color: _warning,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
class _PasswordStrengthIndicator extends StatelessWidget {
|
|
111
|
+
const _PasswordStrengthIndicator({required this.info});
|
|
112
|
+
|
|
113
|
+
final _PasswordStrengthInfo info;
|
|
114
|
+
|
|
115
|
+
@override
|
|
116
|
+
Widget build(BuildContext context) {
|
|
117
|
+
return Column(
|
|
118
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
119
|
+
children: <Widget>[
|
|
120
|
+
Row(
|
|
121
|
+
children: <Widget>[
|
|
122
|
+
Text(
|
|
123
|
+
'Password strength: ${info.label}',
|
|
124
|
+
style: TextStyle(color: info.color, fontWeight: FontWeight.w600),
|
|
125
|
+
),
|
|
126
|
+
const SizedBox(width: 10),
|
|
127
|
+
Expanded(
|
|
128
|
+
child: ClipRRect(
|
|
129
|
+
borderRadius: BorderRadius.circular(999),
|
|
130
|
+
child: LinearProgressIndicator(
|
|
131
|
+
minHeight: 8,
|
|
132
|
+
value: info.score / 4,
|
|
133
|
+
backgroundColor: _borderLight,
|
|
134
|
+
valueColor: AlwaysStoppedAnimation<Color>(info.color),
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
],
|
|
139
|
+
),
|
|
140
|
+
const SizedBox(height: 6),
|
|
141
|
+
Text(
|
|
142
|
+
info.message,
|
|
143
|
+
style: TextStyle(color: _textSecondary, fontSize: 12, height: 1.35),
|
|
144
|
+
),
|
|
145
|
+
],
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
enum AccountSettingsTab { account, security }
|
|
151
|
+
|
|
152
|
+
class AccountSettingsPanel extends StatefulWidget {
|
|
153
|
+
const AccountSettingsPanel({super.key, required this.controller});
|
|
154
|
+
|
|
155
|
+
final NeoAgentController controller;
|
|
156
|
+
|
|
157
|
+
@override
|
|
158
|
+
State<AccountSettingsPanel> createState() => _AccountSettingsPanelState();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
|
|
162
|
+
AccountSettingsTab _selectedTab = AccountSettingsTab.account;
|
|
163
|
+
late final TextEditingController _emailController;
|
|
164
|
+
late final TextEditingController _emailPasswordController;
|
|
165
|
+
late final TextEditingController _setupPasswordController;
|
|
166
|
+
late final TextEditingController _setupCodeController;
|
|
167
|
+
late final TextEditingController _disablePasswordController;
|
|
168
|
+
late final TextEditingController _disableCodeController;
|
|
169
|
+
late final TextEditingController _currentPasswordController;
|
|
170
|
+
late final TextEditingController _newPasswordController;
|
|
171
|
+
late final TextEditingController _confirmNewPasswordController;
|
|
172
|
+
Map<String, dynamic>? _pendingSetup;
|
|
173
|
+
List<String> _recoveryCodes = const <String>[];
|
|
174
|
+
String? _emailSuccessMessage;
|
|
175
|
+
String? _emailInlineError;
|
|
176
|
+
String? _passwordSuccessMessage;
|
|
177
|
+
String? _passwordInlineError;
|
|
178
|
+
|
|
179
|
+
@override
|
|
180
|
+
void initState() {
|
|
181
|
+
super.initState();
|
|
182
|
+
_emailController = TextEditingController(
|
|
183
|
+
text: widget.controller.user?['email']?.toString() ?? '',
|
|
184
|
+
);
|
|
185
|
+
_emailPasswordController = TextEditingController();
|
|
186
|
+
_setupPasswordController = TextEditingController();
|
|
187
|
+
_setupCodeController = TextEditingController();
|
|
188
|
+
_disablePasswordController = TextEditingController();
|
|
189
|
+
_disableCodeController = TextEditingController();
|
|
190
|
+
_currentPasswordController = TextEditingController();
|
|
191
|
+
_newPasswordController = TextEditingController();
|
|
192
|
+
_confirmNewPasswordController = TextEditingController();
|
|
193
|
+
unawaited(widget.controller.refreshAccountSettings());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@override
|
|
197
|
+
void didUpdateWidget(covariant AccountSettingsPanel oldWidget) {
|
|
198
|
+
super.didUpdateWidget(oldWidget);
|
|
199
|
+
final email = widget.controller.user?['email']?.toString() ?? '';
|
|
200
|
+
if (_emailController.text.isEmpty && email.isNotEmpty) {
|
|
201
|
+
_emailController.text = email;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@override
|
|
206
|
+
void dispose() {
|
|
207
|
+
_emailController.dispose();
|
|
208
|
+
_emailPasswordController.dispose();
|
|
209
|
+
_setupPasswordController.dispose();
|
|
210
|
+
_setupCodeController.dispose();
|
|
211
|
+
_disablePasswordController.dispose();
|
|
212
|
+
_disableCodeController.dispose();
|
|
213
|
+
_currentPasswordController.dispose();
|
|
214
|
+
_newPasswordController.dispose();
|
|
215
|
+
_confirmNewPasswordController.dispose();
|
|
216
|
+
super.dispose();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
bool get _supportsQrLoginApproval =>
|
|
220
|
+
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
|
221
|
+
|
|
222
|
+
Future<void> _startQrLoginApproval() async {
|
|
223
|
+
final scanned = await showDialog<String>(
|
|
224
|
+
context: context,
|
|
225
|
+
barrierDismissible: true,
|
|
226
|
+
builder: (dialogContext) => const _QrLoginScannerDialog(),
|
|
227
|
+
);
|
|
228
|
+
if (!mounted || scanned == null || scanned.trim().isEmpty) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
final payload = QrLoginScanPayload.tryParse(scanned);
|
|
233
|
+
if (payload == null) {
|
|
234
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
235
|
+
const SnackBar(
|
|
236
|
+
content: Text('That QR code is not a NeoAgent login request.'),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
final scannedBackend = widget.controller._normalizeBackendUrl(
|
|
243
|
+
payload.backendUrl,
|
|
244
|
+
);
|
|
245
|
+
final currentBackend = widget.controller._normalizeBackendUrl(
|
|
246
|
+
widget.controller.backendUrl,
|
|
247
|
+
);
|
|
248
|
+
if (scannedBackend != currentBackend) {
|
|
249
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
250
|
+
SnackBar(
|
|
251
|
+
content: Text(
|
|
252
|
+
'This code belongs to a different NeoAgent server: ${payload.backendUrl}',
|
|
253
|
+
),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
final preview = await widget.controller.resolveQrLoginApproval(payload);
|
|
261
|
+
if (!mounted) return;
|
|
262
|
+
final approved = await showDialog<bool>(
|
|
263
|
+
context: context,
|
|
264
|
+
builder: (dialogContext) {
|
|
265
|
+
return _QrLoginApprovalDialog(
|
|
266
|
+
preview: preview,
|
|
267
|
+
busy: widget.controller.isApprovingQrLogin,
|
|
268
|
+
);
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
if (approved != true || !mounted) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await widget.controller.approveQrLogin(payload);
|
|
275
|
+
if (!mounted) return;
|
|
276
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
277
|
+
SnackBar(
|
|
278
|
+
content: Text('Approved login for ${preview.requestedDevice.label}.'),
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
} catch (_) {
|
|
282
|
+
if (!mounted) return;
|
|
283
|
+
final message =
|
|
284
|
+
widget.controller.errorMessage ?? 'Could not approve QR login.';
|
|
285
|
+
ScaffoldMessenger.of(
|
|
286
|
+
context,
|
|
287
|
+
).showSnackBar(SnackBar(content: Text(message)));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
@override
|
|
292
|
+
Widget build(BuildContext context) {
|
|
293
|
+
final compact = MediaQuery.sizeOf(context).width < 860;
|
|
294
|
+
return ListView(
|
|
295
|
+
padding: _pagePadding(context),
|
|
296
|
+
children: <Widget>[
|
|
297
|
+
_PageTitle(
|
|
298
|
+
title: 'Account settings',
|
|
299
|
+
subtitle:
|
|
300
|
+
'Manage your account email, two-factor authentication, and active sessions.',
|
|
301
|
+
trailing: OutlinedButton.icon(
|
|
302
|
+
onPressed: widget.controller.isLoadingAccountSettings
|
|
303
|
+
? null
|
|
304
|
+
: widget.controller.refreshAccountSettings,
|
|
305
|
+
icon: widget.controller.isLoadingAccountSettings
|
|
306
|
+
? const SizedBox.square(
|
|
307
|
+
dimension: 16,
|
|
308
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
309
|
+
)
|
|
310
|
+
: Icon(Icons.refresh),
|
|
311
|
+
label: Text('Refresh'),
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
if (widget.controller.errorMessage != null) ...<Widget>[
|
|
315
|
+
_InlineError(message: widget.controller.errorMessage!),
|
|
316
|
+
const SizedBox(height: 16),
|
|
317
|
+
],
|
|
318
|
+
if (compact)
|
|
319
|
+
_AccountSettingsTabs(
|
|
320
|
+
selected: _selectedTab,
|
|
321
|
+
onSelected: (value) => setState(() => _selectedTab = value),
|
|
322
|
+
)
|
|
323
|
+
else
|
|
324
|
+
const SizedBox.shrink(),
|
|
325
|
+
if (compact) const SizedBox(height: 16),
|
|
326
|
+
Card(
|
|
327
|
+
child: Padding(
|
|
328
|
+
padding: const EdgeInsets.all(20),
|
|
329
|
+
child: compact
|
|
330
|
+
? _buildSelectedPanel()
|
|
331
|
+
: Row(
|
|
332
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
333
|
+
children: <Widget>[
|
|
334
|
+
SizedBox(
|
|
335
|
+
width: 220,
|
|
336
|
+
child: _AccountSettingsTabs(
|
|
337
|
+
selected: _selectedTab,
|
|
338
|
+
onSelected: (value) =>
|
|
339
|
+
setState(() => _selectedTab = value),
|
|
340
|
+
vertical: true,
|
|
341
|
+
),
|
|
342
|
+
),
|
|
343
|
+
const SizedBox(width: 24),
|
|
344
|
+
Expanded(child: _buildSelectedPanel()),
|
|
345
|
+
],
|
|
346
|
+
),
|
|
347
|
+
),
|
|
348
|
+
),
|
|
349
|
+
],
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
Widget _buildSelectedPanel() {
|
|
354
|
+
switch (_selectedTab) {
|
|
355
|
+
case AccountSettingsTab.account:
|
|
356
|
+
return _buildAccountPanel();
|
|
357
|
+
case AccountSettingsTab.security:
|
|
358
|
+
return _buildSecurityPanel();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
Widget _buildAccountPanel() {
|
|
363
|
+
final controller = widget.controller;
|
|
364
|
+
final username = controller.user?['username']?.toString() ?? 'Account';
|
|
365
|
+
final currentEmail =
|
|
366
|
+
controller.user?['email']?.toString() ?? 'No email linked';
|
|
367
|
+
final hasPassword = controller.user?['hasPassword'] == true;
|
|
368
|
+
final availableProviders = controller.authProviders
|
|
369
|
+
.where((provider) => provider.configured)
|
|
370
|
+
.toList();
|
|
371
|
+
final linkedProviderKeys = controller.linkedAuthProviders
|
|
372
|
+
.map((provider) => provider.provider)
|
|
373
|
+
.toSet();
|
|
374
|
+
final linkableProviders = availableProviders
|
|
375
|
+
.where((provider) => !linkedProviderKeys.contains(provider.id))
|
|
376
|
+
.toList();
|
|
377
|
+
return Column(
|
|
378
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
379
|
+
children: <Widget>[
|
|
380
|
+
const _SectionTitle('Account'),
|
|
381
|
+
const SizedBox(height: 12),
|
|
382
|
+
_MetaPill(label: username, icon: Icons.person_outline),
|
|
383
|
+
const SizedBox(height: 18),
|
|
384
|
+
Text('Current email: $currentEmail'),
|
|
385
|
+
const SizedBox(height: 16),
|
|
386
|
+
TextField(
|
|
387
|
+
controller: _emailController,
|
|
388
|
+
keyboardType: TextInputType.emailAddress,
|
|
389
|
+
decoration: const InputDecoration(labelText: 'Email'),
|
|
390
|
+
),
|
|
391
|
+
const SizedBox(height: 12),
|
|
392
|
+
TextField(
|
|
393
|
+
controller: _emailPasswordController,
|
|
394
|
+
obscureText: true,
|
|
395
|
+
enabled: hasPassword,
|
|
396
|
+
decoration: InputDecoration(
|
|
397
|
+
labelText: 'Current password',
|
|
398
|
+
helperText: hasPassword
|
|
399
|
+
? 'Required to add or change your account email.'
|
|
400
|
+
: 'Create a password first to change your account email.',
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
if (_emailInlineError != null) ...<Widget>[
|
|
404
|
+
const SizedBox(height: 10),
|
|
405
|
+
_InlineError(message: _emailInlineError!),
|
|
406
|
+
],
|
|
407
|
+
if (_emailSuccessMessage != null) ...<Widget>[
|
|
408
|
+
const SizedBox(height: 10),
|
|
409
|
+
_InlineSuccess(message: _emailSuccessMessage!),
|
|
410
|
+
],
|
|
411
|
+
const SizedBox(height: 14),
|
|
412
|
+
FilledButton.icon(
|
|
413
|
+
onPressed: controller.isSavingAccountSettings || !hasPassword
|
|
414
|
+
? null
|
|
415
|
+
: () async {
|
|
416
|
+
setState(() {
|
|
417
|
+
_emailInlineError = null;
|
|
418
|
+
_emailSuccessMessage = null;
|
|
419
|
+
});
|
|
420
|
+
if (_emailPasswordController.text.trim().isEmpty) {
|
|
421
|
+
setState(() {
|
|
422
|
+
_emailInlineError =
|
|
423
|
+
'Enter your current password to save email changes.';
|
|
424
|
+
});
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
final trimmedEmail = _emailController.text.trim();
|
|
428
|
+
final saved = await controller.updateAccountEmail(
|
|
429
|
+
email: trimmedEmail,
|
|
430
|
+
currentPassword: _emailPasswordController.text,
|
|
431
|
+
);
|
|
432
|
+
if (saved && mounted) {
|
|
433
|
+
setState(() {
|
|
434
|
+
_emailPasswordController.clear();
|
|
435
|
+
_emailSuccessMessage =
|
|
436
|
+
'Email saved. If confirmation is required, check the new address for a NeoAgent confirmation link.';
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
icon: controller.isSavingAccountSettings
|
|
441
|
+
? const SizedBox.square(
|
|
442
|
+
dimension: 16,
|
|
443
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
444
|
+
)
|
|
445
|
+
: Icon(Icons.save_outlined),
|
|
446
|
+
label: Text('Save email'),
|
|
447
|
+
),
|
|
448
|
+
const SizedBox(height: 28),
|
|
449
|
+
Row(
|
|
450
|
+
children: <Widget>[
|
|
451
|
+
const Expanded(child: _SectionTitle('Linked sign-in providers')),
|
|
452
|
+
if (controller.linkedAuthProviders.isNotEmpty)
|
|
453
|
+
Text(
|
|
454
|
+
'${controller.linkedAuthProviders.length} linked',
|
|
455
|
+
style: TextStyle(color: _textSecondary),
|
|
456
|
+
),
|
|
457
|
+
],
|
|
458
|
+
),
|
|
459
|
+
const SizedBox(height: 12),
|
|
460
|
+
if (controller.linkedAuthProviders.isEmpty)
|
|
461
|
+
Text(
|
|
462
|
+
'No external sign-in providers linked.',
|
|
463
|
+
style: TextStyle(color: _textSecondary),
|
|
464
|
+
)
|
|
465
|
+
else
|
|
466
|
+
...controller.linkedAuthProviders.map(
|
|
467
|
+
(provider) => Card(
|
|
468
|
+
margin: const EdgeInsets.only(bottom: 12),
|
|
469
|
+
child: ListTile(
|
|
470
|
+
leading: provider.icon == 'google'
|
|
471
|
+
? const CircleAvatar(
|
|
472
|
+
backgroundColor: Color(0x1A4285F4),
|
|
473
|
+
child: Text(
|
|
474
|
+
'G',
|
|
475
|
+
style: TextStyle(
|
|
476
|
+
color: Color(0xFF4285F4),
|
|
477
|
+
fontWeight: FontWeight.w700,
|
|
478
|
+
),
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
: const CircleAvatar(child: Icon(Icons.link)),
|
|
482
|
+
title: Text(provider.label),
|
|
483
|
+
subtitle: Text(
|
|
484
|
+
provider.email.isNotEmpty
|
|
485
|
+
? '${provider.email}\nLast used: ${provider.lastUsedLabel}'
|
|
486
|
+
: 'Last used: ${provider.lastUsedLabel}',
|
|
487
|
+
),
|
|
488
|
+
isThreeLine: provider.email.isNotEmpty,
|
|
489
|
+
trailing: TextButton(
|
|
490
|
+
onPressed:
|
|
491
|
+
controller.isSavingAccountSettings || !provider.canUnlink
|
|
492
|
+
? null
|
|
493
|
+
: () => controller.unlinkAccountProvider(provider.id),
|
|
494
|
+
child: const Text('Unlink'),
|
|
495
|
+
),
|
|
496
|
+
),
|
|
497
|
+
),
|
|
498
|
+
),
|
|
499
|
+
if (linkableProviders.isNotEmpty) ...<Widget>[
|
|
500
|
+
const SizedBox(height: 8),
|
|
501
|
+
Wrap(
|
|
502
|
+
spacing: 10,
|
|
503
|
+
runSpacing: 10,
|
|
504
|
+
children: linkableProviders
|
|
505
|
+
.map(
|
|
506
|
+
(provider) => OutlinedButton.icon(
|
|
507
|
+
onPressed: controller.isSavingAccountSettings
|
|
508
|
+
? null
|
|
509
|
+
: () => controller.linkAccountProvider(provider.id),
|
|
510
|
+
icon: provider.icon == 'google'
|
|
511
|
+
? const Text(
|
|
512
|
+
'G',
|
|
513
|
+
style: TextStyle(
|
|
514
|
+
fontSize: 18,
|
|
515
|
+
fontWeight: FontWeight.w700,
|
|
516
|
+
color: Color(0xFF4285F4),
|
|
517
|
+
),
|
|
518
|
+
)
|
|
519
|
+
: const Icon(Icons.link),
|
|
520
|
+
label: Text('Link ${provider.label}'),
|
|
521
|
+
),
|
|
522
|
+
)
|
|
523
|
+
.toList(),
|
|
524
|
+
),
|
|
525
|
+
],
|
|
526
|
+
],
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
Widget _buildSecurityPanel() {
|
|
531
|
+
final controller = widget.controller;
|
|
532
|
+
final twoFactorEnabled = controller.accountTwoFactor['enabled'] == true;
|
|
533
|
+
final recoveryCount = _asInt(
|
|
534
|
+
controller.accountTwoFactor['recoveryCodesRemaining'],
|
|
535
|
+
);
|
|
536
|
+
return Column(
|
|
537
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
538
|
+
children: <Widget>[
|
|
539
|
+
if (_supportsQrLoginApproval) ...<Widget>[
|
|
540
|
+
Row(
|
|
541
|
+
children: <Widget>[
|
|
542
|
+
const Expanded(child: _SectionTitle('Approve QR login')),
|
|
543
|
+
_StatusPill(label: 'Android only', color: _accent),
|
|
544
|
+
],
|
|
545
|
+
),
|
|
546
|
+
const SizedBox(height: 12),
|
|
547
|
+
Text(
|
|
548
|
+
'Scan QR login requests from signed-out devices and approve them from this authenticated mobile session.',
|
|
549
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
550
|
+
),
|
|
551
|
+
const SizedBox(height: 14),
|
|
552
|
+
Container(
|
|
553
|
+
width: double.infinity,
|
|
554
|
+
padding: const EdgeInsets.all(18),
|
|
555
|
+
decoration: BoxDecoration(
|
|
556
|
+
gradient: LinearGradient(
|
|
557
|
+
begin: Alignment.topLeft,
|
|
558
|
+
end: Alignment.bottomRight,
|
|
559
|
+
colors: <Color>[
|
|
560
|
+
_accent.withValues(alpha: 0.16),
|
|
561
|
+
_success.withValues(alpha: 0.10),
|
|
562
|
+
],
|
|
563
|
+
),
|
|
564
|
+
borderRadius: BorderRadius.circular(18),
|
|
565
|
+
border: Border.all(color: _borderLight),
|
|
566
|
+
),
|
|
567
|
+
child: Wrap(
|
|
568
|
+
spacing: 12,
|
|
569
|
+
runSpacing: 12,
|
|
570
|
+
crossAxisAlignment: WrapCrossAlignment.center,
|
|
571
|
+
children: <Widget>[
|
|
572
|
+
FilledButton.icon(
|
|
573
|
+
onPressed: controller.isApprovingQrLogin
|
|
574
|
+
? null
|
|
575
|
+
: _startQrLoginApproval,
|
|
576
|
+
icon: controller.isApprovingQrLogin
|
|
577
|
+
? const SizedBox.square(
|
|
578
|
+
dimension: 16,
|
|
579
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
580
|
+
)
|
|
581
|
+
: const Icon(Icons.camera_alt_outlined),
|
|
582
|
+
label: const Text('Scan login QR'),
|
|
583
|
+
),
|
|
584
|
+
],
|
|
585
|
+
),
|
|
586
|
+
),
|
|
587
|
+
const SizedBox(height: 24),
|
|
588
|
+
],
|
|
589
|
+
_buildPasswordPanel(),
|
|
590
|
+
const SizedBox(height: 24),
|
|
591
|
+
Row(
|
|
592
|
+
children: <Widget>[
|
|
593
|
+
Expanded(child: _SectionTitle('Two-factor authentication')),
|
|
594
|
+
_StatusPill(
|
|
595
|
+
label: twoFactorEnabled ? 'Enabled' : 'Disabled',
|
|
596
|
+
color: twoFactorEnabled ? _success : _warning,
|
|
597
|
+
),
|
|
598
|
+
],
|
|
599
|
+
),
|
|
600
|
+
const SizedBox(height: 12),
|
|
601
|
+
Text(
|
|
602
|
+
twoFactorEnabled
|
|
603
|
+
? '$recoveryCount recovery codes are still available.'
|
|
604
|
+
: 'Use an authenticator app such as Authy, 1Password, or Google Authenticator.',
|
|
605
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
606
|
+
),
|
|
607
|
+
const SizedBox(height: 16),
|
|
608
|
+
if (!twoFactorEnabled) _buildEnableTwoFactorPanel(),
|
|
609
|
+
if (twoFactorEnabled) _buildDisableTwoFactorPanel(),
|
|
610
|
+
if (_recoveryCodes.isNotEmpty) ...<Widget>[
|
|
611
|
+
const SizedBox(height: 16),
|
|
612
|
+
_RecoveryCodesCard(codes: _recoveryCodes),
|
|
613
|
+
],
|
|
614
|
+
const SizedBox(height: 24),
|
|
615
|
+
Row(
|
|
616
|
+
children: <Widget>[
|
|
617
|
+
Expanded(child: _SectionTitle('Active sessions')),
|
|
618
|
+
Text(
|
|
619
|
+
'${controller.accountSessions.length} active',
|
|
620
|
+
style: TextStyle(color: _textSecondary),
|
|
621
|
+
),
|
|
622
|
+
],
|
|
623
|
+
),
|
|
624
|
+
const SizedBox(height: 12),
|
|
625
|
+
if (controller.accountSessions.isEmpty)
|
|
626
|
+
Text(
|
|
627
|
+
'No active sessions found.',
|
|
628
|
+
style: TextStyle(color: _textSecondary),
|
|
629
|
+
)
|
|
630
|
+
else
|
|
631
|
+
...controller.accountSessions.map(
|
|
632
|
+
(session) => _AccountSessionCard(
|
|
633
|
+
session: session,
|
|
634
|
+
busy: controller.isRevokingSession,
|
|
635
|
+
onRevoke: session.current
|
|
636
|
+
? null
|
|
637
|
+
: () => controller.revokeAccountSession(session.id),
|
|
638
|
+
),
|
|
639
|
+
),
|
|
640
|
+
],
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
Widget _buildPasswordPanel() {
|
|
645
|
+
final controller = widget.controller;
|
|
646
|
+
final hasPassword = controller.user?['hasPassword'] == true;
|
|
647
|
+
final strength = _passwordStrengthInfo(
|
|
648
|
+
password: _newPasswordController.text,
|
|
649
|
+
username: controller.user?['username']?.toString() ?? '',
|
|
650
|
+
email: controller.user?['email']?.toString() ?? '',
|
|
651
|
+
);
|
|
652
|
+
return Column(
|
|
653
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
654
|
+
children: <Widget>[
|
|
655
|
+
const _SectionTitle('Password'),
|
|
656
|
+
const SizedBox(height: 12),
|
|
657
|
+
if (hasPassword) ...<Widget>[
|
|
658
|
+
TextField(
|
|
659
|
+
controller: _currentPasswordController,
|
|
660
|
+
obscureText: true,
|
|
661
|
+
decoration: const InputDecoration(labelText: 'Current password'),
|
|
662
|
+
),
|
|
663
|
+
const SizedBox(height: 12),
|
|
664
|
+
] else ...<Widget>[
|
|
665
|
+
Text(
|
|
666
|
+
'No local password is set yet. Create one to enable username/password sign-in.',
|
|
667
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
668
|
+
),
|
|
669
|
+
const SizedBox(height: 12),
|
|
670
|
+
],
|
|
671
|
+
TextField(
|
|
672
|
+
controller: _newPasswordController,
|
|
673
|
+
onChanged: (_) => setState(() {}),
|
|
674
|
+
obscureText: true,
|
|
675
|
+
decoration: InputDecoration(
|
|
676
|
+
labelText: hasPassword ? 'New password' : 'Create password',
|
|
677
|
+
),
|
|
678
|
+
),
|
|
679
|
+
const SizedBox(height: 10),
|
|
680
|
+
_PasswordStrengthIndicator(info: strength),
|
|
681
|
+
const SizedBox(height: 12),
|
|
682
|
+
TextField(
|
|
683
|
+
controller: _confirmNewPasswordController,
|
|
684
|
+
obscureText: true,
|
|
685
|
+
decoration: InputDecoration(
|
|
686
|
+
labelText: hasPassword
|
|
687
|
+
? 'Confirm new password'
|
|
688
|
+
: 'Confirm password',
|
|
689
|
+
),
|
|
690
|
+
),
|
|
691
|
+
if (_passwordInlineError != null) ...<Widget>[
|
|
692
|
+
const SizedBox(height: 10),
|
|
693
|
+
_InlineError(message: _passwordInlineError!),
|
|
694
|
+
],
|
|
695
|
+
if (_passwordSuccessMessage != null) ...<Widget>[
|
|
696
|
+
const SizedBox(height: 10),
|
|
697
|
+
_InlineSuccess(message: _passwordSuccessMessage!),
|
|
698
|
+
],
|
|
699
|
+
const SizedBox(height: 14),
|
|
700
|
+
FilledButton.icon(
|
|
701
|
+
onPressed: controller.isSavingAccountSettings
|
|
702
|
+
? null
|
|
703
|
+
: () async {
|
|
704
|
+
setState(() {
|
|
705
|
+
_passwordInlineError = null;
|
|
706
|
+
_passwordSuccessMessage = null;
|
|
707
|
+
});
|
|
708
|
+
if (hasPassword && _currentPasswordController.text.isEmpty) {
|
|
709
|
+
setState(() {
|
|
710
|
+
_passwordInlineError =
|
|
711
|
+
'Enter your current password to change it.';
|
|
712
|
+
});
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (_newPasswordController.text.length < 8) {
|
|
716
|
+
setState(() {
|
|
717
|
+
_passwordInlineError =
|
|
718
|
+
'Use a new password with at least 8 characters.';
|
|
719
|
+
});
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (_newPasswordController.text !=
|
|
723
|
+
_confirmNewPasswordController.text) {
|
|
724
|
+
setState(() {
|
|
725
|
+
_passwordInlineError = 'New passwords do not match.';
|
|
726
|
+
});
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
final saved = await controller.updateAccountPassword(
|
|
730
|
+
currentPassword: _currentPasswordController.text,
|
|
731
|
+
newPassword: _newPasswordController.text,
|
|
732
|
+
);
|
|
733
|
+
if (saved && mounted) {
|
|
734
|
+
setState(() {
|
|
735
|
+
_currentPasswordController.clear();
|
|
736
|
+
_newPasswordController.clear();
|
|
737
|
+
_confirmNewPasswordController.clear();
|
|
738
|
+
_passwordSuccessMessage = hasPassword
|
|
739
|
+
? 'Password changed.'
|
|
740
|
+
: 'Password created.';
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
icon: controller.isSavingAccountSettings
|
|
745
|
+
? const SizedBox.square(
|
|
746
|
+
dimension: 16,
|
|
747
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
748
|
+
)
|
|
749
|
+
: Icon(Icons.password_outlined),
|
|
750
|
+
label: Text(hasPassword ? 'Change password' : 'Create password'),
|
|
751
|
+
),
|
|
752
|
+
],
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
Widget _buildEnableTwoFactorPanel() {
|
|
757
|
+
final setupUrl = _pendingSetup?['otpauthUrl']?.toString() ?? '';
|
|
758
|
+
final manualKey = _pendingSetup?['manualKey']?.toString() ?? '';
|
|
759
|
+
return Column(
|
|
760
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
761
|
+
children: <Widget>[
|
|
762
|
+
if (_pendingSetup == null) ...<Widget>[
|
|
763
|
+
TextField(
|
|
764
|
+
controller: _setupPasswordController,
|
|
765
|
+
obscureText: true,
|
|
766
|
+
decoration: const InputDecoration(labelText: 'Current password'),
|
|
767
|
+
),
|
|
768
|
+
const SizedBox(height: 12),
|
|
769
|
+
FilledButton.icon(
|
|
770
|
+
onPressed: widget.controller.isConfiguringTwoFactor
|
|
771
|
+
? null
|
|
772
|
+
: () async {
|
|
773
|
+
final setup = await widget.controller.beginTwoFactorSetup(
|
|
774
|
+
_setupPasswordController.text,
|
|
775
|
+
);
|
|
776
|
+
if (setup != null && mounted) {
|
|
777
|
+
setState(() => _pendingSetup = setup);
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
icon: Icon(Icons.qr_code_2_outlined),
|
|
781
|
+
label: Text('Start setup'),
|
|
782
|
+
),
|
|
783
|
+
] else ...<Widget>[
|
|
784
|
+
Center(
|
|
785
|
+
child: Container(
|
|
786
|
+
color: Colors.white,
|
|
787
|
+
padding: const EdgeInsets.all(12),
|
|
788
|
+
child: QrImageView(
|
|
789
|
+
data: setupUrl,
|
|
790
|
+
version: QrVersions.auto,
|
|
791
|
+
size: 220,
|
|
792
|
+
),
|
|
793
|
+
),
|
|
794
|
+
),
|
|
795
|
+
const SizedBox(height: 12),
|
|
796
|
+
SelectableText(manualKey, style: TextStyle(color: _textSecondary)),
|
|
797
|
+
const SizedBox(height: 12),
|
|
798
|
+
TextField(
|
|
799
|
+
controller: _setupCodeController,
|
|
800
|
+
keyboardType: TextInputType.number,
|
|
801
|
+
decoration: const InputDecoration(labelText: 'Authenticator code'),
|
|
802
|
+
),
|
|
803
|
+
const SizedBox(height: 12),
|
|
804
|
+
FilledButton.icon(
|
|
805
|
+
onPressed: widget.controller.isConfiguringTwoFactor
|
|
806
|
+
? null
|
|
807
|
+
: () async {
|
|
808
|
+
final codes = await widget.controller.enableTwoFactor(
|
|
809
|
+
_setupCodeController.text,
|
|
810
|
+
);
|
|
811
|
+
if (codes.isNotEmpty && mounted) {
|
|
812
|
+
setState(() {
|
|
813
|
+
_recoveryCodes = codes;
|
|
814
|
+
_pendingSetup = null;
|
|
815
|
+
_setupPasswordController.clear();
|
|
816
|
+
_setupCodeController.clear();
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
icon: Icon(Icons.verified_user_outlined),
|
|
821
|
+
label: Text('Enable 2FA'),
|
|
822
|
+
),
|
|
823
|
+
],
|
|
824
|
+
],
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
Widget _buildDisableTwoFactorPanel() {
|
|
829
|
+
return Column(
|
|
830
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
831
|
+
children: <Widget>[
|
|
832
|
+
TextField(
|
|
833
|
+
controller: _disablePasswordController,
|
|
834
|
+
obscureText: true,
|
|
835
|
+
decoration: const InputDecoration(labelText: 'Current password'),
|
|
836
|
+
),
|
|
837
|
+
const SizedBox(height: 12),
|
|
838
|
+
TextField(
|
|
839
|
+
controller: _disableCodeController,
|
|
840
|
+
decoration: const InputDecoration(labelText: '2FA or recovery code'),
|
|
841
|
+
),
|
|
842
|
+
const SizedBox(height: 12),
|
|
843
|
+
Wrap(
|
|
844
|
+
spacing: 10,
|
|
845
|
+
runSpacing: 10,
|
|
846
|
+
children: <Widget>[
|
|
847
|
+
FilledButton.icon(
|
|
848
|
+
onPressed: widget.controller.isConfiguringTwoFactor
|
|
849
|
+
? null
|
|
850
|
+
: () => widget.controller.disableTwoFactor(
|
|
851
|
+
currentPassword: _disablePasswordController.text,
|
|
852
|
+
code: _disableCodeController.text,
|
|
853
|
+
),
|
|
854
|
+
icon: Icon(Icons.lock_open_outlined),
|
|
855
|
+
label: Text('Disable 2FA'),
|
|
856
|
+
),
|
|
857
|
+
OutlinedButton.icon(
|
|
858
|
+
onPressed: widget.controller.isConfiguringTwoFactor
|
|
859
|
+
? null
|
|
860
|
+
: () async {
|
|
861
|
+
final codes = await widget.controller
|
|
862
|
+
.regenerateRecoveryCodes(
|
|
863
|
+
currentPassword: _disablePasswordController.text,
|
|
864
|
+
code: _disableCodeController.text,
|
|
865
|
+
);
|
|
866
|
+
if (codes.isNotEmpty && mounted) {
|
|
867
|
+
setState(() => _recoveryCodes = codes);
|
|
868
|
+
}
|
|
869
|
+
},
|
|
870
|
+
icon: Icon(Icons.password_outlined),
|
|
871
|
+
label: Text('New recovery codes'),
|
|
872
|
+
),
|
|
873
|
+
],
|
|
874
|
+
),
|
|
875
|
+
],
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
class _AccountSettingsTabs extends StatelessWidget {
|
|
881
|
+
const _AccountSettingsTabs({
|
|
882
|
+
required this.selected,
|
|
883
|
+
required this.onSelected,
|
|
884
|
+
this.vertical = false,
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
final AccountSettingsTab selected;
|
|
888
|
+
final ValueChanged<AccountSettingsTab> onSelected;
|
|
889
|
+
final bool vertical;
|
|
890
|
+
|
|
891
|
+
@override
|
|
892
|
+
Widget build(BuildContext context) {
|
|
893
|
+
final buttons = <Widget>[
|
|
894
|
+
_tabButton(AccountSettingsTab.account, Icons.person_outline, 'Account'),
|
|
895
|
+
_tabButton(
|
|
896
|
+
AccountSettingsTab.security,
|
|
897
|
+
Icons.security_outlined,
|
|
898
|
+
'Security',
|
|
899
|
+
),
|
|
900
|
+
];
|
|
901
|
+
return vertical
|
|
902
|
+
? Column(children: buttons)
|
|
903
|
+
: Wrap(spacing: 8, runSpacing: 8, children: buttons);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
Widget _tabButton(AccountSettingsTab tab, IconData icon, String label) {
|
|
907
|
+
return Padding(
|
|
908
|
+
padding: EdgeInsets.only(bottom: vertical ? 8 : 0),
|
|
909
|
+
child: _SidebarButton(
|
|
910
|
+
label: label,
|
|
911
|
+
icon: icon,
|
|
912
|
+
active: selected == tab,
|
|
913
|
+
onTap: () => onSelected(tab),
|
|
914
|
+
),
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
class _RecoveryCodesCard extends StatelessWidget {
|
|
920
|
+
const _RecoveryCodesCard({required this.codes});
|
|
921
|
+
|
|
922
|
+
final List<String> codes;
|
|
923
|
+
|
|
924
|
+
@override
|
|
925
|
+
Widget build(BuildContext context) {
|
|
926
|
+
return Container(
|
|
927
|
+
width: double.infinity,
|
|
928
|
+
padding: const EdgeInsets.all(16),
|
|
929
|
+
decoration: BoxDecoration(
|
|
930
|
+
color: _warning.withValues(alpha: 0.08),
|
|
931
|
+
borderRadius: BorderRadius.circular(12),
|
|
932
|
+
border: Border.all(color: _warning.withValues(alpha: 0.35)),
|
|
933
|
+
),
|
|
934
|
+
child: Column(
|
|
935
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
936
|
+
children: <Widget>[
|
|
937
|
+
Text(
|
|
938
|
+
'Save these recovery codes now. They will not be shown again.',
|
|
939
|
+
style: TextStyle(fontWeight: FontWeight.w700),
|
|
940
|
+
),
|
|
941
|
+
const SizedBox(height: 10),
|
|
942
|
+
Wrap(
|
|
943
|
+
spacing: 10,
|
|
944
|
+
runSpacing: 10,
|
|
945
|
+
children: codes
|
|
946
|
+
.map(
|
|
947
|
+
(code) => SelectableText(
|
|
948
|
+
code,
|
|
949
|
+
style: TextStyle(fontFamily: 'monospace'),
|
|
950
|
+
),
|
|
951
|
+
)
|
|
952
|
+
.toList(),
|
|
953
|
+
),
|
|
954
|
+
const SizedBox(height: 12),
|
|
955
|
+
OutlinedButton.icon(
|
|
956
|
+
onPressed: () =>
|
|
957
|
+
Clipboard.setData(ClipboardData(text: codes.join('\n'))),
|
|
958
|
+
icon: Icon(Icons.copy_outlined),
|
|
959
|
+
label: Text('Copy codes'),
|
|
960
|
+
),
|
|
961
|
+
],
|
|
962
|
+
),
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
class _AccountSessionCard extends StatelessWidget {
|
|
968
|
+
const _AccountSessionCard({
|
|
969
|
+
required this.session,
|
|
970
|
+
required this.busy,
|
|
971
|
+
required this.onRevoke,
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
final AccountSessionItem session;
|
|
975
|
+
final bool busy;
|
|
976
|
+
final VoidCallback? onRevoke;
|
|
977
|
+
|
|
978
|
+
@override
|
|
979
|
+
Widget build(BuildContext context) {
|
|
980
|
+
return Container(
|
|
981
|
+
margin: const EdgeInsets.only(bottom: 10),
|
|
982
|
+
padding: const EdgeInsets.all(14),
|
|
983
|
+
decoration: BoxDecoration(
|
|
984
|
+
color: _bgSecondary,
|
|
985
|
+
borderRadius: BorderRadius.circular(12),
|
|
986
|
+
border: Border.all(color: _border),
|
|
987
|
+
),
|
|
988
|
+
child: Row(
|
|
989
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
990
|
+
children: <Widget>[
|
|
991
|
+
Icon(
|
|
992
|
+
session.deviceIcon,
|
|
993
|
+
color: session.current ? _success : _textSecondary,
|
|
994
|
+
),
|
|
995
|
+
const SizedBox(width: 12),
|
|
996
|
+
Expanded(
|
|
997
|
+
child: Column(
|
|
998
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
999
|
+
children: <Widget>[
|
|
1000
|
+
Text(
|
|
1001
|
+
session.current
|
|
1002
|
+
? '${session.clientLabel} · Current session'
|
|
1003
|
+
: session.clientLabel,
|
|
1004
|
+
style: TextStyle(fontWeight: FontWeight.w700),
|
|
1005
|
+
),
|
|
1006
|
+
const SizedBox(height: 4),
|
|
1007
|
+
Text(
|
|
1008
|
+
[
|
|
1009
|
+
session.locationSummary,
|
|
1010
|
+
'Last seen ${session.lastSeenLabel}',
|
|
1011
|
+
].join(' · '),
|
|
1012
|
+
style: TextStyle(color: _textSecondary),
|
|
1013
|
+
),
|
|
1014
|
+
if (session.userAgent.isNotEmpty) ...<Widget>[
|
|
1015
|
+
const SizedBox(height: 4),
|
|
1016
|
+
Text(
|
|
1017
|
+
'${session.clientPlatformLabel} · ${session.clientBrowserLabel} · Created ${session.createdLabel}',
|
|
1018
|
+
maxLines: 2,
|
|
1019
|
+
overflow: TextOverflow.ellipsis,
|
|
1020
|
+
style: TextStyle(color: _textMuted, fontSize: 12),
|
|
1021
|
+
),
|
|
1022
|
+
],
|
|
1023
|
+
],
|
|
1024
|
+
),
|
|
1025
|
+
),
|
|
1026
|
+
if (!session.current)
|
|
1027
|
+
TextButton(
|
|
1028
|
+
onPressed: busy ? null : onRevoke,
|
|
1029
|
+
child: Text('Revoke'),
|
|
1030
|
+
),
|
|
1031
|
+
],
|
|
1032
|
+
),
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
class _QrLoginScannerDialog extends StatefulWidget {
|
|
1038
|
+
const _QrLoginScannerDialog();
|
|
1039
|
+
|
|
1040
|
+
@override
|
|
1041
|
+
State<_QrLoginScannerDialog> createState() => _QrLoginScannerDialogState();
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
class _QrLoginScannerDialogState extends State<_QrLoginScannerDialog> {
|
|
1045
|
+
bool _handled = false;
|
|
1046
|
+
|
|
1047
|
+
@override
|
|
1048
|
+
Widget build(BuildContext context) {
|
|
1049
|
+
return Dialog.fullscreen(
|
|
1050
|
+
backgroundColor: Colors.black,
|
|
1051
|
+
child: Stack(
|
|
1052
|
+
fit: StackFit.expand,
|
|
1053
|
+
children: <Widget>[
|
|
1054
|
+
MobileScanner(
|
|
1055
|
+
fit: BoxFit.cover,
|
|
1056
|
+
onDetect: (capture) {
|
|
1057
|
+
if (_handled) return;
|
|
1058
|
+
final raw = capture.barcodes
|
|
1059
|
+
.map((barcode) => barcode.rawValue?.trim() ?? '')
|
|
1060
|
+
.firstWhere((value) => value.isNotEmpty, orElse: () => '');
|
|
1061
|
+
if (raw.isEmpty) return;
|
|
1062
|
+
_handled = true;
|
|
1063
|
+
Navigator.of(context).pop(raw);
|
|
1064
|
+
},
|
|
1065
|
+
),
|
|
1066
|
+
DecoratedBox(
|
|
1067
|
+
decoration: BoxDecoration(
|
|
1068
|
+
gradient: LinearGradient(
|
|
1069
|
+
begin: Alignment.topCenter,
|
|
1070
|
+
end: Alignment.bottomCenter,
|
|
1071
|
+
colors: <Color>[
|
|
1072
|
+
Colors.black.withValues(alpha: 0.72),
|
|
1073
|
+
Colors.transparent,
|
|
1074
|
+
Colors.black.withValues(alpha: 0.78),
|
|
1075
|
+
],
|
|
1076
|
+
stops: const <double>[0, 0.42, 1],
|
|
1077
|
+
),
|
|
1078
|
+
),
|
|
1079
|
+
),
|
|
1080
|
+
SafeArea(
|
|
1081
|
+
child: Padding(
|
|
1082
|
+
padding: const EdgeInsets.all(20),
|
|
1083
|
+
child: Column(
|
|
1084
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1085
|
+
children: <Widget>[
|
|
1086
|
+
Align(
|
|
1087
|
+
alignment: Alignment.topRight,
|
|
1088
|
+
child: IconButton(
|
|
1089
|
+
onPressed: () => Navigator.of(context).pop(),
|
|
1090
|
+
icon: const Icon(
|
|
1091
|
+
Icons.close_rounded,
|
|
1092
|
+
color: Colors.white,
|
|
1093
|
+
),
|
|
1094
|
+
),
|
|
1095
|
+
),
|
|
1096
|
+
const Spacer(),
|
|
1097
|
+
Center(
|
|
1098
|
+
child: Container(
|
|
1099
|
+
width: 260,
|
|
1100
|
+
height: 260,
|
|
1101
|
+
decoration: BoxDecoration(
|
|
1102
|
+
borderRadius: BorderRadius.circular(28),
|
|
1103
|
+
border: Border.all(color: Colors.white, width: 2),
|
|
1104
|
+
),
|
|
1105
|
+
),
|
|
1106
|
+
),
|
|
1107
|
+
const SizedBox(height: 28),
|
|
1108
|
+
Text(
|
|
1109
|
+
'Scan a NeoAgent login QR',
|
|
1110
|
+
style: GoogleFonts.spaceGrotesk(
|
|
1111
|
+
fontSize: 28,
|
|
1112
|
+
fontWeight: FontWeight.w700,
|
|
1113
|
+
color: Colors.white,
|
|
1114
|
+
),
|
|
1115
|
+
),
|
|
1116
|
+
const SizedBox(height: 8),
|
|
1117
|
+
Text(
|
|
1118
|
+
'Point the camera at the code shown on the signed-out device. Approval stays on this phone.',
|
|
1119
|
+
style: TextStyle(
|
|
1120
|
+
color: Colors.white.withValues(alpha: 0.82),
|
|
1121
|
+
height: 1.5,
|
|
1122
|
+
),
|
|
1123
|
+
),
|
|
1124
|
+
const SizedBox(height: 24),
|
|
1125
|
+
],
|
|
1126
|
+
),
|
|
1127
|
+
),
|
|
1128
|
+
),
|
|
1129
|
+
],
|
|
1130
|
+
),
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
class _QrLoginApprovalDialog extends StatelessWidget {
|
|
1136
|
+
const _QrLoginApprovalDialog({required this.preview, required this.busy});
|
|
1137
|
+
|
|
1138
|
+
final QrLoginApprovalPreview preview;
|
|
1139
|
+
final bool busy;
|
|
1140
|
+
|
|
1141
|
+
IconData get _deviceIcon => switch (preview.requestedDevice.deviceClass) {
|
|
1142
|
+
'mobile' => Icons.smartphone_rounded,
|
|
1143
|
+
'tablet' => Icons.tablet_mac_rounded,
|
|
1144
|
+
'desktop' => Icons.laptop_mac_rounded,
|
|
1145
|
+
'server' => Icons.dns_outlined,
|
|
1146
|
+
_ => Icons.devices_other_outlined,
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
@override
|
|
1150
|
+
Widget build(BuildContext context) {
|
|
1151
|
+
final canApprove =
|
|
1152
|
+
preview.canApprove && !preview.isExpired && !preview.isClaimed;
|
|
1153
|
+
return AlertDialog(
|
|
1154
|
+
backgroundColor: _bgCard,
|
|
1155
|
+
title: const Text('Approve QR login'),
|
|
1156
|
+
content: SizedBox(
|
|
1157
|
+
width: 460,
|
|
1158
|
+
child: Column(
|
|
1159
|
+
mainAxisSize: MainAxisSize.min,
|
|
1160
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1161
|
+
children: <Widget>[
|
|
1162
|
+
Container(
|
|
1163
|
+
width: double.infinity,
|
|
1164
|
+
padding: const EdgeInsets.all(16),
|
|
1165
|
+
decoration: BoxDecoration(
|
|
1166
|
+
color: _bgSecondary,
|
|
1167
|
+
borderRadius: BorderRadius.circular(16),
|
|
1168
|
+
border: Border.all(color: _border),
|
|
1169
|
+
),
|
|
1170
|
+
child: Row(
|
|
1171
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1172
|
+
children: <Widget>[
|
|
1173
|
+
Icon(_deviceIcon, color: _accent),
|
|
1174
|
+
const SizedBox(width: 12),
|
|
1175
|
+
Expanded(
|
|
1176
|
+
child: Column(
|
|
1177
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1178
|
+
children: <Widget>[
|
|
1179
|
+
Text(
|
|
1180
|
+
preview.requestedDevice.label,
|
|
1181
|
+
style: const TextStyle(fontWeight: FontWeight.w700),
|
|
1182
|
+
),
|
|
1183
|
+
const SizedBox(height: 6),
|
|
1184
|
+
Text(
|
|
1185
|
+
[
|
|
1186
|
+
preview.requestLocation.label,
|
|
1187
|
+
if (preview.requestedAt != null)
|
|
1188
|
+
'Requested ${_formatTimestamp(preview.requestedAt!)}',
|
|
1189
|
+
].join(' · '),
|
|
1190
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
1191
|
+
),
|
|
1192
|
+
],
|
|
1193
|
+
),
|
|
1194
|
+
),
|
|
1195
|
+
],
|
|
1196
|
+
),
|
|
1197
|
+
),
|
|
1198
|
+
const SizedBox(height: 14),
|
|
1199
|
+
Wrap(
|
|
1200
|
+
spacing: 8,
|
|
1201
|
+
runSpacing: 8,
|
|
1202
|
+
children: <Widget>[
|
|
1203
|
+
_MetaPill(
|
|
1204
|
+
label: preview.requestedDevice.platformLabel,
|
|
1205
|
+
icon: Icons.devices_outlined,
|
|
1206
|
+
),
|
|
1207
|
+
_MetaPill(
|
|
1208
|
+
label: preview.requestedDevice.browserLabel,
|
|
1209
|
+
icon: Icons.language_outlined,
|
|
1210
|
+
),
|
|
1211
|
+
if (preview.expiresAt != null)
|
|
1212
|
+
_MetaPill(
|
|
1213
|
+
label: 'Expires ${_formatTimestamp(preview.expiresAt!)}',
|
|
1214
|
+
icon: Icons.timer_outlined,
|
|
1215
|
+
),
|
|
1216
|
+
],
|
|
1217
|
+
),
|
|
1218
|
+
const SizedBox(height: 14),
|
|
1219
|
+
Text(
|
|
1220
|
+
preview.isClaimed
|
|
1221
|
+
? 'This request has already been used.'
|
|
1222
|
+
: preview.isExpired
|
|
1223
|
+
? 'This request has expired. Ask the other device to generate a new code.'
|
|
1224
|
+
: 'Approve this only if you started the login on that device just now.',
|
|
1225
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
1226
|
+
),
|
|
1227
|
+
],
|
|
1228
|
+
),
|
|
1229
|
+
),
|
|
1230
|
+
actions: <Widget>[
|
|
1231
|
+
TextButton(
|
|
1232
|
+
onPressed: busy ? null : () => Navigator.of(context).pop(false),
|
|
1233
|
+
child: const Text('Cancel'),
|
|
1234
|
+
),
|
|
1235
|
+
FilledButton.icon(
|
|
1236
|
+
onPressed: !canApprove || busy
|
|
1237
|
+
? null
|
|
1238
|
+
: () => Navigator.of(context).pop(true),
|
|
1239
|
+
icon: busy
|
|
1240
|
+
? const SizedBox.square(
|
|
1241
|
+
dimension: 16,
|
|
1242
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
1243
|
+
)
|
|
1244
|
+
: const Icon(Icons.verified_user_outlined),
|
|
1245
|
+
label: const Text('Approve login'),
|
|
1246
|
+
),
|
|
1247
|
+
],
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
}
|