Merge pull request #179 from cogwheel0/refactor-auth-provider-and-server-health
refactor-auth-provider-and-server-health
This commit is contained in:
@@ -30,6 +30,7 @@ class ApiAuthInterceptor extends Interceptor {
|
||||
|
||||
// Endpoints that have optional authentication (work without but better with)
|
||||
static const Set<String> _optionalAuthEndpoints = {
|
||||
'/api/config',
|
||||
'/api/models',
|
||||
'/api/v1/configs/models',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ class ApiService {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Health check
|
||||
/// Basic health check - just verifies the server is reachable.
|
||||
Future<bool> 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<bool> verifyIsOpenWebUIServer() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/config');
|
||||
if (response.statusCode != 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
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<Map<String, dynamic>> checkServerStatus() async {
|
||||
final result = <String, dynamic>{
|
||||
|
||||
@@ -32,10 +32,18 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
|
||||
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<ErrorBoundary> {
|
||||
// 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
|
||||
|
||||
@@ -37,6 +37,7 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
bool _useApiKey = false;
|
||||
String? _loginError;
|
||||
bool _isSigningIn = false;
|
||||
bool _serverConfigSaved = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -72,13 +73,20 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
});
|
||||
|
||||
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<AuthenticationPage> {
|
||||
|
||||
// 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<AuthenticationPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<AuthenticationPage> {
|
||||
// Main content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Form(
|
||||
|
||||
@@ -215,19 +215,31 @@ class _ConnectionIssuePageState extends ConsumerState<ConnectionIssuePage> {
|
||||
}
|
||||
|
||||
Future<void> _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<ConnectionIssuePage> {
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_statusMessage = 'Connection failed. Please try again.';
|
||||
_statusMessage = l10n.couldNotConnectGeneric;
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
||||
@@ -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<ServerConnectionPage> {
|
||||
}
|
||||
|
||||
Future<void> _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<ServerConnectionPage> {
|
||||
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<ServerConnectionPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<ServerConnectionPage> {
|
||||
// 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<ServerConnectionPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
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<ServerConnectionPage> {
|
||||
}
|
||||
|
||||
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<ServerConnectionPage> {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../../shared/widgets/responsive_drawer_layout.dart';
|
||||
import '../../navigation/widgets/chats_drawer.dart';
|
||||
import 'dart:async';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
import '../providers/chat_providers.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../core/utils/user_display_name.dart';
|
||||
@@ -1122,12 +1123,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
Widget _buildEmptyState(ThemeData theme) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final currentUserAsync = ref.watch(currentUserProvider);
|
||||
final userFromProfile = currentUserAsync.maybeWhen(
|
||||
data: (user) => user,
|
||||
orElse: () => null,
|
||||
final authUser = ref.watch(currentUserProvider2);
|
||||
final asyncUser = ref.watch(currentUserProvider);
|
||||
final user = asyncUser.maybeWhen(
|
||||
data: (value) => value ?? authUser,
|
||||
orElse: () => authUser,
|
||||
);
|
||||
final user = userFromProfile;
|
||||
String? greetingName;
|
||||
if (user != null) {
|
||||
final derived = deriveUserDisplayName(user, fallback: '').trim();
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../../chat/providers/chat_providers.dart' as chat;
|
||||
// import '../../files/views/files_page.dart';
|
||||
@@ -1477,12 +1478,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
Widget _buildBottomSection(BuildContext context) {
|
||||
final theme = context.conduitTheme;
|
||||
final sidebarTheme = context.sidebarTheme;
|
||||
final currentUserAsync = ref.watch(currentUserProvider);
|
||||
final userFromProfile = currentUserAsync.maybeWhen(
|
||||
data: (u) => u,
|
||||
orElse: () => null,
|
||||
);
|
||||
final user = userFromProfile;
|
||||
final user = ref.watch(currentUserProvider2);
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
|
||||
String initialFor(String name) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
import '../../../core/utils/user_display_name.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../../../shared/widgets/sheet_handle.dart';
|
||||
@@ -67,12 +67,7 @@ class _OnboardingSheetState extends ConsumerState<OnboardingSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final currentUserAsync = ref.watch(currentUserProvider);
|
||||
final userFromProfile = currentUserAsync.maybeWhen(
|
||||
data: (user) => user,
|
||||
orElse: () => null,
|
||||
);
|
||||
final user = userFromProfile;
|
||||
final user = ref.watch(currentUserProvider2);
|
||||
final greetingName = deriveUserDisplayName(user);
|
||||
final pages = _buildPages(l10n, greetingName);
|
||||
final pageCount = pages.length;
|
||||
|
||||
@@ -41,40 +41,23 @@ class ProfilePage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final user = ref.watch(currentUserProvider2);
|
||||
final isAuthLoading = ref.watch(isAuthLoadingProvider2);
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
|
||||
return ErrorBoundary(
|
||||
child: user.when(
|
||||
data: (userData) => _buildScaffold(
|
||||
context,
|
||||
body: _buildProfileBody(context, ref, userData, api),
|
||||
Widget body;
|
||||
if (isAuthLoading && user == null) {
|
||||
body = _buildCenteredState(
|
||||
context,
|
||||
ImprovedLoadingState(
|
||||
message: AppLocalizations.of(context)!.loadingProfile,
|
||||
),
|
||||
loading: () => _buildScaffold(
|
||||
context,
|
||||
body: _buildCenteredState(
|
||||
context,
|
||||
ImprovedLoadingState(
|
||||
message: AppLocalizations.of(context)!.loadingProfile,
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => _buildScaffold(
|
||||
context,
|
||||
body: _buildCenteredState(
|
||||
context,
|
||||
ImprovedEmptyState(
|
||||
title: AppLocalizations.of(context)!.unableToLoadProfile,
|
||||
subtitle: AppLocalizations.of(context)!.pleaseCheckConnection,
|
||||
icon: UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.exclamationmark_triangle,
|
||||
android: Icons.error_outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
} else {
|
||||
body = _buildProfileBody(context, ref, user, api);
|
||||
}
|
||||
|
||||
return ErrorBoundary(child: _buildScaffold(context, body: body));
|
||||
}
|
||||
|
||||
Scaffold _buildScaffold(BuildContext context, {required Widget body}) {
|
||||
|
||||
Reference in New Issue
Block a user