From 44d1cc99b4d58a74591691ab01872528989fa5eb Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:25:02 +0530 Subject: [PATCH] feat(server): Improve server health checks and authentication flow --- lib/core/auth/api_auth_interceptor.dart | 1 + lib/core/router/app_router.dart | 10 +- lib/core/services/api_service.dart | 31 +++++- lib/core/widgets/error_boundary.dart | 18 +++- .../auth/views/authentication_page.dart | 23 +++- .../auth/views/connection_issue_page.dart | 18 +++- .../auth/views/server_connection_page.dart | 100 +++++++++++++----- 7 files changed, 166 insertions(+), 35 deletions(-) diff --git a/lib/core/auth/api_auth_interceptor.dart b/lib/core/auth/api_auth_interceptor.dart index e935ee6..7772232 100644 --- a/lib/core/auth/api_auth_interceptor.dart +++ b/lib/core/auth/api_auth_interceptor.dart @@ -30,6 +30,7 @@ class ApiAuthInterceptor extends Interceptor { // Endpoints that have optional authentication (work without but better with) static const Set _optionalAuthEndpoints = { + '/api/config', '/api/models', '/api/v1/configs/models', }; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index dcef7d4..7446559 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -81,8 +81,14 @@ class RouterNotifier extends ChangeNotifier { final activeServer = activeServerAsync.asData?.value; final hasActiveServer = activeServer != null; if (!hasActiveServer) { - // Allow auth-related routes while no server configured - if (_isAuthLocation(location)) return null; + // No server configured - redirect to server connection + // Exception: allow staying on server connection or authentication pages + // But always redirect away from connection issue page (user logged out) + if (location == Routes.serverConnection || + location == Routes.authentication || + location == Routes.login) { + return null; + } return Routes.serverConnection; } diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 4070543..aa4320e 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -219,7 +219,7 @@ class ApiService { return parsed; } - // Health check + /// Basic health check - just verifies the server is reachable. Future checkHealth() async { try { final response = await _dio.get('/health'); @@ -229,6 +229,35 @@ class ApiService { } } + /// Verifies this is actually an OpenWebUI server by checking the /api/config + /// endpoint for OpenWebUI-specific fields (version, status, features). + /// + /// Returns `true` if the server appears to be a valid OpenWebUI instance. + Future verifyIsOpenWebUIServer() async { + try { + final response = await _dio.get('/api/config'); + if (response.statusCode != 200) { + return false; + } + + final data = response.data; + if (data is! Map) { + return false; + } + + // Check for OpenWebUI-specific fields + // The /api/config endpoint always returns these fields on OpenWebUI + final hasStatus = data['status'] == true; + final hasVersion = + data['version'] is String && (data['version'] as String).isNotEmpty; + final hasFeatures = data['features'] is Map; + + return hasStatus && hasVersion && hasFeatures; + } catch (e) { + return false; + } + } + // Enhanced health check with model availability Future> checkServerStatus() async { final result = { diff --git a/lib/core/widgets/error_boundary.dart b/lib/core/widgets/error_boundary.dart index 08693b6..48405b4 100644 --- a/lib/core/widgets/error_boundary.dart +++ b/lib/core/widgets/error_boundary.dart @@ -32,10 +32,18 @@ class _ErrorBoundaryState extends ConsumerState { void Function(FlutterErrorDetails details)? _previousOnError; bool _shouldIgnoreError(Object error) { - // Ignore RenderFlex overflow errors (layout issues) final errorString = error.toString(); - return errorString.contains('RenderFlex') || - errorString.contains('overflow') && errorString.contains('pixels'); + // Ignore RenderFlex overflow errors (layout issues) + if (errorString.contains('RenderFlex') || + errorString.contains('overflow') && errorString.contains('pixels')) { + return true; + } + // Ignore "Build scheduled during frame" errors - these are harmless + // framework warnings from animations during layout + if (errorString.contains('Build scheduled during frame')) { + return true; + } + return false; } void _scheduleHandleError(Object error, StackTrace? stack) { @@ -59,6 +67,10 @@ class _ErrorBoundaryState extends ConsumerState { // Set up Flutter error handling for this widget _previousOnError = FlutterError.onError; FlutterError.onError = (FlutterErrorDetails details) { + // Check if this is a harmless error we should completely ignore + if (_shouldIgnoreError(details.exception)) { + return; // Don't forward or handle + } // Forward to any previously registered handler to avoid interfering _previousOnError?.call(details); // Defer handling to avoid setState during build diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart index 5ece084..00752e4 100644 --- a/lib/features/auth/views/authentication_page.dart +++ b/lib/features/auth/views/authentication_page.dart @@ -37,6 +37,7 @@ class _AuthenticationPageState extends ConsumerState { bool _useApiKey = false; String? _loginError; bool _isSigningIn = false; + bool _serverConfigSaved = false; @override void initState() { @@ -72,13 +73,20 @@ class _AuthenticationPageState extends ConsumerState { }); try { + // Save server config on first sign-in attempt if it's a new config + // This persists the server so user can retry with different credentials + if (widget.serverConfig != null && !_serverConfigSaved) { + await _saveServerConfig(widget.serverConfig!); + _serverConfigSaved = true; + } + final actions = ref.read(authActionsProvider); bool success; if (_useApiKey) { success = await actions.loginWithApiKey( _apiKeyController.text.trim(), - rememberCredentials: true, // Consistent with credentials method + rememberCredentials: true, ); } else { success = await actions.login( @@ -95,6 +103,9 @@ class _AuthenticationPageState extends ConsumerState { // Success - navigation will be handled by auth state change } catch (e) { + // Don't clear server config on auth failure - user should be able to retry + // The server config is valid (passed OpenWebUI verification), only the + // credentials were wrong or there was a network issue setState(() { _loginError = _formatLoginError(e.toString()); }); @@ -107,6 +118,14 @@ class _AuthenticationPageState extends ConsumerState { } } + Future _saveServerConfig(ServerConfig config) async { + final storage = ref.read(optimizedStorageServiceProvider); + await storage.saveServerConfigs([config]); + await storage.setActiveServerId(config.id); + ref.invalidate(serverConfigsProvider); + ref.invalidate(activeServerProvider); + } + String _formatLoginError(String error) { if (error.contains('401') || error.contains('Unauthorized')) { return AppLocalizations.of(context)!.invalidCredentials; @@ -164,6 +183,8 @@ class _AuthenticationPageState extends ConsumerState { // Main content Expanded( child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Form( diff --git a/lib/features/auth/views/connection_issue_page.dart b/lib/features/auth/views/connection_issue_page.dart index 8c017de..242b758 100644 --- a/lib/features/auth/views/connection_issue_page.dart +++ b/lib/features/auth/views/connection_issue_page.dart @@ -215,19 +215,31 @@ class _ConnectionIssuePageState extends ConsumerState { } Future _retryConnection() async { + final l10n = AppLocalizations.of(context)!; + setState(() { _isRetrying = true; _statusMessage = null; }); try { - // Clear the error state and attempt to re-establish connection final authManager = ref.read(authStateManagerProvider.notifier); + final authState = ref.read(authStateManagerProvider); + final hasValidToken = authState.maybeWhen( + data: (state) => state.hasValidToken, + orElse: () => false, + ); // Reset retry counter for manual retry attempts authManager.resetRetryCounter(); - await authManager.silentLogin(); + if (hasValidToken) { + // User has a valid token - just refresh to verify connection + await authManager.refresh(); + } else { + // No valid token - attempt silent login with saved credentials + await authManager.silentLogin(); + } // If successful, router will automatically navigate to chat if (!mounted) return; @@ -237,7 +249,7 @@ class _ConnectionIssuePageState extends ConsumerState { } catch (_) { if (!mounted) return; setState(() { - _statusMessage = 'Connection failed. Please try again.'; + _statusMessage = l10n.couldNotConnectGeneric; }); } finally { if (mounted) { diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart index 4dabc16..2f36f7e 100644 --- a/lib/features/auth/views/server_connection_page.dart +++ b/lib/features/auth/views/server_connection_page.dart @@ -14,6 +14,7 @@ import '../../../core/services/api_service.dart'; import '../../../core/services/worker_manager.dart'; import '../../../core/services/input_validation_service.dart'; import '../../../core/services/navigation_service.dart'; +import '../../../core/utils/debug_logger.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/services/brand_service.dart'; import '../../../shared/theme/theme_extensions.dart'; @@ -63,7 +64,22 @@ class _ServerConnectionPageState extends ConsumerState { } Future _connectToServer() async { - if (!_formKey.currentState!.validate()) return; + DebugLogger.log('Connect button pressed', scope: 'auth/connection'); + + final urlValue = _urlController.text.trim(); + DebugLogger.log('URL value: "$urlValue"', scope: 'auth/connection'); + + // Check what validation would return + final validationResult = InputValidationService.validateUrl(urlValue); + DebugLogger.log( + 'URL validation result: ${validationResult ?? "valid"}', + scope: 'auth/connection', + ); + + if (!_formKey.currentState!.validate()) { + DebugLogger.log('Form validation failed', scope: 'auth/connection'); + return; + } setState(() { _isConnecting = true; @@ -87,21 +103,56 @@ class _ServerConnectionPageState extends ConsumerState { serverConfig: tempConfig, workerManager: workerManager, ); - final isHealthy = await api.checkHealth(); - if (!isHealthy) { + + // First check basic connectivity + DebugLogger.log('Checking server health...', scope: 'auth/connection'); + final isReachable = await api.checkHealth(); + DebugLogger.log( + 'Health check result: $isReachable', + scope: 'auth/connection', + ); + if (!isReachable) { + throw Exception( + 'Could not reach the server. Please check the address.', + ); + } + + // Then verify it's actually an OpenWebUI server + DebugLogger.log( + 'Verifying OpenWebUI server...', + scope: 'auth/connection', + ); + final isOpenWebUI = await api.verifyIsOpenWebUIServer(); + DebugLogger.log( + 'OpenWebUI verification result: $isOpenWebUI', + scope: 'auth/connection', + ); + if (!isOpenWebUI) { throw Exception('This does not appear to be an Open-WebUI server.'); } - await _saveServerConfig(tempConfig); + DebugLogger.log( + 'Server validation passed, navigating to auth page', + scope: 'auth/connection', + ); - // Navigate to authentication page + // Don't save server config yet - wait until authentication succeeds + // The config is passed to the authentication page if (mounted) { context.pushNamed(RouteNames.authentication, extra: tempConfig); } - } catch (e) { - setState(() { - _connectionError = _formatConnectionError(e.toString()); - }); + } catch (e, stack) { + DebugLogger.error( + 'server-connection-error', + scope: 'auth/connection', + error: e, + stackTrace: stack, + ); + if (mounted) { + setState(() { + _connectionError = _formatConnectionError(e.toString()); + }); + } } finally { if (mounted) { setState(() { @@ -111,14 +162,6 @@ class _ServerConnectionPageState extends ConsumerState { } } - Future _saveServerConfig(ServerConfig config) async { - final storage = ref.read(optimizedStorageServiceProvider); - await storage.saveServerConfigs([config]); - await storage.setActiveServerId(config.id); - ref.invalidate(serverConfigsProvider); - ref.invalidate(activeServerProvider); - } - String _validateAndFormatUrl(String input) { if (input.isEmpty) { throw Exception(AppLocalizations.of(context)!.serverUrlEmpty); @@ -244,6 +287,8 @@ class _ServerConnectionPageState extends ConsumerState { // Main content Expanded( child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Form( @@ -528,12 +573,16 @@ class _ServerConnectionPageState extends ConsumerState { ), ), ), - AnimatedSize( + // Use AnimatedCrossFade instead of AnimatedSize to avoid + // "Build scheduled during frame" errors + AnimatedCrossFade( duration: AnimationDuration.microInteraction, - curve: Curves.easeInOutCubic, - child: _showAdvancedSettings - ? _buildAdvancedSettingsContent() - : const SizedBox.shrink(), + sizeCurve: Curves.easeInOutCubic, + crossFadeState: _showAdvancedSettings + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox.shrink(), + secondChild: _buildAdvancedSettingsContent(), ), ], ); @@ -845,8 +894,8 @@ class _ServerConnectionPageState extends ConsumerState { } String? _validateHeaderKey(String key) { - // RFC 7230 compliant header name validation - if (key.isEmpty) return AppLocalizations.of(context)!.headerNameEmpty; + // Allow empty - header fields are optional + if (key.isEmpty) return null; if (key.length > 64) return AppLocalizations.of(context)!.headerNameTooLong; // Check for valid characters (RFC 7230: token characters) @@ -879,7 +928,8 @@ class _ServerConnectionPageState extends ConsumerState { } String? _validateHeaderValue(String value) { - if (value.isEmpty) return AppLocalizations.of(context)!.headerValueEmpty; + // Allow empty - header fields are optional + if (value.isEmpty) return null; if (value.length > 1024) { return AppLocalizations.of(context)!.headerValueTooLong; }