From ea61168184b241e258151070c82177ffed95a658 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:36:22 +0530 Subject: [PATCH] feat(auth): Add LDAP and SSO authentication support --- lib/core/auth/auth_state_manager.dart | 160 ++++- lib/core/auth/webview_cookie_helper.dart | 33 + lib/core/router/app_router.dart | 11 + lib/core/services/api_service.dart | 40 ++ lib/core/services/navigation_service.dart | 2 + .../services/optimized_storage_service.dart | 2 + .../services/secure_credential_storage.dart | 20 +- .../providers/unified_auth_providers.dart | 14 + .../auth/views/authentication_page.dart | 380 +++++++++++- lib/features/auth/views/sso_auth_page.dart | 565 ++++++++++++++++++ lib/l10n/app_de.arb | 17 +- lib/l10n/app_en.arb | 60 ++ lib/l10n/app_es.arb | 17 +- lib/l10n/app_fr.arb | 17 +- lib/l10n/app_it.arb | 17 +- lib/l10n/app_ko.arb | 17 +- lib/l10n/app_nl.arb | 17 +- lib/l10n/app_ru.arb | 17 +- lib/l10n/app_zh.arb | 17 +- lib/l10n/app_zh_Hant.arb | 17 +- 20 files changed, 1395 insertions(+), 45 deletions(-) create mode 100644 lib/core/auth/webview_cookie_helper.dart create mode 100644 lib/features/auth/views/sso_auth_page.dart diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index 86bd28c..cb258ec 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -8,6 +8,7 @@ import '../models/user.dart'; import '../services/optimized_storage_service.dart'; import 'token_validator.dart'; import 'auth_cache_manager.dart'; +import 'webview_cookie_helper.dart'; import '../utils/debug_logger.dart'; import '../utils/user_avatar_utils.dart'; import '../../features/tools/providers/tools_providers.dart'; @@ -288,11 +289,17 @@ class AuthStateManager extends _$AuthStateManager { } } - /// Perform login with JWT token + /// Perform login with JWT token. + /// /// Note: API keys (sk-...) are not supported for streaming. + /// + /// [authType] specifies the source of the token for credential storage: + /// - 'token': Manual JWT entry (default) + /// - 'sso': Token obtained via SSO/OAuth flow Future loginWithApiKey( String apiKey, { bool rememberCredentials = false, + String authType = 'token', }) async { _update( (current) => current.copyWith( @@ -347,6 +354,7 @@ class AuthStateManager extends _$AuthStateManager { serverId: activeServer.id, username: 'jwt_user', // Special username to indicate JWT auth password: tokenStr, // Store JWT in password field + authType: authType, // 'token' for manual entry, 'sso' for OAuth ); } } @@ -486,6 +494,117 @@ class AuthStateManager extends _$AuthStateManager { } } + /// Perform login with LDAP credentials. + /// + /// LDAP uses username (not email) for authentication. + /// The server must have LDAP enabled, otherwise this will throw an error. + Future ldapLogin( + String username, + String password, { + bool rememberCredentials = false, + }) async { + _update( + (current) => current.copyWith( + status: AuthStatus.loading, + isLoading: true, + clearError: true, + ), + ); + + try { + // Ensure API service is available + await _ensureApiServiceAvailable(); + final api = ref.read(apiServiceProvider); + if (api == null) { + throw Exception('No server connection available'); + } + + // Perform LDAP login API call + final response = await api.ldapLogin(username, password); + + // Check if notifier is still mounted after async call + if (!ref.mounted) return false; + + // Extract and validate token + final token = response['token'] ?? response['access_token']; + if (token == null || token.toString().trim().isEmpty) { + throw Exception('No authentication token received'); + } + + final tokenStr = token.toString(); + if (!_isValidTokenFormat(tokenStr)) { + throw Exception('Invalid authentication token format'); + } + + // Save token to storage + final storage = ref.read(optimizedStorageServiceProvider); + await storage.saveAuthToken(tokenStr); + + if (!ref.mounted) return false; + + // Save JWT token for re-authentication if requested + // We store the token (not the raw LDAP password) for security: + // - JWT tokens can be revoked server-side + // - Avoids storing the user's directory password + // - Consistent with SSO token storage approach + if (rememberCredentials) { + final activeServer = await ref.read(activeServerProvider.future); + if (!ref.mounted) return false; + if (activeServer != null) { + await storage.saveCredentials( + serverId: activeServer.id, + // Prefix with ldap: to preserve original username for debugging + // while indicating this is token-based auth + username: 'ldap:$username', + password: tokenStr, // Store JWT token, not LDAP password + authType: 'ldap', // Track that this originated from LDAP login + ); + } + } + + if (!ref.mounted) return false; + + // Update state and API service + _update( + (current) => current.copyWith( + status: AuthStatus.authenticated, + token: tokenStr, + isLoading: false, + clearError: true, + ), + cache: true, + ); + + _updateApiServiceToken(tokenStr); + _preloadDefaultModel(); + + // Load user data in background + _loadUserData(); + _prefetchConversations(); + + DebugLogger.auth('LDAP login successful'); + return true; + } catch (e, stack) { + DebugLogger.error( + 'ldap-login-failed', + scope: 'auth/state', + error: e, + stackTrace: stack, + ); + if (ref.mounted) { + _update( + (current) => current.copyWith( + status: AuthStatus.error, + error: e.toString(), + isLoading: false, + clearToken: true, + ), + ); + } + rethrow; + } + } + /// Wait briefly until the API service becomes available Future _ensureApiServiceAvailable({ Duration timeout = const Duration(seconds: 2), @@ -582,12 +701,27 @@ class AuthStateManager extends _$AuthStateManager { return false; } - // Attempt login (detect API key vs normal credentials) - if (username == 'api_key_user' || username == 'jwt_user') { - // This is a saved JWT token (or legacy API key) - return await loginWithApiKey(password, rememberCredentials: false); + // Attempt login based on auth type + final authType = savedCredentials['authType'] ?? 'credentials'; + + // Handle JWT token-based authentication (includes legacy prefixes) + // LDAP now also stores JWT tokens for re-auth (not raw passwords) + if (username == 'api_key_user' || + username == 'jwt_user' || + username.startsWith('ldap:') || + authType == 'token' || + authType == 'sso' || + authType == 'ldap') { + // This is a saved JWT token (manual entry, SSO, or LDAP-obtained) + // For LDAP, we store the JWT token returned by the server, not the + // original password, for security reasons + return await loginWithApiKey( + password, // This is the JWT token + rememberCredentials: false, + authType: authType, + ); } else { - // Normal username/password credentials + // Standard credentials login (default) return await login(username, password, rememberCredentials: false); } } catch (e, stack) { @@ -805,6 +939,20 @@ class AuthStateManager extends _$AuthStateManager { await storage.clearAuthData(); _updateApiServiceToken(null); + // Clear WebView cookies to ensure fresh SSO sessions on next login + try { + final cleared = await WebViewCookieHelper.clearCookies(); + if (cleared) { + DebugLogger.auth('WebView cookies cleared'); + } + } catch (e) { + DebugLogger.warning( + 'webview-cookie-clear-failed', + scope: 'auth/state', + data: {'error': e.toString()}, + ); + } + // Keep active server ID so router redirects to sign-in page, not server // connection page. Users can navigate to server settings if they need to // change server configuration. diff --git a/lib/core/auth/webview_cookie_helper.dart b/lib/core/auth/webview_cookie_helper.dart new file mode 100644 index 0000000..e59ad97 --- /dev/null +++ b/lib/core/auth/webview_cookie_helper.dart @@ -0,0 +1,33 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// Check if WebView is supported on the current platform. +/// +/// webview_flutter only supports iOS and Android. +bool get isWebViewSupported => + !kIsWeb && (Platform.isIOS || Platform.isAndroid); + +/// Helper for clearing WebView cookies on supported platforms. +/// +/// This is isolated in its own file to prevent platform coupling issues +/// when the webview_flutter package isn't available. +class WebViewCookieHelper { + /// Clears all WebView cookies. + /// + /// Returns true if cookies were cleared, false if not supported or failed. + /// Checks platform support internally, so safe to call on any platform. + static Future clearCookies() async { + // Only supported on mobile platforms + if (!isWebViewSupported) return false; + + try { + return await WebViewCookieManager().clearCookies(); + } catch (e) { + // Silently fail - WebView may not be available + return false; + } + } +} + diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1ad2d03..d81aba5 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -14,6 +14,7 @@ import '../../features/auth/views/authentication_page.dart'; import '../../features/auth/views/connect_signin_page.dart'; import '../../features/auth/views/connection_issue_page.dart'; import '../../features/auth/views/server_connection_page.dart'; +import '../../features/auth/views/sso_auth_page.dart'; import '../../features/chat/views/chat_page.dart'; import '../../features/navigation/views/splash_launcher_page.dart'; import '../../features/notes/views/notes_list_page.dart'; @@ -237,6 +238,16 @@ final goRouterProvider = Provider((ref) { ); }, ), + GoRoute( + path: Routes.ssoAuth, + name: RouteNames.ssoAuth, + builder: (context, state) { + final config = state.extra; + return SsoAuthPage( + serverConfig: config is ServerConfig ? config : null, + ); + }, + ), GoRoute( path: Routes.profile, name: RouteNames.profile, diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index dc06354..bd38e9a 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -355,6 +355,46 @@ class ApiService { await _dio.get('/api/v1/auths/signout'); } + /// LDAP authentication - uses username instead of email. + /// + /// Returns the same response format as regular login: + /// `{"token": "...", "token_type": "Bearer", "id": "...", ...}` + /// + /// Throws an exception if LDAP is not enabled on the server (400 response). + Future> ldapLogin( + String username, + String password, + ) async { + try { + final response = await _dio.post( + '/api/v1/auths/ldap', + data: {'user': username, 'password': password}, + ); + + return response.data; + } catch (e) { + if (e is DioException) { + // Handle LDAP not enabled + if (e.response?.statusCode == 400) { + final data = e.response?.data; + if (data is Map && data['detail'] != null) { + throw Exception(data['detail']); + } + } + // Handle specific redirect cases + if (e.response?.statusCode == 307 || e.response?.statusCode == 308) { + final location = e.response?.headers.value('location'); + if (location != null) { + throw Exception( + 'Server redirect detected. Please check your server URL configuration. Redirect to: $location', + ); + } + } + } + rethrow; + } + } + // User info Future getCurrentUser() async { final response = await _dio.get('/api/v1/auths/'); diff --git a/lib/core/services/navigation_service.dart b/lib/core/services/navigation_service.dart index d583319..573fd09 100644 --- a/lib/core/services/navigation_service.dart +++ b/lib/core/services/navigation_service.dart @@ -101,6 +101,7 @@ class Routes { static const String serverConnection = '/server-connection'; static const String connectionIssue = '/connection-issue'; static const String authentication = '/authentication'; + static const String ssoAuth = '/sso-auth'; static const String profile = '/profile'; static const String appCustomization = '/profile/customization'; static const String notes = '/notes'; @@ -115,6 +116,7 @@ class RouteNames { static const String serverConnection = 'server-connection'; static const String connectionIssue = 'connection-issue'; static const String authentication = 'authentication'; + static const String ssoAuth = 'sso-auth'; static const String profile = 'profile'; static const String appCustomization = 'app-customization'; static const String notes = 'notes'; diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart index bc8f447..1413513 100644 --- a/lib/core/services/optimized_storage_service.dart +++ b/lib/core/services/optimized_storage_service.dart @@ -132,12 +132,14 @@ class OptimizedStorageService { required String serverId, required String username, required String password, + String authType = 'credentials', }) async { try { await _secureCredentialStorage.saveCredentials( serverId: serverId, username: username, password: password, + authType: authType, ); _cacheManager.write('has_credentials', true, ttl: _credentialsFlagTtl); diff --git a/lib/core/services/secure_credential_storage.dart b/lib/core/services/secure_credential_storage.dart index d0028e0..304ca6d 100644 --- a/lib/core/services/secure_credential_storage.dart +++ b/lib/core/services/secure_credential_storage.dart @@ -40,11 +40,18 @@ class SecureCredentialStorage { ); } - /// Save user credentials securely + /// Save user credentials securely. + /// + /// [authType] identifies the authentication method: + /// - 'credentials': Standard email/password login (default) + /// - 'ldap': LDAP directory authentication + /// - 'token': Manual JWT token entry + /// - 'sso': JWT token obtained via SSO/OAuth flow Future saveCredentials({ required String serverId, required String username, required String password, + String authType = 'credentials', }) async { try { // First check if secure storage is available @@ -57,9 +64,10 @@ class SecureCredentialStorage { 'serverId': serverId, 'username': username, 'password': password, + 'authType': authType, 'savedAt': DateTime.now().toIso8601String(), 'deviceId': await _getDeviceFingerprint(), - 'version': '2.0', // Version for migration purposes + 'version': '2.1', // Version for migration purposes }; final encryptedData = await _encryptData(jsonEncode(credentials)); @@ -76,7 +84,7 @@ class SecureCredentialStorage { DebugLogger.storage( 'save-ok', scope: 'credentials', - data: {'version': '2.0'}, + data: {'version': '2.1'}, ); } catch (e) { DebugLogger.error('save-failed', scope: 'credentials', error: e); @@ -156,6 +164,7 @@ class SecureCredentialStorage { 'username': decoded['username']?.toString() ?? '', 'password': decoded['password']?.toString() ?? '', 'savedAt': decoded['savedAt']?.toString() ?? '', + 'authType': decoded['authType']?.toString() ?? 'credentials', }; } catch (e) { DebugLogger.error('read-failed', scope: 'credentials', error: e); @@ -355,7 +364,9 @@ class SecureCredentialStorage { } } - /// Migrate from old storage format if needed + /// Migrate from old storage format if needed. + /// + /// Preserves the [authType] if present in old credentials. Future migrateFromOldStorage( Map? oldCredentials, ) async { @@ -366,6 +377,7 @@ class SecureCredentialStorage { serverId: oldCredentials['serverId'] ?? '', username: oldCredentials['username'] ?? '', password: oldCredentials['password'] ?? '', + authType: oldCredentials['authType'] ?? 'credentials', ); DebugLogger.storage('migrate-ok', scope: 'credentials'); } catch (e) { diff --git a/lib/features/auth/providers/unified_auth_providers.dart b/lib/features/auth/providers/unified_auth_providers.dart index 37ca30f..ab84f8c 100644 --- a/lib/features/auth/providers/unified_auth_providers.dart +++ b/lib/features/auth/providers/unified_auth_providers.dart @@ -29,10 +29,24 @@ class AuthActions { Future loginWithApiKey( String apiKey, { bool rememberCredentials = false, + String authType = 'token', }) { return _auth.loginWithApiKey( apiKey, rememberCredentials: rememberCredentials, + authType: authType, + ); + } + + Future ldapLogin( + String username, + String password, { + bool rememberCredentials = false, + }) { + return _auth.ldapLogin( + username, + password, + rememberCredentials: rememberCredentials, ); } diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart index 9150c77..5a19d9d 100644 --- a/lib/features/auth/views/authentication_page.dart +++ b/lib/features/auth/views/authentication_page.dart @@ -17,6 +17,15 @@ import '../../../core/auth/auth_state_manager.dart'; import '../../../core/utils/debug_logger.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../providers/unified_auth_providers.dart'; +import '../../../core/auth/webview_cookie_helper.dart' show isWebViewSupported; + +/// Authentication mode options +enum AuthMode { + credentials, // Email/password + token, // JWT token + sso, // OAuth/OIDC via WebView + ldap, // LDAP username/password +} class AuthenticationPage extends ConsumerStatefulWidget { final ServerConfig? serverConfig; @@ -32,9 +41,11 @@ class _AuthenticationPageState extends ConsumerState { final TextEditingController _usernameController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); final TextEditingController _apiKeyController = TextEditingController(); + final TextEditingController _ldapUsernameController = TextEditingController(); + final TextEditingController _ldapPasswordController = TextEditingController(); bool _obscurePassword = true; - bool _useApiKey = false; + AuthMode _authMode = AuthMode.credentials; String? _loginError; bool _isSigningIn = false; bool _serverConfigSaved = false; @@ -56,7 +67,7 @@ class _AuthenticationPageState extends ConsumerState { _loginError = _formatLoginError(authState.error!); // Switch to token tab if the error is about API keys if (authState.error!.contains('apiKey')) { - _useApiKey = true; + _authMode = AuthMode.token; } }); } @@ -77,6 +88,8 @@ class _AuthenticationPageState extends ConsumerState { _usernameController.dispose(); _passwordController.dispose(); _apiKeyController.dispose(); + _ldapUsernameController.dispose(); + _ldapPasswordController.dispose(); super.dispose(); } @@ -100,17 +113,27 @@ class _AuthenticationPageState extends ConsumerState { final actions = ref.read(authActionsProvider); bool success; - if (_useApiKey) { - success = await actions.loginWithApiKey( - _apiKeyController.text.trim(), - rememberCredentials: true, - ); - } else { - success = await actions.login( - _usernameController.text.trim(), - _passwordController.text, - rememberCredentials: true, - ); + switch (_authMode) { + case AuthMode.credentials: + success = await actions.login( + _usernameController.text.trim(), + _passwordController.text, + rememberCredentials: true, + ); + case AuthMode.token: + success = await actions.loginWithApiKey( + _apiKeyController.text.trim(), + rememberCredentials: true, + ); + case AuthMode.ldap: + success = await actions.ldapLogin( + _ldapUsernameController.text.trim(), + _ldapPasswordController.text, + rememberCredentials: true, + ); + case AuthMode.sso: + // SSO is handled by navigating to SsoAuthPage + return; } if (!success) { @@ -149,6 +172,8 @@ class _AuthenticationPageState extends ConsumerState { return l10n.apiKeyNotSupported; } else if (error.contains('apiKeyNoLongerSupported')) { return l10n.apiKeyNoLongerSupported; + } else if (error.contains('LDAP authentication is not enabled')) { + return l10n.ldapNotEnabled; } else if (error.contains('401') || error.contains('Unauthorized')) { return l10n.invalidCredentials; } else if (error.contains('redirect')) { @@ -374,10 +399,12 @@ class _AuthenticationPageState extends ConsumerState { } Widget _buildAuthForm() { + final l10n = AppLocalizations.of(context)!; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Authentication mode toggle + // Primary authentication mode toggle (Credentials/Token) _buildAuthModeToggle(), const SizedBox(height: Spacing.lg), @@ -389,11 +416,19 @@ class _AuthenticationPageState extends ConsumerState { const SizedBox(height: Spacing.md), _buildErrorMessage(_loginError!), ], + + // More options section (SSO/LDAP) + const SizedBox(height: Spacing.lg), + _buildMoreOptionsSection(l10n), ], ); } Widget _buildAuthModeToggle() { + final l10n = AppLocalizations.of(context)!; + final isPrimaryMode = + _authMode == AuthMode.credentials || _authMode == AuthMode.token; + return Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration( @@ -411,9 +446,13 @@ class _AuthenticationPageState extends ConsumerState { icon: Platform.isIOS ? CupertinoIcons.person_circle : Icons.account_circle_outlined, - label: AppLocalizations.of(context)!.credentials, - isSelected: !_useApiKey, - onTap: () => setState(() => _useApiKey = false), + label: l10n.credentials, + isSelected: _authMode == AuthMode.credentials && isPrimaryMode, + onTap: () => setState(() { + _authMode = AuthMode.credentials; + _loginError = null; + _obscurePassword = true; // Reset visibility on mode change + }), ), ), Expanded( @@ -421,9 +460,13 @@ class _AuthenticationPageState extends ConsumerState { icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key_outlined, - label: AppLocalizations.of(context)!.token, - isSelected: _useApiKey, - onTap: () => setState(() => _useApiKey = true), + label: l10n.token, + isSelected: _authMode == AuthMode.token && isPrimaryMode, + onTap: () => setState(() { + _authMode = AuthMode.token; + _loginError = null; + _obscurePassword = true; // Reset visibility on mode change + }), ), ), ], @@ -498,10 +541,23 @@ class _AuthenticationPageState extends ConsumerState { ), ); }, - child: _useApiKey ? _buildApiKeyForm() : _buildCredentialsForm(), + child: _buildCurrentAuthForm(), ); } + Widget _buildCurrentAuthForm() { + switch (_authMode) { + case AuthMode.credentials: + return _buildCredentialsForm(); + case AuthMode.token: + return _buildApiKeyForm(); + case AuthMode.ldap: + return _buildLdapForm(); + case AuthMode.sso: + return _buildSsoPrompt(); + } + } + /// Validates that a token is a JWT and not an API key. /// API keys (sk-, api-, key-) don't work with WebSocket authentication. String? _validateJwtToken(String? value) { @@ -632,15 +688,287 @@ class _AuthenticationPageState extends ConsumerState { ); } + Widget _buildLdapForm() { + final l10n = AppLocalizations.of(context)!; + + return Column( + key: const ValueKey('ldap_form'), + children: [ + AccessibleFormField( + label: l10n.ldapUsername, + hint: l10n.ldapUsernameHint, + controller: _ldapUsernameController, + validator: InputValidationService.validateRequired, + keyboardType: TextInputType.text, + semanticLabel: l10n.ldapUsernameHint, + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.person : Icons.person_outline, + color: context.conduitTheme.iconSecondary, + ), + autofillHints: const [AutofillHints.username], + isRequired: true, + ), + const SizedBox(height: Spacing.lg), + AccessibleFormField( + label: l10n.password, + hint: l10n.passwordHint, + controller: _ldapPasswordController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => InputValidationService.validateMinLength( + value, + 1, + fieldName: l10n.password, + ), + ]), + obscureText: _obscurePassword, + semanticLabel: l10n.passwordHint, + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.lock : Icons.lock_outline, + color: context.conduitTheme.iconSecondary, + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? (Platform.isIOS + ? CupertinoIcons.eye_slash + : Icons.visibility_off) + : (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility), + color: context.conduitTheme.iconSecondary, + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + onSubmitted: (_) => _signIn(), + autofillHints: const [AutofillHints.password], + isRequired: true, + ), + const SizedBox(height: Spacing.sm), + Text( + l10n.ldapDescription, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ); + } + + Widget _buildSsoPrompt() { + final l10n = AppLocalizations.of(context)!; + + return Column( + key: const ValueKey('sso_form'), + children: [ + Container( + padding: const EdgeInsets.all(Spacing.lg), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.medium), + border: Border.all( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), + width: BorderWidth.standard, + ), + ), + child: Column( + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.lock_shield : Icons.security, + size: IconSize.xxl, + color: context.conduitTheme.buttonPrimary, + ), + const SizedBox(height: Spacing.md), + Text(l10n.sso, style: context.conduitTheme.headingMedium), + const SizedBox(height: Spacing.sm), + Text( + l10n.ssoDescription, + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.lg), + ConduitButton( + text: l10n.signInWithSso, + icon: Platform.isIOS + ? CupertinoIcons.arrow_right + : Icons.arrow_forward, + onPressed: _navigateToSso, + isFullWidth: true, + ), + ], + ), + ), + ], + ); + } + + Future _navigateToSso() async { + if (!mounted) return; + + // Save server config first if needed + if (widget.serverConfig != null && !_serverConfigSaved) { + await _saveServerConfig(widget.serverConfig!); + _serverConfigSaved = true; + if (!mounted) return; + } + + context.pushNamed(RouteNames.ssoAuth, extra: widget.serverConfig); + } + + Widget _buildMoreOptionsSection(AppLocalizations l10n) { + return Column( + children: [ + // Divider with "or" text + Row( + children: [ + Expanded( + child: Divider( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + child: Text( + l10n.moreSignInOptions, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ), + Expanded( + child: Divider( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.5), + ), + ), + ], + ), + const SizedBox(height: Spacing.md), + + // SSO and LDAP buttons + // SSO is only available on platforms that support WebView (iOS/Android) + Row( + children: [ + if (isWebViewSupported) ...[ + Expanded( + child: _buildOptionButton( + icon: Platform.isIOS + ? CupertinoIcons.lock_shield + : Icons.security, + label: l10n.sso, + isSelected: _authMode == AuthMode.sso, + onTap: () => setState(() { + _authMode = AuthMode.sso; + _loginError = null; + _obscurePassword = true; // Reset visibility on mode change + }), + ), + ), + const SizedBox(width: Spacing.sm), + ], + Expanded( + child: _buildOptionButton( + icon: Platform.isIOS + ? CupertinoIcons.building_2_fill + : Icons.domain, + label: l10n.ldap, + isSelected: _authMode == AuthMode.ldap, + onTap: () => setState(() { + _authMode = AuthMode.ldap; + _loginError = null; + _obscurePassword = true; // Reset visibility on mode change + }), + ), + ), + ], + ), + ], + ); + } + + Widget _buildOptionButton({ + required IconData icon, + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + return Material( + color: isSelected + ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1) + : context.conduitTheme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.small), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppBorderRadius.small), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: Spacing.md, + horizontal: Spacing.sm, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppBorderRadius.small), + border: Border.all( + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.dividerColor.withValues(alpha: 0.5), + width: BorderWidth.standard, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: IconSize.small, + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.xs), + Text( + label, + style: context.conduitTheme.bodySmall?.copyWith( + color: isSelected + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildSignInButton() { + final l10n = AppLocalizations.of(context)!; + + // Don't show sign-in button for SSO mode (it has its own button) + if (_authMode == AuthMode.sso) { + return const SizedBox.shrink(); + } + + String buttonText; + if (_isSigningIn) { + buttonText = l10n.signingIn; + } else { + switch (_authMode) { + case AuthMode.credentials: + buttonText = l10n.signIn; + case AuthMode.token: + buttonText = l10n.signInWithToken; + case AuthMode.ldap: + buttonText = l10n.signInWithLdap; + case AuthMode.sso: + buttonText = l10n.signInWithSso; + } + } + return Padding( padding: const EdgeInsets.only(top: Spacing.lg), child: ConduitButton( - text: _isSigningIn - ? AppLocalizations.of(context)!.signingIn - : _useApiKey - ? AppLocalizations.of(context)!.signInWithToken - : AppLocalizations.of(context)!.signIn, + text: buttonText, icon: _isSigningIn ? null : (Platform.isIOS diff --git a/lib/features/auth/views/sso_auth_page.dart b/lib/features/auth/views/sso_auth_page.dart new file mode 100644 index 0000000..da99682 --- /dev/null +++ b/lib/features/auth/views/sso_auth_page.dart @@ -0,0 +1,565 @@ +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../../../core/auth/webview_cookie_helper.dart'; +import '../../../core/models/server_config.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/services/navigation_service.dart'; +import '../../../core/utils/debug_logger.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import 'package:conduit/l10n/app_localizations.dart'; +import '../providers/unified_auth_providers.dart'; + +/// SSO Authentication page that uses a WebView to handle OAuth/OIDC flows. +/// +/// This page loads the Open-WebUI `/auth` page in a WebView, allowing users +/// to authenticate via configured OAuth providers (Google, Microsoft, GitHub, +/// OIDC, etc.). After successful authentication, the JWT token is captured +/// from cookies or localStorage and used to authenticate in Conduit. +class SsoAuthPage extends ConsumerStatefulWidget { + final ServerConfig? serverConfig; + + const SsoAuthPage({super.key, this.serverConfig}); + + @override + ConsumerState createState() => _SsoAuthPageState(); +} + +class _SsoAuthPageState extends ConsumerState { + WebViewController? _controller; + bool _isLoading = true; + bool _tokenCaptured = false; + String? _error; + String? _serverUrl; + int _captureAttemptId = 0; // Used to cancel stale retry sequences + + @override + void initState() { + super.initState(); + // Defer initialization to after first frame to ensure context is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initializeWebView(); + }); + } + + @override + void dispose() { + // Increment attempt ID to cancel any in-flight token capture operations + _captureAttemptId++; + // Clear controller reference (WebViewController doesn't have a dispose method, + // but setting to null ensures callbacks check mounted state) + _controller = null; + super.dispose(); + } + + Future _initializeWebView() async { + // Check platform support first - webview_flutter only supports iOS/Android + if (!isWebViewSupported) { + if (!mounted) return; + final l10n = AppLocalizations.of(context); + setState(() { + _error = + l10n?.ssoPlatformNotSupported ?? + 'SSO authentication is not supported on this platform. ' + 'Please use credentials or LDAP authentication instead.'; + _isLoading = false; + }); + return; + } + + // Get server URL from config or active server + final config = widget.serverConfig; + if (config != null) { + _serverUrl = config.url; + } else { + final activeServer = await ref.read(activeServerProvider.future); + if (!mounted) return; + _serverUrl = activeServer?.url; + } + + if (_serverUrl == null) { + if (!mounted) return; + setState(() { + _error = 'No server configured'; + _isLoading = false; + }); + return; + } + + DebugLogger.auth('Initializing SSO WebView for $_serverUrl'); + + final controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: _onPageStarted, + onPageFinished: _onPageFinished, + onWebResourceError: _onWebResourceError, + onNavigationRequest: _onNavigationRequest, + ), + ) + ..setUserAgent(_buildUserAgent()); + + // Clear cookies before loading to ensure fresh session + if (isWebViewSupported) { + await WebViewCookieManager().clearCookies(); + } + + if (!mounted) return; + + // Load the auth page + await controller.loadRequest(Uri.parse('$_serverUrl/auth')); + + if (!mounted) return; + + setState(() { + _controller = controller; + }); + } + + String _buildUserAgent() { + // Use a standard mobile browser user agent to ensure OAuth providers work correctly + // Note: webview_flutter only supports iOS and Android; guard against web to be safe + if (!kIsWeb && Platform.isIOS) { + return 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + } else { + // Android (or fallback) - use mobile Chrome + return 'Mozilla/5.0 (Linux; Android 14) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; + } + } + + void _onPageStarted(String url) { + DebugLogger.auth('SSO page started: $url'); + // Increment attempt ID to cancel any in-progress retry sequences + _captureAttemptId++; + setState(() { + _isLoading = true; + _error = null; + }); + } + + Future _onPageFinished(String url) async { + DebugLogger.auth('SSO page finished: $url'); + + setState(() { + _isLoading = false; + }); + + if (_tokenCaptured) return; + + final uri = Uri.parse(url); + + // Check for error parameter (OAuth failures redirect with ?error=...) + final error = uri.queryParameters['error']; + if (error != null && error.isNotEmpty) { + DebugLogger.auth('SSO error from URL: $error'); + setState(() { + _error = error; + }); + return; + } + + // Only check for token on /auth page (after OAuth callback redirect) + // or on the root page (some configurations) + if (!uri.path.endsWith('/auth') && uri.path != '/') return; + + // Wait a moment for the frontend to persist the token + // The OAuth callback sets the cookie, then redirects to /auth, + // where the frontend reads the cookie and stores it in localStorage + final attemptId = _captureAttemptId; + await _attemptTokenCaptureWithRetry(uri, attemptId: attemptId); + } + + /// Attempt token capture with retries to handle timing issues. + /// + /// The Open-WebUI frontend needs a moment to read the token cookie + /// and store it in localStorage after the OAuth redirect. + /// + /// [attemptId] is used to cancel this retry sequence if a new page load starts. + Future _attemptTokenCaptureWithRetry( + Uri uri, { + required int attemptId, + int maxAttempts = 3, + }) async { + for (int attempt = 0; attempt < maxAttempts; attempt++) { + // Cancel if token captured, widget disposed, or a new page load started + if (_tokenCaptured || !mounted || attemptId != _captureAttemptId) return; + + // Small delay to let frontend persist token (except on first attempt) + if (attempt > 0) { + await Future.delayed(const Duration(milliseconds: 500)); + // Re-check after delay in case state changed + if (_tokenCaptured || !mounted || attemptId != _captureAttemptId) { + return; + } + } + + final found = await _attemptTokenCapture(uri, attemptId: attemptId); + if (found) return; + } + + // After all attempts, token not found - user may still be in auth flow + // Only log if this is still the current attempt sequence + if (attemptId == _captureAttemptId) { + DebugLogger.auth( + 'No token found after $maxAttempts attempts, user may still be authenticating', + ); + } + } + + /// Attempts to capture the authentication token from cookies or localStorage. + /// + /// Returns true if a token was found and handled, false otherwise. + /// [attemptId] is checked to abort if a new page load started. + Future _attemptTokenCapture(Uri uri, {required int attemptId}) async { + final controller = _controller; + if (controller == null || !mounted) return false; + + // Abort if a new page load started + if (attemptId != _captureAttemptId) return false; + + // Strategy 1: Check token cookie via JavaScript + // Open-WebUI sets the token cookie with httponly=False, so it's accessible + try { + final cookieResult = await controller.runJavaScriptReturningResult( + '(function() {' + ' var cookies = document.cookie.split(";");' + ' for (var i = 0; i < cookies.length; i++) {' + ' var cookie = cookies[i].trim();' + ' if (cookie.startsWith("token=")) {' + ' return cookie.substring(6);' + ' }' + ' }' + ' return "";' + '})()', + ); + + // Abort if widget disposed or new page load started + if (!mounted || attemptId != _captureAttemptId) return false; + + String tokenValue = _cleanJsString(cookieResult.toString()); + if (tokenValue.isNotEmpty) { + DebugLogger.auth('Found token in cookie'); + await _handleToken(tokenValue); + return true; + } + } catch (e) { + DebugLogger.warning( + 'sso-cookie-read-failed', + scope: 'auth/sso', + data: {'error': e.toString()}, + ); + } + + // Abort if widget disposed or new page load started + if (!mounted || attemptId != _captureAttemptId) return false; + + // Strategy 2: Check localStorage (fallback - frontend sets this) + try { + final result = await controller.runJavaScriptReturningResult( + 'localStorage.getItem("token")', + ); + + // Abort if widget disposed or new page load started + if (!mounted || attemptId != _captureAttemptId) return false; + + String tokenValue = _cleanJsString(result.toString()); + if (tokenValue.isNotEmpty && tokenValue != 'null') { + DebugLogger.auth('Found token in localStorage'); + await _handleToken(tokenValue); + return true; + } + } catch (e) { + DebugLogger.warning( + 'sso-localstorage-read-failed', + scope: 'auth/sso', + data: {'error': e.toString()}, + ); + } + + return false; + } + + /// Clean JavaScript string result by removing surrounding quotes + String _cleanJsString(String value) { + if (value.startsWith('"') && value.endsWith('"')) { + return value.substring(1, value.length - 1); + } + return value; + } + + Future _handleToken(String token) async { + if (_tokenCaptured || !mounted) return; + + // Basic validation before attempting login + final trimmedToken = token.trim(); + // JWT tokens have 3 dot-separated segments and are typically 100+ chars + final isValidFormat = + trimmedToken.length >= 50 && trimmedToken.split('.').length == 3; + if (trimmedToken.isEmpty || !isValidFormat) { + DebugLogger.warning( + 'sso-token-invalid', + scope: 'auth/sso', + data: { + 'length': trimmedToken.length, + 'segments': trimmedToken.split('.').length, + }, + ); + return; // Invalid token, don't mark as captured - allow retry + } + + DebugLogger.auth('Handling captured SSO token'); + _tokenCaptured = true; + + setState(() { + _isLoading = true; + }); + + // Capture localized error message before async gap + final ssoFailedMessage = + AppLocalizations.of(context)?.ssoAuthFailed ?? + 'SSO authentication failed'; + + try { + final authActions = ref.read(authActionsProvider); + final success = await authActions.loginWithApiKey( + trimmedToken, + rememberCredentials: true, + authType: 'sso', // Mark as SSO-obtained token for traceability + ); + + if (!mounted) return; + + if (success) { + DebugLogger.auth('SSO login successful, navigating to chat'); + context.go(Routes.chat); + } else { + setState(() { + _error = ssoFailedMessage; + _isLoading = false; + _tokenCaptured = false; + }); + } + } catch (e) { + DebugLogger.error( + 'sso-token-handling-failed', + scope: 'auth/sso', + error: e, + ); + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + _tokenCaptured = false; + }); + } + } + + void _onWebResourceError(WebResourceError error) { + DebugLogger.error( + 'sso-webview-error', + scope: 'auth/sso', + data: { + 'errorCode': error.errorCode, + 'description': error.description, + 'errorType': error.errorType?.name, + }, + ); + + // Only show error for main frame failures + if (error.isForMainFrame ?? false) { + setState(() { + _error = error.description; + _isLoading = false; + }); + } + } + + NavigationDecision _onNavigationRequest(NavigationRequest request) { + final url = request.url; + DebugLogger.auth('SSO navigation request: $url'); + + // Allow all navigation - OAuth flows require redirects to external + // identity providers and back. The WebView is sandboxed and the token + // is only captured when the user returns to the Open-WebUI /auth page. + // + // We log the URL for debugging but don't restrict navigation since: + // 1. OAuth providers may use various redirect URLs + // 2. The user initiated this flow intentionally + // 3. Token capture only happens on the configured server's /auth page + return NavigationDecision.navigate; + } + + Future _refresh() async { + final controller = _controller; + if (controller == null || _serverUrl == null) return; + + setState(() { + _isLoading = true; + _error = null; + _tokenCaptured = false; + }); + + // Clear cookies and reload (with platform guard) + if (isWebViewSupported) { + await WebViewCookieManager().clearCookies(); + } + + if (!mounted) return; + + await controller.loadRequest(Uri.parse('$_serverUrl/auth')); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return ErrorBoundary( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: 0, + leading: ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back, + onPressed: () => context.pop(), + tooltip: l10n?.back ?? 'Back', + ), + title: Text( + l10n?.sso ?? 'SSO', + style: context.conduitTheme.headingMedium, + ), + centerTitle: true, + actions: [ + if (_controller != null) + ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + onPressed: _refresh, + tooltip: l10n?.retry ?? 'Retry', + ), + ], + ), + body: SafeArea(child: _buildBody(l10n)), + ), + ); + } + + Widget _buildBody(AppLocalizations? l10n) { + if (_error != null) { + return _buildErrorState(l10n); + } + + // Guard against rendering WebView on unsupported platforms + if (_controller == null || !isWebViewSupported) { + return _buildLoadingState(l10n); + } + + return Stack( + children: [ + WebViewWidget(controller: _controller!), + if (_isLoading) _buildLoadingOverlay(l10n), + ], + ); + } + + Widget _buildLoadingState(AppLocalizations? l10n) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: Spacing.lg), + Text( + l10n?.ssoLoadingLogin ?? 'Loading login page...', + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildLoadingOverlay(AppLocalizations? l10n) { + return Positioned.fill( + child: Container( + color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.8), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: Spacing.lg), + Text( + _tokenCaptured + ? (l10n?.ssoAuthenticating ?? 'Authenticating...') + : (l10n?.ssoLoadingLogin ?? 'Loading...'), + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildErrorState(AppLocalizations? l10n) { + return Padding( + padding: const EdgeInsets.all(Spacing.pagePadding), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle + : Icons.error_outline, + size: IconSize.xxl, + color: context.conduitTheme.error, + ), + const SizedBox(height: Spacing.lg), + Text( + l10n?.ssoAuthFailed ?? 'SSO authentication failed', + style: context.conduitTheme.headingMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.sm), + Text( + _error ?? '', + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.xl), + ConduitButton( + text: l10n?.retry ?? 'Retry', + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + onPressed: _refresh, + ), + const SizedBox(height: Spacing.md), + ConduitButton( + text: l10n?.back ?? 'Back', + icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back, + onPressed: () => context.pop(), + isSecondary: true, + ), + ], + ), + ), + ); + } +} + diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 49ada17..7497462 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -862,5 +862,20 @@ "widgetCamera": "Kamera", "widgetPhotos": "Fotos", "widgetClipboard": "Zwischenablage", - "widgetDescription": "Schnellzugriff auf den Conduit-Chat mit Kamera, Fotos und Zwischenablage-Verknüpfungen" + "widgetDescription": "Schnellzugriff auf den Conduit-Chat mit Kamera, Fotos und Zwischenablage-Verknüpfungen", + "sso": "SSO", + "ssoDescription": "Mit dem Identitätsanbieter Ihrer Organisation anmelden", + "signInWithSso": "Mit SSO anmelden", + "ssoAuthenticating": "Authentifizierung...", + "ssoAuthFailed": "SSO-Authentifizierung fehlgeschlagen", + "ssoTokenNotFound": "Authentifizierungstoken vom SSO-Anbieter konnte nicht abgerufen werden", + "ssoLoadingLogin": "Anmeldeseite wird geladen...", + "ldap": "LDAP", + "ldapDescription": "Mit Ihren LDAP-Verzeichnis-Anmeldedaten anmelden", + "signInWithLdap": "Mit LDAP anmelden", + "ldapUsername": "Benutzername", + "ldapUsernameHint": "Geben Sie Ihren LDAP-Benutzernamen ein", + "moreSignInOptions": "Weitere Anmeldeoptionen", + "ldapNotEnabled": "LDAP-Authentifizierung ist auf diesem Server nicht aktiviert", + "ssoPlatformNotSupported": "SSO-Authentifizierung wird auf dieser Plattform nicht unterstützt. Bitte verwenden Sie stattdessen Anmeldedaten oder LDAP-Authentifizierung." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 20032f0..1de9113 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1858,5 +1858,65 @@ "example": "100" } } + }, + "sso": "SSO", + "@sso": { + "description": "Label for Single Sign-On authentication option." + }, + "ssoDescription": "Sign in with your organization's identity provider", + "@ssoDescription": { + "description": "Description text explaining SSO authentication." + }, + "signInWithSso": "Sign in with SSO", + "@signInWithSso": { + "description": "Button text for SSO sign-in." + }, + "ssoAuthenticating": "Authenticating...", + "@ssoAuthenticating": { + "description": "Loading message during SSO authentication." + }, + "ssoAuthFailed": "SSO authentication failed", + "@ssoAuthFailed": { + "description": "Error message when SSO authentication fails." + }, + "ssoTokenNotFound": "Could not retrieve authentication token from SSO provider", + "@ssoTokenNotFound": { + "description": "Error message when SSO token cannot be captured." + }, + "ssoLoadingLogin": "Loading login page...", + "@ssoLoadingLogin": { + "description": "Loading message while SSO login page loads." + }, + "ldap": "LDAP", + "@ldap": { + "description": "Label for LDAP authentication option." + }, + "ldapDescription": "Sign in with your LDAP directory credentials", + "@ldapDescription": { + "description": "Description text explaining LDAP authentication." + }, + "signInWithLdap": "Sign in with LDAP", + "@signInWithLdap": { + "description": "Button text for LDAP sign-in." + }, + "ldapUsername": "Username", + "@ldapUsername": { + "description": "Label for LDAP username field." + }, + "ldapUsernameHint": "Enter your LDAP username", + "@ldapUsernameHint": { + "description": "Hint text for LDAP username field." + }, + "moreSignInOptions": "More sign-in options", + "@moreSignInOptions": { + "description": "Section header for additional authentication methods." + }, + "ldapNotEnabled": "LDAP authentication is not enabled on this server", + "@ldapNotEnabled": { + "description": "Error message when LDAP is not configured on the server." + }, + "ssoPlatformNotSupported": "SSO authentication is not supported on this platform. Please use credentials or LDAP authentication instead.", + "@ssoPlatformNotSupported": { + "description": "Error message when SSO is attempted on an unsupported platform (desktop/web)." } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index bd33bfb..1b2cf10 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -862,5 +862,20 @@ "widgetCamera": "Cámara", "widgetPhotos": "Fotos", "widgetClipboard": "Portapapeles", - "widgetDescription": "Acceso rápido al chat de Conduit con cámara, fotos y atajos del portapapeles" + "widgetDescription": "Acceso rápido al chat de Conduit con cámara, fotos y atajos del portapapeles", + "sso": "SSO", + "ssoDescription": "Iniciar sesión con el proveedor de identidad de su organización", + "signInWithSso": "Iniciar sesión con SSO", + "ssoAuthenticating": "Autenticando...", + "ssoAuthFailed": "Error de autenticación SSO", + "ssoTokenNotFound": "No se pudo obtener el token de autenticación del proveedor SSO", + "ssoLoadingLogin": "Cargando página de inicio de sesión...", + "ldap": "LDAP", + "ldapDescription": "Iniciar sesión con sus credenciales de directorio LDAP", + "signInWithLdap": "Iniciar sesión con LDAP", + "ldapUsername": "Nombre de usuario", + "ldapUsernameHint": "Ingrese su nombre de usuario LDAP", + "moreSignInOptions": "Más opciones de inicio de sesión", + "ldapNotEnabled": "La autenticación LDAP no está habilitada en este servidor", + "ssoPlatformNotSupported": "La autenticación SSO no es compatible con esta plataforma. Por favor, use credenciales o autenticación LDAP en su lugar." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e3aa888..082e6dd 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -862,5 +862,20 @@ "widgetCamera": "Appareil photo", "widgetPhotos": "Photos", "widgetClipboard": "Presse-papiers", - "widgetDescription": "Accès rapide au chat Conduit avec appareil photo, photos et raccourcis du presse-papiers" + "widgetDescription": "Accès rapide au chat Conduit avec appareil photo, photos et raccourcis du presse-papiers", + "sso": "SSO", + "ssoDescription": "Connectez-vous avec le fournisseur d'identité de votre organisation", + "signInWithSso": "Se connecter avec SSO", + "ssoAuthenticating": "Authentification...", + "ssoAuthFailed": "Échec de l'authentification SSO", + "ssoTokenNotFound": "Impossible de récupérer le jeton d'authentification du fournisseur SSO", + "ssoLoadingLogin": "Chargement de la page de connexion...", + "ldap": "LDAP", + "ldapDescription": "Connectez-vous avec vos identifiants d'annuaire LDAP", + "signInWithLdap": "Se connecter avec LDAP", + "ldapUsername": "Nom d'utilisateur", + "ldapUsernameHint": "Entrez votre nom d'utilisateur LDAP", + "moreSignInOptions": "Plus d'options de connexion", + "ldapNotEnabled": "L'authentification LDAP n'est pas activée sur ce serveur", + "ssoPlatformNotSupported": "L'authentification SSO n'est pas prise en charge sur cette plateforme. Veuillez utiliser les identifiants ou l'authentification LDAP à la place." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 6d8bedd..18462f0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -862,5 +862,20 @@ "widgetCamera": "Fotocamera", "widgetPhotos": "Foto", "widgetClipboard": "Appunti", - "widgetDescription": "Accesso rapido alla chat di Conduit con fotocamera, foto e scorciatoie degli appunti" + "widgetDescription": "Accesso rapido alla chat di Conduit con fotocamera, foto e scorciatoie degli appunti", + "sso": "SSO", + "ssoDescription": "Accedi con il provider di identità della tua organizzazione", + "signInWithSso": "Accedi con SSO", + "ssoAuthenticating": "Autenticazione...", + "ssoAuthFailed": "Autenticazione SSO fallita", + "ssoTokenNotFound": "Impossibile recuperare il token di autenticazione dal provider SSO", + "ssoLoadingLogin": "Caricamento pagina di accesso...", + "ldap": "LDAP", + "ldapDescription": "Accedi con le credenziali della directory LDAP", + "signInWithLdap": "Accedi con LDAP", + "ldapUsername": "Nome utente", + "ldapUsernameHint": "Inserisci il tuo nome utente LDAP", + "moreSignInOptions": "Altre opzioni di accesso", + "ldapNotEnabled": "L'autenticazione LDAP non è abilitata su questo server", + "ssoPlatformNotSupported": "L'autenticazione SSO non è supportata su questa piattaforma. Usa invece le credenziali o l'autenticazione LDAP." } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9dee5cf..712dd0c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -640,5 +640,20 @@ "widgetCamera": "카메라", "widgetPhotos": "사진", "widgetClipboard": "클립보드", - "widgetDescription": "카메라, 사진 및 클립보드 바로가기로 Conduit 채팅에 빠르게 액세스" + "widgetDescription": "카메라, 사진 및 클립보드 바로가기로 Conduit 채팅에 빠르게 액세스", + "sso": "SSO", + "ssoDescription": "조직의 ID 공급자로 로그인", + "signInWithSso": "SSO로 로그인", + "ssoAuthenticating": "인증 중...", + "ssoAuthFailed": "SSO 인증 실패", + "ssoTokenNotFound": "SSO 공급자에서 인증 토큰을 검색할 수 없습니다", + "ssoLoadingLogin": "로그인 페이지 로딩 중...", + "ldap": "LDAP", + "ldapDescription": "LDAP 디렉토리 자격 증명으로 로그인", + "signInWithLdap": "LDAP으로 로그인", + "ldapUsername": "사용자 이름", + "ldapUsernameHint": "LDAP 사용자 이름을 입력하세요", + "moreSignInOptions": "추가 로그인 옵션", + "ldapNotEnabled": "이 서버에서 LDAP 인증이 활성화되어 있지 않습니다", + "ssoPlatformNotSupported": "이 플랫폼에서는 SSO 인증이 지원되지 않습니다. 대신 자격 증명 또는 LDAP 인증을 사용하세요." } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 9a333ad..3d2db17 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -862,5 +862,20 @@ "widgetCamera": "Camera", "widgetPhotos": "Foto's", "widgetClipboard": "Klembord", - "widgetDescription": "Snelle toegang tot Conduit-chat met camera, foto's en klembordsnelkoppelingen" + "widgetDescription": "Snelle toegang tot Conduit-chat met camera, foto's en klembordsnelkoppelingen", + "sso": "SSO", + "ssoDescription": "Aanmelden met de identiteitsprovider van uw organisatie", + "signInWithSso": "Aanmelden met SSO", + "ssoAuthenticating": "Authenticeren...", + "ssoAuthFailed": "SSO-authenticatie mislukt", + "ssoTokenNotFound": "Kan authenticatietoken niet ophalen van SSO-provider", + "ssoLoadingLogin": "Inlogpagina laden...", + "ldap": "LDAP", + "ldapDescription": "Aanmelden met uw LDAP-directorygegevens", + "signInWithLdap": "Aanmelden met LDAP", + "ldapUsername": "Gebruikersnaam", + "ldapUsernameHint": "Voer uw LDAP-gebruikersnaam in", + "moreSignInOptions": "Meer aanmeldopties", + "ldapNotEnabled": "LDAP-authenticatie is niet ingeschakeld op deze server", + "ssoPlatformNotSupported": "SSO-authenticatie wordt niet ondersteund op dit platform. Gebruik in plaats daarvan inloggegevens of LDAP-authenticatie." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1627f20..02146fe 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -862,5 +862,20 @@ "widgetCamera": "Камера", "widgetPhotos": "Фотографии", "widgetClipboard": "Буфер обмена", - "widgetDescription": "Быстрый доступ к чату Conduit с камерой, фотографиями и буфером обмена" + "widgetDescription": "Быстрый доступ к чату Conduit с камерой, фотографиями и буфером обмена", + "sso": "SSO", + "ssoDescription": "Войдите через провайдера идентификации вашей организации", + "signInWithSso": "Войти через SSO", + "ssoAuthenticating": "Аутентификация...", + "ssoAuthFailed": "Ошибка SSO-аутентификации", + "ssoTokenNotFound": "Не удалось получить токен аутентификации от провайдера SSO", + "ssoLoadingLogin": "Загрузка страницы входа...", + "ldap": "LDAP", + "ldapDescription": "Войдите с учётными данными каталога LDAP", + "signInWithLdap": "Войти через LDAP", + "ldapUsername": "Имя пользователя", + "ldapUsernameHint": "Введите имя пользователя LDAP", + "moreSignInOptions": "Дополнительные способы входа", + "ldapNotEnabled": "LDAP-аутентификация не включена на этом сервере", + "ssoPlatformNotSupported": "SSO-аутентификация не поддерживается на этой платформе. Пожалуйста, используйте учётные данные или LDAP-аутентификацию." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a8e3905..f41467a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -862,5 +862,20 @@ "widgetCamera": "相机", "widgetPhotos": "照片", "widgetClipboard": "剪贴板", - "widgetDescription": "快速访问 Conduit 聊天,支持相机、照片和剪贴板快捷方式" + "widgetDescription": "快速访问 Conduit 聊天,支持相机、照片和剪贴板快捷方式", + "sso": "SSO", + "ssoDescription": "使用组织的身份提供商登录", + "signInWithSso": "使用 SSO 登录", + "ssoAuthenticating": "正在验证...", + "ssoAuthFailed": "SSO 验证失败", + "ssoTokenNotFound": "无法从 SSO 提供商获取验证令牌", + "ssoLoadingLogin": "正在加载登录页面...", + "ldap": "LDAP", + "ldapDescription": "使用 LDAP 目录凭据登录", + "signInWithLdap": "使用 LDAP 登录", + "ldapUsername": "用户名", + "ldapUsernameHint": "输入您的 LDAP 用户名", + "moreSignInOptions": "更多登录选项", + "ldapNotEnabled": "此服务器未启用 LDAP 验证", + "ssoPlatformNotSupported": "此平台不支持 SSO 验证。请改用凭据或 LDAP 验证。" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 38e4632..f5e7ce5 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -862,5 +862,20 @@ "widgetCamera": "相機", "widgetPhotos": "照片", "widgetClipboard": "剪貼簿", - "widgetDescription": "快速存取 Conduit 聊天,支援相機、照片和剪貼簿捷徑" + "widgetDescription": "快速存取 Conduit 聊天,支援相機、照片和剪貼簿捷徑", + "sso": "SSO", + "ssoDescription": "使用組織的身份提供商登入", + "signInWithSso": "使用 SSO 登入", + "ssoAuthenticating": "正在驗證...", + "ssoAuthFailed": "SSO 驗證失敗", + "ssoTokenNotFound": "無法從 SSO 提供商取得驗證權杖", + "ssoLoadingLogin": "正在載入登入頁面...", + "ldap": "LDAP", + "ldapDescription": "使用 LDAP 目錄憑據登入", + "signInWithLdap": "使用 LDAP 登入", + "ldapUsername": "使用者名稱", + "ldapUsernameHint": "輸入您的 LDAP 使用者名稱", + "moreSignInOptions": "更多登入選項", + "ldapNotEnabled": "此伺服器未啟用 LDAP 驗證", + "ssoPlatformNotSupported": "此平台不支援 SSO 驗證。請改用憑據或 LDAP 驗證。" }