Merge pull request #266 from cogwheel0/ldap-sso-authentication-support
ldap-sso-authentication-support
This commit is contained in:
@@ -73,9 +73,13 @@ flutter run -d ios # or: -d android
|
|||||||
### Authentication
|
### Authentication
|
||||||
Conduit supports multiple authentication flows when connecting to your Open‑WebUI:
|
Conduit supports multiple authentication flows when connecting to your Open‑WebUI:
|
||||||
- **Username + Password**: Sign in directly against servers that expose a login endpoint. Credentials are stored securely using platform keychains.
|
- **Username + Password**: Sign in directly against servers that expose a login endpoint. Credentials are stored securely using platform keychains.
|
||||||
- **JWT Token** (for OIDC): Paste a server‑issued JWT token for stateless auth.
|
- **SSO / OAuth** (iOS & Android): Authenticate via your server's configured OAuth providers (Google, Microsoft, GitHub, OIDC, etc.) using an in-app WebView. The token is automatically captured after the OAuth flow completes. Also supports reverse proxy authentication (Authelia, Authentik, etc.).
|
||||||
|
- **LDAP**: Sign in using LDAP credentials if enabled on your server.
|
||||||
|
- **JWT Token**: Paste a server‑issued JWT token for manual token-based auth.
|
||||||
- **Custom Headers**: Add headers during login (e.g., `X-API-Key`, `Authorization`, `X-Org`, or self‑hosted SSO headers) that Conduit will include on all HTTP/WebSocket requests (see [Endpoint Allowlist](#endpoint-allowlist-custom-auth) for reverse proxy whitelisting).
|
- **Custom Headers**: Add headers during login (e.g., `X-API-Key`, `Authorization`, `X-Org`, or self‑hosted SSO headers) that Conduit will include on all HTTP/WebSocket requests (see [Endpoint Allowlist](#endpoint-allowlist-custom-auth) for reverse proxy whitelisting).
|
||||||
|
|
||||||
|
The authentication page dynamically displays available options based on your server's configuration.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
| | | | |
|
| | | | |
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import '../models/user.dart';
|
|||||||
import '../services/optimized_storage_service.dart';
|
import '../services/optimized_storage_service.dart';
|
||||||
import 'token_validator.dart';
|
import 'token_validator.dart';
|
||||||
import 'auth_cache_manager.dart';
|
import 'auth_cache_manager.dart';
|
||||||
|
import 'webview_cookie_helper.dart';
|
||||||
import '../utils/debug_logger.dart';
|
import '../utils/debug_logger.dart';
|
||||||
import '../utils/user_avatar_utils.dart';
|
import '../utils/user_avatar_utils.dart';
|
||||||
import '../../features/tools/providers/tools_providers.dart';
|
|
||||||
|
|
||||||
part 'auth_state_manager.g.dart';
|
part 'auth_state_manager.g.dart';
|
||||||
|
|
||||||
@@ -288,11 +288,17 @@ class AuthStateManager extends _$AuthStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform login with JWT token
|
/// Perform login with JWT token.
|
||||||
|
///
|
||||||
/// Note: API keys (sk-...) are not supported for streaming.
|
/// 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<bool> loginWithApiKey(
|
Future<bool> loginWithApiKey(
|
||||||
String apiKey, {
|
String apiKey, {
|
||||||
bool rememberCredentials = false,
|
bool rememberCredentials = false,
|
||||||
|
String authType = 'token',
|
||||||
}) async {
|
}) async {
|
||||||
_update(
|
_update(
|
||||||
(current) => current.copyWith(
|
(current) => current.copyWith(
|
||||||
@@ -347,6 +353,7 @@ class AuthStateManager extends _$AuthStateManager {
|
|||||||
serverId: activeServer.id,
|
serverId: activeServer.id,
|
||||||
username: 'jwt_user', // Special username to indicate JWT auth
|
username: 'jwt_user', // Special username to indicate JWT auth
|
||||||
password: tokenStr, // Store JWT in password field
|
password: tokenStr, // Store JWT in password field
|
||||||
|
authType: authType, // 'token' for manual entry, 'sso' for OAuth
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,6 +493,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<bool> 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
|
/// Wait briefly until the API service becomes available
|
||||||
Future<void> _ensureApiServiceAvailable({
|
Future<void> _ensureApiServiceAvailable({
|
||||||
Duration timeout = const Duration(seconds: 2),
|
Duration timeout = const Duration(seconds: 2),
|
||||||
@@ -582,12 +700,27 @@ class AuthStateManager extends _$AuthStateManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt login (detect API key vs normal credentials)
|
// Attempt login based on auth type
|
||||||
if (username == 'api_key_user' || username == 'jwt_user') {
|
final authType = savedCredentials['authType'] ?? 'credentials';
|
||||||
// This is a saved JWT token (or legacy API key)
|
|
||||||
return await loginWithApiKey(password, rememberCredentials: false);
|
// 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 {
|
} else {
|
||||||
// Normal username/password credentials
|
// Standard credentials login (default)
|
||||||
return await login(username, password, rememberCredentials: false);
|
return await login(username, password, rememberCredentials: false);
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
@@ -805,12 +938,26 @@ class AuthStateManager extends _$AuthStateManager {
|
|||||||
await storage.clearAuthData();
|
await storage.clearAuthData();
|
||||||
_updateApiServiceToken(null);
|
_updateApiServiceToken(null);
|
||||||
|
|
||||||
|
// Clear all WebView data (cookies, localStorage, cache) to ensure
|
||||||
|
// fresh SSO sessions on next login
|
||||||
|
try {
|
||||||
|
await WebViewCookieHelper.clearAllWebViewData();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger.warning(
|
||||||
|
'webview-data-clear-failed',
|
||||||
|
scope: 'auth/state',
|
||||||
|
data: {'error': e.toString()},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Keep active server ID so router redirects to sign-in page, not server
|
// 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
|
// connection page. Users can navigate to server settings if they need to
|
||||||
// change server configuration.
|
// change server configuration.
|
||||||
|
|
||||||
// Invalidate tools provider to clear cached data
|
// Note: toolsListProvider is NOT invalidated here because:
|
||||||
ref.invalidate(toolsListProvider);
|
// 1. clearAuthData() already deletes the tools cache from storage
|
||||||
|
// 2. The provider has auth checks that prevent API calls when logged out
|
||||||
|
// 3. When user logs back in, the provider will rebuild with fresh data
|
||||||
|
|
||||||
// Clear auth cache manager
|
// Clear auth cache manager
|
||||||
_cacheManager.clearAuthCache();
|
_cacheManager.clearAuthCache();
|
||||||
|
|||||||
74
lib/core/auth/webview_cookie_helper.dart
Normal file
74
lib/core/auth/webview_cookie_helper.dart
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
import '../utils/debug_logger.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 data 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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all WebView data including cookies, localStorage, and cache.
|
||||||
|
///
|
||||||
|
/// This should be called on logout to ensure SSO sessions are fully cleared.
|
||||||
|
/// Returns true if all data was cleared successfully.
|
||||||
|
static Future<bool> clearAllWebViewData() async {
|
||||||
|
if (!isWebViewSupported) return false;
|
||||||
|
|
||||||
|
var success = true;
|
||||||
|
|
||||||
|
// Clear cookies
|
||||||
|
try {
|
||||||
|
await WebViewCookieManager().clearCookies();
|
||||||
|
DebugLogger.auth('WebView cookies cleared');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger.warning(
|
||||||
|
'webview-cookie-clear-failed',
|
||||||
|
scope: 'auth/webview',
|
||||||
|
data: {'error': e.toString()},
|
||||||
|
);
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear localStorage and cache using a temporary controller
|
||||||
|
try {
|
||||||
|
final controller = WebViewController();
|
||||||
|
await controller.clearLocalStorage();
|
||||||
|
await controller.clearCache();
|
||||||
|
DebugLogger.auth('WebView localStorage and cache cleared');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger.warning(
|
||||||
|
'webview-storage-clear-failed',
|
||||||
|
scope: 'auth/webview',
|
||||||
|
data: {'error': e.toString()},
|
||||||
|
);
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,80 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Represents the available OAuth providers configured on the server.
|
||||||
|
@immutable
|
||||||
|
class OAuthProviders {
|
||||||
|
const OAuthProviders({
|
||||||
|
this.google,
|
||||||
|
this.microsoft,
|
||||||
|
this.github,
|
||||||
|
this.oidc,
|
||||||
|
this.feishu,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Google OAuth provider name (if enabled).
|
||||||
|
final String? google;
|
||||||
|
|
||||||
|
/// Microsoft OAuth provider name (if enabled).
|
||||||
|
final String? microsoft;
|
||||||
|
|
||||||
|
/// GitHub OAuth provider name (if enabled).
|
||||||
|
final String? github;
|
||||||
|
|
||||||
|
/// Generic OIDC provider name (if enabled).
|
||||||
|
final String? oidc;
|
||||||
|
|
||||||
|
/// Feishu OAuth provider name (if enabled).
|
||||||
|
final String? feishu;
|
||||||
|
|
||||||
|
/// Whether any OAuth provider is enabled.
|
||||||
|
bool get hasAnyProvider =>
|
||||||
|
google != null ||
|
||||||
|
microsoft != null ||
|
||||||
|
github != null ||
|
||||||
|
oidc != null ||
|
||||||
|
feishu != null;
|
||||||
|
|
||||||
|
/// Returns the list of enabled provider keys.
|
||||||
|
List<String> get enabledProviders => [
|
||||||
|
if (google != null) 'google',
|
||||||
|
if (microsoft != null) 'microsoft',
|
||||||
|
if (github != null) 'github',
|
||||||
|
if (oidc != null) 'oidc',
|
||||||
|
if (feishu != null) 'feishu',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns the display name for a provider.
|
||||||
|
String getProviderDisplayName(String key) {
|
||||||
|
return switch (key) {
|
||||||
|
'google' => google ?? 'Google',
|
||||||
|
'microsoft' => microsoft ?? 'Microsoft',
|
||||||
|
'github' => github ?? 'GitHub',
|
||||||
|
'oidc' => oidc ?? 'SSO',
|
||||||
|
'feishu' => feishu ?? 'Feishu',
|
||||||
|
_ => key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory OAuthProviders.fromJson(Map<String, dynamic>? json) {
|
||||||
|
if (json == null) return const OAuthProviders();
|
||||||
|
return OAuthProviders(
|
||||||
|
google: json['google'] as String?,
|
||||||
|
microsoft: json['microsoft'] as String?,
|
||||||
|
github: json['github'] as String?,
|
||||||
|
oidc: json['oidc'] as String?,
|
||||||
|
feishu: json['feishu'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
if (google != null) 'google': google,
|
||||||
|
if (microsoft != null) 'microsoft': microsoft,
|
||||||
|
if (github != null) 'github': github,
|
||||||
|
if (oidc != null) 'oidc': oidc,
|
||||||
|
if (feishu != null) 'feishu': feishu,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Subset of the backend `/api/config` response the app cares about.
|
/// Subset of the backend `/api/config` response the app cares about.
|
||||||
@immutable
|
@immutable
|
||||||
class BackendConfig {
|
class BackendConfig {
|
||||||
@@ -14,6 +89,9 @@ class BackendConfig {
|
|||||||
this.audioSampleRate,
|
this.audioSampleRate,
|
||||||
this.audioFrameSize,
|
this.audioFrameSize,
|
||||||
this.vadEnabled,
|
this.vadEnabled,
|
||||||
|
this.oauthProviders = const OAuthProviders(),
|
||||||
|
this.enableLdap = false,
|
||||||
|
this.enableLoginForm = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Mirrors `features.enable_websocket` from OpenWebUI.
|
/// Mirrors `features.enable_websocket` from OpenWebUI.
|
||||||
@@ -28,6 +106,18 @@ class BackendConfig {
|
|||||||
final int? audioFrameSize;
|
final int? audioFrameSize;
|
||||||
final bool? vadEnabled;
|
final bool? vadEnabled;
|
||||||
|
|
||||||
|
/// OAuth providers configured on the server.
|
||||||
|
final OAuthProviders oauthProviders;
|
||||||
|
|
||||||
|
/// Whether LDAP authentication is enabled on the server.
|
||||||
|
final bool enableLdap;
|
||||||
|
|
||||||
|
/// Whether the standard login form (email/password) is enabled.
|
||||||
|
final bool enableLoginForm;
|
||||||
|
|
||||||
|
/// Whether SSO (OAuth) login is available.
|
||||||
|
bool get hasSsoEnabled => oauthProviders.hasAnyProvider;
|
||||||
|
|
||||||
/// Returns a copy with updated fields.
|
/// Returns a copy with updated fields.
|
||||||
BackendConfig copyWith({
|
BackendConfig copyWith({
|
||||||
bool? enableWebsocket,
|
bool? enableWebsocket,
|
||||||
@@ -40,6 +130,9 @@ class BackendConfig {
|
|||||||
int? audioSampleRate,
|
int? audioSampleRate,
|
||||||
int? audioFrameSize,
|
int? audioFrameSize,
|
||||||
bool? vadEnabled,
|
bool? vadEnabled,
|
||||||
|
OAuthProviders? oauthProviders,
|
||||||
|
bool? enableLdap,
|
||||||
|
bool? enableLoginForm,
|
||||||
}) {
|
}) {
|
||||||
return BackendConfig(
|
return BackendConfig(
|
||||||
enableWebsocket: enableWebsocket ?? this.enableWebsocket,
|
enableWebsocket: enableWebsocket ?? this.enableWebsocket,
|
||||||
@@ -52,6 +145,9 @@ class BackendConfig {
|
|||||||
audioSampleRate: audioSampleRate ?? this.audioSampleRate,
|
audioSampleRate: audioSampleRate ?? this.audioSampleRate,
|
||||||
audioFrameSize: audioFrameSize ?? this.audioFrameSize,
|
audioFrameSize: audioFrameSize ?? this.audioFrameSize,
|
||||||
vadEnabled: vadEnabled ?? this.vadEnabled,
|
vadEnabled: vadEnabled ?? this.vadEnabled,
|
||||||
|
oauthProviders: oauthProviders ?? this.oauthProviders,
|
||||||
|
enableLdap: enableLdap ?? this.enableLdap,
|
||||||
|
enableLoginForm: enableLoginForm ?? this.enableLoginForm,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +182,9 @@ class BackendConfig {
|
|||||||
'audio_sample_rate': audioSampleRate,
|
'audio_sample_rate': audioSampleRate,
|
||||||
'audio_frame_size': audioFrameSize,
|
'audio_frame_size': audioFrameSize,
|
||||||
'vad_enabled': vadEnabled,
|
'vad_enabled': vadEnabled,
|
||||||
|
'oauth': {'providers': oauthProviders.toJson()},
|
||||||
|
'enable_ldap': enableLdap,
|
||||||
|
'enable_login_form': enableLoginForm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +199,10 @@ class BackendConfig {
|
|||||||
int? audioSampleRate;
|
int? audioSampleRate;
|
||||||
int? audioFrameSize;
|
int? audioFrameSize;
|
||||||
bool? vadEnabled;
|
bool? vadEnabled;
|
||||||
|
OAuthProviders oauthProviders = const OAuthProviders();
|
||||||
|
bool enableLdap = false;
|
||||||
|
bool enableLoginForm = true;
|
||||||
|
|
||||||
// Try canonical format first
|
// Try canonical format first
|
||||||
final value = json['enable_websocket'];
|
final value = json['enable_websocket'];
|
||||||
if (value is bool) {
|
if (value is bool) {
|
||||||
@@ -129,6 +232,21 @@ class BackendConfig {
|
|||||||
final vad = json['vad_enabled'];
|
final vad = json['vad_enabled'];
|
||||||
if (vad is bool) vadEnabled = vad;
|
if (vad is bool) vadEnabled = vad;
|
||||||
|
|
||||||
|
// Parse OAuth providers from top-level oauth.providers
|
||||||
|
final oauth = json['oauth'];
|
||||||
|
if (oauth is Map<String, dynamic>) {
|
||||||
|
final providers = oauth['providers'];
|
||||||
|
if (providers is Map<String, dynamic>) {
|
||||||
|
oauthProviders = OAuthProviders.fromJson(providers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse auth features from top-level
|
||||||
|
final ldapValue = json['enable_ldap'];
|
||||||
|
if (ldapValue is bool) enableLdap = ldapValue;
|
||||||
|
final loginFormValue = json['enable_login_form'];
|
||||||
|
if (loginFormValue is bool) enableLoginForm = loginFormValue;
|
||||||
|
|
||||||
// Fallback to nested format for backwards compatibility
|
// Fallback to nested format for backwards compatibility
|
||||||
final features = json['features'];
|
final features = json['features'];
|
||||||
if (features is Map<String, dynamic>) {
|
if (features is Map<String, dynamic>) {
|
||||||
@@ -172,6 +290,11 @@ class BackendConfig {
|
|||||||
if (nestedVad is bool && vadEnabled == null) {
|
if (nestedVad is bool && vadEnabled == null) {
|
||||||
vadEnabled = nestedVad;
|
vadEnabled = nestedVad;
|
||||||
}
|
}
|
||||||
|
// Auth features in nested format
|
||||||
|
final nestedLdap = features['enable_ldap'];
|
||||||
|
if (nestedLdap is bool) enableLdap = nestedLdap;
|
||||||
|
final nestedLoginForm = features['enable_login_form'];
|
||||||
|
if (nestedLoginForm is bool) enableLoginForm = nestedLoginForm;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BackendConfig(
|
return BackendConfig(
|
||||||
@@ -185,6 +308,9 @@ class BackendConfig {
|
|||||||
audioSampleRate: audioSampleRate,
|
audioSampleRate: audioSampleRate,
|
||||||
audioFrameSize: audioFrameSize,
|
audioFrameSize: audioFrameSize,
|
||||||
vadEnabled: vadEnabled,
|
vadEnabled: vadEnabled,
|
||||||
|
oauthProviders: oauthProviders,
|
||||||
|
enableLdap: enableLdap,
|
||||||
|
enableLoginForm: enableLoginForm,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
import 'backend_config.dart';
|
||||||
|
|
||||||
part 'server_config.freezed.dart';
|
part 'server_config.freezed.dart';
|
||||||
part 'server_config.g.dart';
|
part 'server_config.g.dart';
|
||||||
|
|
||||||
|
/// Container for passing server and backend config during authentication flow.
|
||||||
|
@immutable
|
||||||
|
class AuthFlowConfig {
|
||||||
|
const AuthFlowConfig({required this.serverConfig, this.backendConfig});
|
||||||
|
|
||||||
|
/// The server configuration (URL, headers, etc.).
|
||||||
|
final ServerConfig serverConfig;
|
||||||
|
|
||||||
|
/// The backend configuration (auth methods, features, etc.).
|
||||||
|
/// May be null if not yet fetched.
|
||||||
|
final BackendConfig? backendConfig;
|
||||||
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
sealed class ServerConfig with _$ServerConfig {
|
sealed class ServerConfig with _$ServerConfig {
|
||||||
const factory ServerConfig({
|
const factory ServerConfig({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../features/auth/views/authentication_page.dart';
|
|||||||
import '../../features/auth/views/connect_signin_page.dart';
|
import '../../features/auth/views/connect_signin_page.dart';
|
||||||
import '../../features/auth/views/connection_issue_page.dart';
|
import '../../features/auth/views/connection_issue_page.dart';
|
||||||
import '../../features/auth/views/server_connection_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/chat/views/chat_page.dart';
|
||||||
import '../../features/navigation/views/splash_launcher_page.dart';
|
import '../../features/navigation/views/splash_launcher_page.dart';
|
||||||
import '../../features/notes/views/notes_list_page.dart';
|
import '../../features/notes/views/notes_list_page.dart';
|
||||||
@@ -179,7 +180,8 @@ class RouterNotifier extends ChangeNotifier {
|
|||||||
return location == Routes.serverConnection ||
|
return location == Routes.serverConnection ||
|
||||||
location == Routes.login ||
|
location == Routes.login ||
|
||||||
location == Routes.authentication ||
|
location == Routes.authentication ||
|
||||||
location == Routes.connectionIssue;
|
location == Routes.connectionIssue ||
|
||||||
|
location == Routes.ssoAuth;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -231,8 +233,25 @@ final goRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
path: Routes.authentication,
|
path: Routes.authentication,
|
||||||
name: RouteNames.authentication,
|
name: RouteNames.authentication,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final config = state.extra;
|
final extra = state.extra;
|
||||||
|
// Support both AuthFlowConfig (new) and ServerConfig (legacy)
|
||||||
|
if (extra is AuthFlowConfig) {
|
||||||
return AuthenticationPage(
|
return AuthenticationPage(
|
||||||
|
serverConfig: extra.serverConfig,
|
||||||
|
backendConfig: extra.backendConfig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return AuthenticationPage(
|
||||||
|
serverConfig: extra is ServerConfig ? extra : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.ssoAuth,
|
||||||
|
name: RouteNames.ssoAuth,
|
||||||
|
builder: (context, state) {
|
||||||
|
final config = state.extra;
|
||||||
|
return SsoAuthPage(
|
||||||
serverConfig: config is ServerConfig ? config : null,
|
serverConfig: config is ServerConfig ? config : null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -232,15 +232,24 @@ class ApiService {
|
|||||||
///
|
///
|
||||||
/// Returns `true` if the server appears to be a valid OpenWebUI instance.
|
/// Returns `true` if the server appears to be a valid OpenWebUI instance.
|
||||||
Future<bool> verifyIsOpenWebUIServer() async {
|
Future<bool> verifyIsOpenWebUIServer() async {
|
||||||
|
final config = await verifyAndGetConfig();
|
||||||
|
return config != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies this is an OpenWebUI server and returns the backend config.
|
||||||
|
///
|
||||||
|
/// Returns `BackendConfig` if the server is valid, `null` otherwise.
|
||||||
|
/// This combines server verification and config fetching in a single call.
|
||||||
|
Future<BackendConfig?> verifyAndGetConfig() async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get('/api/config');
|
final response = await _dio.get('/api/config');
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = response.data;
|
final data = response.data;
|
||||||
if (data is! Map<String, dynamic>) {
|
if (data is! Map<String, dynamic>) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for OpenWebUI-specific fields
|
// Check for OpenWebUI-specific fields
|
||||||
@@ -250,9 +259,13 @@ class ApiService {
|
|||||||
data['version'] is String && (data['version'] as String).isNotEmpty;
|
data['version'] is String && (data['version'] as String).isNotEmpty;
|
||||||
final hasFeatures = data['features'] is Map;
|
final hasFeatures = data['features'] is Map;
|
||||||
|
|
||||||
return hasStatus && hasVersion && hasFeatures;
|
if (!hasStatus || !hasVersion || !hasFeatures) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackendConfig.fromJson(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +368,46 @@ class ApiService {
|
|||||||
await _dio.get('/api/v1/auths/signout');
|
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<Map<String, dynamic>> 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
|
// User info
|
||||||
Future<User> getCurrentUser() async {
|
Future<User> getCurrentUser() async {
|
||||||
final response = await _dio.get('/api/v1/auths/');
|
final response = await _dio.get('/api/v1/auths/');
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class Routes {
|
|||||||
static const String serverConnection = '/server-connection';
|
static const String serverConnection = '/server-connection';
|
||||||
static const String connectionIssue = '/connection-issue';
|
static const String connectionIssue = '/connection-issue';
|
||||||
static const String authentication = '/authentication';
|
static const String authentication = '/authentication';
|
||||||
|
static const String ssoAuth = '/sso-auth';
|
||||||
static const String profile = '/profile';
|
static const String profile = '/profile';
|
||||||
static const String appCustomization = '/profile/customization';
|
static const String appCustomization = '/profile/customization';
|
||||||
static const String notes = '/notes';
|
static const String notes = '/notes';
|
||||||
@@ -115,6 +116,7 @@ class RouteNames {
|
|||||||
static const String serverConnection = 'server-connection';
|
static const String serverConnection = 'server-connection';
|
||||||
static const String connectionIssue = 'connection-issue';
|
static const String connectionIssue = 'connection-issue';
|
||||||
static const String authentication = 'authentication';
|
static const String authentication = 'authentication';
|
||||||
|
static const String ssoAuth = 'sso-auth';
|
||||||
static const String profile = 'profile';
|
static const String profile = 'profile';
|
||||||
static const String appCustomization = 'app-customization';
|
static const String appCustomization = 'app-customization';
|
||||||
static const String notes = 'notes';
|
static const String notes = 'notes';
|
||||||
|
|||||||
@@ -132,12 +132,14 @@ class OptimizedStorageService {
|
|||||||
required String serverId,
|
required String serverId,
|
||||||
required String username,
|
required String username,
|
||||||
required String password,
|
required String password,
|
||||||
|
String authType = 'credentials',
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await _secureCredentialStorage.saveCredentials(
|
await _secureCredentialStorage.saveCredentials(
|
||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
|
authType: authType,
|
||||||
);
|
);
|
||||||
|
|
||||||
_cacheManager.write('has_credentials', true, ttl: _credentialsFlagTtl);
|
_cacheManager.write('has_credentials', true, ttl: _credentialsFlagTtl);
|
||||||
|
|||||||
@@ -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<void> saveCredentials({
|
Future<void> saveCredentials({
|
||||||
required String serverId,
|
required String serverId,
|
||||||
required String username,
|
required String username,
|
||||||
required String password,
|
required String password,
|
||||||
|
String authType = 'credentials',
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// First check if secure storage is available
|
// First check if secure storage is available
|
||||||
@@ -57,9 +64,10 @@ class SecureCredentialStorage {
|
|||||||
'serverId': serverId,
|
'serverId': serverId,
|
||||||
'username': username,
|
'username': username,
|
||||||
'password': password,
|
'password': password,
|
||||||
|
'authType': authType,
|
||||||
'savedAt': DateTime.now().toIso8601String(),
|
'savedAt': DateTime.now().toIso8601String(),
|
||||||
'deviceId': await _getDeviceFingerprint(),
|
'deviceId': await _getDeviceFingerprint(),
|
||||||
'version': '2.0', // Version for migration purposes
|
'version': '2.1', // Version for migration purposes
|
||||||
};
|
};
|
||||||
|
|
||||||
final encryptedData = await _encryptData(jsonEncode(credentials));
|
final encryptedData = await _encryptData(jsonEncode(credentials));
|
||||||
@@ -76,7 +84,7 @@ class SecureCredentialStorage {
|
|||||||
DebugLogger.storage(
|
DebugLogger.storage(
|
||||||
'save-ok',
|
'save-ok',
|
||||||
scope: 'credentials',
|
scope: 'credentials',
|
||||||
data: {'version': '2.0'},
|
data: {'version': '2.1'},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLogger.error('save-failed', scope: 'credentials', error: e);
|
DebugLogger.error('save-failed', scope: 'credentials', error: e);
|
||||||
@@ -156,6 +164,7 @@ class SecureCredentialStorage {
|
|||||||
'username': decoded['username']?.toString() ?? '',
|
'username': decoded['username']?.toString() ?? '',
|
||||||
'password': decoded['password']?.toString() ?? '',
|
'password': decoded['password']?.toString() ?? '',
|
||||||
'savedAt': decoded['savedAt']?.toString() ?? '',
|
'savedAt': decoded['savedAt']?.toString() ?? '',
|
||||||
|
'authType': decoded['authType']?.toString() ?? 'credentials',
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLogger.error('read-failed', scope: 'credentials', error: 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<void> migrateFromOldStorage(
|
Future<void> migrateFromOldStorage(
|
||||||
Map<String, String>? oldCredentials,
|
Map<String, String>? oldCredentials,
|
||||||
) async {
|
) async {
|
||||||
@@ -366,6 +377,7 @@ class SecureCredentialStorage {
|
|||||||
serverId: oldCredentials['serverId'] ?? '',
|
serverId: oldCredentials['serverId'] ?? '',
|
||||||
username: oldCredentials['username'] ?? '',
|
username: oldCredentials['username'] ?? '',
|
||||||
password: oldCredentials['password'] ?? '',
|
password: oldCredentials['password'] ?? '',
|
||||||
|
authType: oldCredentials['authType'] ?? 'credentials',
|
||||||
);
|
);
|
||||||
DebugLogger.storage('migrate-ok', scope: 'credentials');
|
DebugLogger.storage('migrate-ok', scope: 'credentials');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -29,10 +29,24 @@ class AuthActions {
|
|||||||
Future<bool> loginWithApiKey(
|
Future<bool> loginWithApiKey(
|
||||||
String apiKey, {
|
String apiKey, {
|
||||||
bool rememberCredentials = false,
|
bool rememberCredentials = false,
|
||||||
|
String authType = 'token',
|
||||||
}) {
|
}) {
|
||||||
return _auth.loginWithApiKey(
|
return _auth.loginWithApiKey(
|
||||||
apiKey,
|
apiKey,
|
||||||
rememberCredentials: rememberCredentials,
|
rememberCredentials: rememberCredentials,
|
||||||
|
authType: authType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> ldapLogin(
|
||||||
|
String username,
|
||||||
|
String password, {
|
||||||
|
bool rememberCredentials = false,
|
||||||
|
}) {
|
||||||
|
return _auth.ldapLogin(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
rememberCredentials: rememberCredentials,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../core/models/backend_config.dart';
|
||||||
import '../../../core/models/server_config.dart';
|
import '../../../core/models/server_config.dart';
|
||||||
import '../../../core/providers/app_providers.dart';
|
import '../../../core/providers/app_providers.dart';
|
||||||
import '../../../core/services/input_validation_service.dart';
|
import '../../../core/services/input_validation_service.dart';
|
||||||
@@ -17,11 +18,21 @@ import '../../../core/auth/auth_state_manager.dart';
|
|||||||
import '../../../core/utils/debug_logger.dart';
|
import '../../../core/utils/debug_logger.dart';
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
import '../providers/unified_auth_providers.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 {
|
class AuthenticationPage extends ConsumerStatefulWidget {
|
||||||
final ServerConfig? serverConfig;
|
final ServerConfig? serverConfig;
|
||||||
|
final BackendConfig? backendConfig;
|
||||||
|
|
||||||
const AuthenticationPage({super.key, this.serverConfig});
|
const AuthenticationPage({super.key, this.serverConfig, this.backendConfig});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<AuthenticationPage> createState() => _AuthenticationPageState();
|
ConsumerState<AuthenticationPage> createState() => _AuthenticationPageState();
|
||||||
@@ -32,16 +43,35 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
|||||||
final TextEditingController _usernameController = TextEditingController();
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
final TextEditingController _apiKeyController = TextEditingController();
|
final TextEditingController _apiKeyController = TextEditingController();
|
||||||
|
final TextEditingController _ldapUsernameController = TextEditingController();
|
||||||
|
final TextEditingController _ldapPasswordController = TextEditingController();
|
||||||
|
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
bool _useApiKey = false;
|
AuthMode _authMode = AuthMode.credentials;
|
||||||
String? _loginError;
|
String? _loginError;
|
||||||
bool _isSigningIn = false;
|
bool _isSigningIn = false;
|
||||||
bool _serverConfigSaved = false;
|
bool _serverConfigSaved = false;
|
||||||
|
bool _showMoreOptions = false;
|
||||||
|
|
||||||
|
/// Whether the server has OAuth/SSO providers configured.
|
||||||
|
bool get _hasSsoEnabled =>
|
||||||
|
widget.backendConfig?.hasSsoEnabled == true && isWebViewSupported;
|
||||||
|
|
||||||
|
/// Whether LDAP authentication is enabled on the server.
|
||||||
|
bool get _hasLdapEnabled => widget.backendConfig?.enableLdap == true;
|
||||||
|
|
||||||
|
/// Whether the login form (email/password) is enabled on the server.
|
||||||
|
bool get _hasLoginFormEnabled =>
|
||||||
|
widget.backendConfig?.enableLoginForm ?? true;
|
||||||
|
|
||||||
|
/// OAuth providers available on the server.
|
||||||
|
OAuthProviders get _oauthProviders =>
|
||||||
|
widget.backendConfig?.oauthProviders ?? const OAuthProviders();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_setDefaultAuthMode();
|
||||||
_loadSavedCredentials();
|
_loadSavedCredentials();
|
||||||
// Check for auth errors (e.g., forced logout due to API key)
|
// Check for auth errors (e.g., forced logout due to API key)
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -49,6 +79,22 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the default auth mode based on what the server supports.
|
||||||
|
void _setDefaultAuthMode() {
|
||||||
|
// Priority: SSO > Credentials > LDAP > Token
|
||||||
|
if (_hasSsoEnabled && _oauthProviders.enabledProviders.length == 1) {
|
||||||
|
// If only one SSO provider, that's probably the intended method
|
||||||
|
_authMode = AuthMode.sso;
|
||||||
|
} else if (_hasLoginFormEnabled) {
|
||||||
|
_authMode = AuthMode.credentials;
|
||||||
|
} else if (_hasLdapEnabled) {
|
||||||
|
_authMode = AuthMode.ldap;
|
||||||
|
} else {
|
||||||
|
// Fallback to token if nothing else is enabled
|
||||||
|
_authMode = AuthMode.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _checkAuthStateError() {
|
void _checkAuthStateError() {
|
||||||
final authState = ref.read(authStateManagerProvider).asData?.value;
|
final authState = ref.read(authStateManagerProvider).asData?.value;
|
||||||
if (authState?.error != null && authState!.error!.isNotEmpty) {
|
if (authState?.error != null && authState!.error!.isNotEmpty) {
|
||||||
@@ -56,7 +102,7 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
|||||||
_loginError = _formatLoginError(authState.error!);
|
_loginError = _formatLoginError(authState.error!);
|
||||||
// Switch to token tab if the error is about API keys
|
// Switch to token tab if the error is about API keys
|
||||||
if (authState.error!.contains('apiKey')) {
|
if (authState.error!.contains('apiKey')) {
|
||||||
_useApiKey = true;
|
_authMode = AuthMode.token;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -77,6 +123,8 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
|||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_apiKeyController.dispose();
|
_apiKeyController.dispose();
|
||||||
|
_ldapUsernameController.dispose();
|
||||||
|
_ldapPasswordController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,17 +148,27 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
|||||||
final actions = ref.read(authActionsProvider);
|
final actions = ref.read(authActionsProvider);
|
||||||
bool success;
|
bool success;
|
||||||
|
|
||||||
if (_useApiKey) {
|
switch (_authMode) {
|
||||||
success = await actions.loginWithApiKey(
|
case AuthMode.credentials:
|
||||||
_apiKeyController.text.trim(),
|
|
||||||
rememberCredentials: true,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
success = await actions.login(
|
success = await actions.login(
|
||||||
_usernameController.text.trim(),
|
_usernameController.text.trim(),
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
rememberCredentials: true,
|
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) {
|
if (!success) {
|
||||||
@@ -149,6 +207,8 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
|||||||
return l10n.apiKeyNotSupported;
|
return l10n.apiKeyNotSupported;
|
||||||
} else if (error.contains('apiKeyNoLongerSupported')) {
|
} else if (error.contains('apiKeyNoLongerSupported')) {
|
||||||
return l10n.apiKeyNoLongerSupported;
|
return l10n.apiKeyNoLongerSupported;
|
||||||
|
} else if (error.contains('LDAP authentication is not enabled')) {
|
||||||
|
return l10n.ldapNotEnabled;
|
||||||
} else if (error.contains('401') || error.contains('Unauthorized')) {
|
} else if (error.contains('401') || error.contains('Unauthorized')) {
|
||||||
return l10n.invalidCredentials;
|
return l10n.invalidCredentials;
|
||||||
} else if (error.contains('redirect')) {
|
} else if (error.contains('redirect')) {
|
||||||
@@ -374,131 +434,112 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAuthForm() {
|
Widget _buildAuthForm() {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Authentication mode toggle
|
// Show SSO buttons prominently if OAuth providers are configured
|
||||||
_buildAuthModeToggle(),
|
if (_hasSsoEnabled) ...[
|
||||||
|
_buildSsoButtons(l10n),
|
||||||
|
if (_hasLoginFormEnabled || _hasLdapEnabled) ...[
|
||||||
const SizedBox(height: Spacing.lg),
|
const SizedBox(height: Spacing.lg),
|
||||||
|
_buildDividerWithText(l10n.or),
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
// Authentication form fields
|
// Show the appropriate form based on auth mode
|
||||||
_buildAuthFields(),
|
// Credentials form is shown directly when login form is enabled
|
||||||
|
// Other modes (LDAP, Token) are shown when selected from "More options"
|
||||||
|
if (_hasLoginFormEnabled && _authMode == AuthMode.credentials) ...[
|
||||||
|
_buildCredentialsForm(),
|
||||||
|
] else if (_authMode == AuthMode.ldap && _hasLdapEnabled) ...[
|
||||||
|
_buildLdapForm(),
|
||||||
|
] else if (_authMode == AuthMode.token) ...[
|
||||||
|
_buildApiKeyForm(),
|
||||||
|
] else if (_authMode == AuthMode.sso && !_hasSsoEnabled) ...[
|
||||||
|
_buildSsoPrompt(),
|
||||||
|
],
|
||||||
|
|
||||||
if (_loginError != null) ...[
|
if (_loginError != null) ...[
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
_buildErrorMessage(_loginError!),
|
_buildErrorMessage(_loginError!),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// More options section - always show for additional auth methods
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
_buildMoreOptionsSection(l10n),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAuthModeToggle() {
|
Widget _buildDividerWithText(String text) {
|
||||||
return Container(
|
return Row(
|
||||||
padding: const EdgeInsets.all(3),
|
children: [
|
||||||
decoration: BoxDecoration(
|
Expanded(
|
||||||
color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.5),
|
child: Divider(
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
|
||||||
border: Border.all(
|
|
||||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
|
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
|
||||||
width: BorderWidth.standard,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
|
||||||
Expanded(
|
child: Text(
|
||||||
child: _buildAuthToggleOption(
|
text,
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.person_circle
|
|
||||||
: Icons.account_circle_outlined,
|
|
||||||
label: AppLocalizations.of(context)!.credentials,
|
|
||||||
isSelected: !_useApiKey,
|
|
||||||
onTap: () => setState(() => _useApiKey = false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _buildAuthToggleOption(
|
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.lock_shield
|
|
||||||
: Icons.vpn_key_outlined,
|
|
||||||
label: AppLocalizations.of(context)!.token,
|
|
||||||
isSelected: _useApiKey,
|
|
||||||
onTap: () => setState(() => _useApiKey = true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAuthToggleOption({
|
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
required bool isSelected,
|
|
||||||
required VoidCallback onTap,
|
|
||||||
}) {
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: AnimationDuration.microInteraction,
|
|
||||||
curve: Curves.easeInOutCubic,
|
|
||||||
child: Material(
|
|
||||||
color: isSelected
|
|
||||||
? context.conduitTheme.buttonPrimary
|
|
||||||
: Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.small - 1),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.small - 1),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: Spacing.sm,
|
|
||||||
horizontal: Spacing.sm,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: IconSize.small,
|
|
||||||
color: isSelected
|
|
||||||
? context.conduitTheme.buttonPrimaryText
|
|
||||||
: context.conduitTheme.iconSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: context.conduitTheme.bodySmall?.copyWith(
|
style: context.conduitTheme.bodySmall?.copyWith(
|
||||||
color: isSelected
|
color: context.conduitTheme.textSecondary,
|
||||||
? context.conduitTheme.buttonPrimaryText
|
),
|
||||||
: context.conduitTheme.textSecondary,
|
),
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Divider(
|
||||||
|
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAuthFields() {
|
Widget _buildSsoButtons(AppLocalizations l10n) {
|
||||||
return AnimatedSwitcher(
|
final providers = _oauthProviders.enabledProviders;
|
||||||
duration: AnimationDuration.pageTransition,
|
|
||||||
switchInCurve: Curves.easeInOutCubic,
|
return Column(
|
||||||
switchOutCurve: Curves.easeInOutCubic,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
children: [
|
||||||
return FadeTransition(
|
for (int i = 0; i < providers.length; i++) ...[
|
||||||
opacity: animation,
|
if (i > 0) const SizedBox(height: Spacing.sm),
|
||||||
child: SlideTransition(
|
_buildOAuthButton(providers[i], l10n),
|
||||||
position: Tween<Offset>(
|
],
|
||||||
begin: const Offset(0, 0.1),
|
],
|
||||||
end: Offset.zero,
|
|
||||||
).animate(animation),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
child: _useApiKey ? _buildApiKeyForm() : _buildCredentialsForm(),
|
|
||||||
|
Widget _buildOAuthButton(String provider, AppLocalizations l10n) {
|
||||||
|
final displayName = _oauthProviders.getProviderDisplayName(provider);
|
||||||
|
|
||||||
|
IconData icon;
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case 'google':
|
||||||
|
icon = Icons.g_mobiledata;
|
||||||
|
case 'microsoft':
|
||||||
|
icon = Icons.window;
|
||||||
|
case 'github':
|
||||||
|
icon = Icons.code;
|
||||||
|
case 'oidc':
|
||||||
|
icon = Platform.isIOS ? CupertinoIcons.lock_shield : Icons.security;
|
||||||
|
case 'feishu':
|
||||||
|
icon = Icons.chat_bubble_outline;
|
||||||
|
default:
|
||||||
|
icon = Icons.login;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConduitButton(
|
||||||
|
text: l10n.continueWithProvider(displayName),
|
||||||
|
icon: icon,
|
||||||
|
onPressed: _navigateToSso,
|
||||||
|
isSecondary: true,
|
||||||
|
isFullWidth: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,15 +673,353 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<void> _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) {
|
||||||
|
// Build list of available options - always show all available options
|
||||||
|
// with the current one highlighted for consistency
|
||||||
|
final options = <Widget>[];
|
||||||
|
|
||||||
|
// Credentials option (if login form is enabled)
|
||||||
|
if (_hasLoginFormEnabled) {
|
||||||
|
options.add(
|
||||||
|
_buildOptionButton(
|
||||||
|
icon: Platform.isIOS
|
||||||
|
? CupertinoIcons.person_circle
|
||||||
|
: Icons.account_circle_outlined,
|
||||||
|
label: l10n.credentials,
|
||||||
|
isSelected: _authMode == AuthMode.credentials,
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_authMode = AuthMode.credentials;
|
||||||
|
_loginError = null;
|
||||||
|
_obscurePassword = true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSO option (only if WebView supported and no OAuth buttons shown above)
|
||||||
|
if (isWebViewSupported && !_hasSsoEnabled) {
|
||||||
|
options.add(
|
||||||
|
_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;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP option (if enabled on server)
|
||||||
|
if (_hasLdapEnabled) {
|
||||||
|
options.add(
|
||||||
|
_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;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token option (always available as fallback)
|
||||||
|
options.add(
|
||||||
|
_buildOptionButton(
|
||||||
|
icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key,
|
||||||
|
label: l10n.token,
|
||||||
|
isSelected: _authMode == AuthMode.token,
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_authMode = AuthMode.token;
|
||||||
|
_loginError = null;
|
||||||
|
_obscurePassword = true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Expandable header
|
||||||
|
InkWell(
|
||||||
|
onTap: () => setState(() => _showMoreOptions = !_showMoreOptions),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.md,
|
||||||
|
vertical: Spacing.sm,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.moreSignInOptions,
|
||||||
|
style: context.conduitTheme.bodySmall?.copyWith(
|
||||||
|
color: context.conduitTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
AnimatedRotation(
|
||||||
|
duration: AnimationDuration.microInteraction,
|
||||||
|
turns: _showMoreOptions ? 0.5 : 0,
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.chevron_down
|
||||||
|
: Icons.expand_more,
|
||||||
|
color: context.conduitTheme.iconSecondary,
|
||||||
|
size: IconSize.small,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Options (collapsed by default unless a secondary option is selected)
|
||||||
|
AnimatedCrossFade(
|
||||||
|
duration: AnimationDuration.microInteraction,
|
||||||
|
sizeCurve: Curves.easeInOutCubic,
|
||||||
|
crossFadeState:
|
||||||
|
_showMoreOptions ||
|
||||||
|
_authMode == AuthMode.ldap ||
|
||||||
|
_authMode == AuthMode.token ||
|
||||||
|
(_authMode == AuthMode.sso && !_hasSsoEnabled)
|
||||||
|
? CrossFadeState.showSecond
|
||||||
|
: CrossFadeState.showFirst,
|
||||||
|
firstChild: const SizedBox.shrink(),
|
||||||
|
secondChild: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: Spacing.md),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < options.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: Spacing.sm),
|
||||||
|
Expanded(child: options[i]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: Spacing.lg),
|
padding: const EdgeInsets.only(top: Spacing.lg),
|
||||||
child: ConduitButton(
|
child: ConduitButton(
|
||||||
text: _isSigningIn
|
text: buttonText,
|
||||||
? AppLocalizations.of(context)!.signingIn
|
|
||||||
: _useApiKey
|
|
||||||
? AppLocalizations.of(context)!.signInWithToken
|
|
||||||
: AppLocalizations.of(context)!.signIn,
|
|
||||||
icon: _isSigningIn
|
icon: _isSigningIn
|
||||||
? null
|
? null
|
||||||
: (Platform.isIOS
|
: (Platform.isIOS
|
||||||
|
|||||||
@@ -117,17 +117,17 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then verify it's actually an OpenWebUI server
|
// Then verify it's actually an OpenWebUI server and get its config
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'Verifying OpenWebUI server...',
|
'Verifying OpenWebUI server...',
|
||||||
scope: 'auth/connection',
|
scope: 'auth/connection',
|
||||||
);
|
);
|
||||||
final isOpenWebUI = await api.verifyIsOpenWebUIServer();
|
final backendConfig = await api.verifyAndGetConfig();
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'OpenWebUI verification result: $isOpenWebUI',
|
'OpenWebUI verification result: ${backendConfig != null}',
|
||||||
scope: 'auth/connection',
|
scope: 'auth/connection',
|
||||||
);
|
);
|
||||||
if (!isOpenWebUI) {
|
if (backendConfig == null) {
|
||||||
throw Exception('This does not appear to be an Open-WebUI server.');
|
throw Exception('This does not appear to be an Open-WebUI server.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +137,13 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Don't save server config yet - wait until authentication succeeds
|
// Don't save server config yet - wait until authentication succeeds
|
||||||
// The config is passed to the authentication page
|
// The config is passed to the authentication page along with backend config
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.pushNamed(RouteNames.authentication, extra: tempConfig);
|
final authFlowConfig = AuthFlowConfig(
|
||||||
|
serverConfig: tempConfig,
|
||||||
|
backendConfig: backendConfig,
|
||||||
|
);
|
||||||
|
context.pushNamed(RouteNames.authentication, extra: authFlowConfig);
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
|
|||||||
615
lib/features/auth/views/sso_auth_page.dart
Normal file
615
lib/features/auth/views/sso_auth_page.dart
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
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/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<SsoAuthPage> createState() => _SsoAuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||||
|
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<void> _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,
|
||||||
|
onUrlChange: _onUrlChange,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when URL changes (may catch changes that onPageFinished misses)
|
||||||
|
Future<void> _onUrlChange(UrlChange change) async {
|
||||||
|
final url = change.url;
|
||||||
|
if (url == null) return;
|
||||||
|
DebugLogger.auth('SSO URL changed: $url');
|
||||||
|
|
||||||
|
// Try to capture token on URL change as well
|
||||||
|
if (_tokenCaptured) return;
|
||||||
|
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
final serverUrl = _serverUrl;
|
||||||
|
if (serverUrl == null) return;
|
||||||
|
|
||||||
|
final serverUri = Uri.parse(serverUrl);
|
||||||
|
if (uri.host != serverUri.host) return;
|
||||||
|
|
||||||
|
// Attempt single token capture (no retry) - onPageFinished will handle retries
|
||||||
|
// This provides fast capture when URL changes, while onPageFinished
|
||||||
|
// provides the retry mechanism as a fallback
|
||||||
|
await _attemptTokenCapture(uri, attemptId: _captureAttemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a page on our server where a token might be present
|
||||||
|
// After OAuth, Open-WebUI may redirect to:
|
||||||
|
// - /auth (login page with token in cookie)
|
||||||
|
// - / (root/chat page after successful auth)
|
||||||
|
// - /api/v1/auths/callback/* (OAuth callback that sets the token)
|
||||||
|
// We should check for tokens on any page on our server after OAuth completes
|
||||||
|
final serverUrl = _serverUrl;
|
||||||
|
if (serverUrl == null) return;
|
||||||
|
|
||||||
|
final serverUri = Uri.parse(serverUrl);
|
||||||
|
final isOurServer = uri.host == serverUri.host;
|
||||||
|
if (!isOurServer) return;
|
||||||
|
|
||||||
|
// Skip external OAuth provider pages (they won't have our token)
|
||||||
|
// Only check pages that could have the token set
|
||||||
|
final isAuthRelatedPath =
|
||||||
|
uri.path == '/' ||
|
||||||
|
uri.path.endsWith('/auth') ||
|
||||||
|
uri.path.contains('/callback') ||
|
||||||
|
uri.path.contains('/oauth');
|
||||||
|
|
||||||
|
if (!isAuthRelatedPath) {
|
||||||
|
// For other pages on our server (like /chat), still try to capture
|
||||||
|
// the token since the user might have been redirected there after auth
|
||||||
|
DebugLogger.auth('Checking for token on ${uri.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a moment for the frontend to persist the token
|
||||||
|
// The OAuth callback sets the cookie, then redirects to /auth or /,
|
||||||
|
// 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<void> _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<bool> _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 (_isValidJwtFormat(tokenValue)) {
|
||||||
|
DebugLogger.auth('Found valid token in cookie');
|
||||||
|
await _handleToken(tokenValue);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Expected during page load - token may not be accessible yet
|
||||||
|
DebugLogger.log(
|
||||||
|
'Cookie read failed (expected during auth flow): ${e.toString().split('\n').first}',
|
||||||
|
scope: 'auth/sso',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (_isValidJwtFormat(tokenValue)) {
|
||||||
|
DebugLogger.auth('Found valid token in localStorage');
|
||||||
|
await _handleToken(tokenValue);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Expected during page load - token may not be accessible yet
|
||||||
|
DebugLogger.log(
|
||||||
|
'localStorage read failed (expected during auth flow): ${e.toString().split('\n').first}',
|
||||||
|
scope: 'auth/sso',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a string looks like a valid JWT token.
|
||||||
|
///
|
||||||
|
/// JWT tokens have 3 dot-separated segments and are typically 100+ chars.
|
||||||
|
/// This filters out invalid values like 'null', 'undefined', empty strings,
|
||||||
|
/// or placeholder values that might be in localStorage before OAuth completes.
|
||||||
|
bool _isValidJwtFormat(String value) {
|
||||||
|
if (value.isEmpty) return false;
|
||||||
|
final trimmed = value.trim();
|
||||||
|
// Filter out common invalid values
|
||||||
|
if (trimmed == 'null' ||
|
||||||
|
trimmed == 'undefined' ||
|
||||||
|
trimmed == 'false' ||
|
||||||
|
trimmed == 'true') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// JWT must have 3 segments and be reasonably long
|
||||||
|
final segments = trimmed.split('.');
|
||||||
|
return segments.length == 3 && trimmed.length >= 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleToken(String token) async {
|
||||||
|
if (_tokenCaptured || !mounted) return;
|
||||||
|
|
||||||
|
final trimmedToken = token.trim();
|
||||||
|
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');
|
||||||
|
// Navigation is handled automatically by the router when auth state
|
||||||
|
// changes to authenticated. The router redirect will navigate to chat.
|
||||||
|
// We don't need to call context.go() here - it can cause race conditions.
|
||||||
|
} 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<void> _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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -862,5 +862,22 @@
|
|||||||
"widgetCamera": "Kamera",
|
"widgetCamera": "Kamera",
|
||||||
"widgetPhotos": "Fotos",
|
"widgetPhotos": "Fotos",
|
||||||
"widgetClipboard": "Zwischenablage",
|
"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.",
|
||||||
|
"continueWithProvider": "Weiter mit {provider}",
|
||||||
|
"or": "oder"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1858,5 +1858,79 @@
|
|||||||
"example": "100"
|
"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)."
|
||||||
|
},
|
||||||
|
"continueWithProvider": "Continue with {provider}",
|
||||||
|
"@continueWithProvider": {
|
||||||
|
"description": "Button text for OAuth provider sign-in.",
|
||||||
|
"placeholders": {
|
||||||
|
"provider": {
|
||||||
|
"type": "String",
|
||||||
|
"example": "Google"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"or": "or",
|
||||||
|
"@or": {
|
||||||
|
"description": "Separator text between authentication options."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -862,5 +862,22 @@
|
|||||||
"widgetCamera": "Cámara",
|
"widgetCamera": "Cámara",
|
||||||
"widgetPhotos": "Fotos",
|
"widgetPhotos": "Fotos",
|
||||||
"widgetClipboard": "Portapapeles",
|
"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.",
|
||||||
|
"continueWithProvider": "Continuar con {provider}",
|
||||||
|
"or": "o"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -862,5 +862,22 @@
|
|||||||
"widgetCamera": "Appareil photo",
|
"widgetCamera": "Appareil photo",
|
||||||
"widgetPhotos": "Photos",
|
"widgetPhotos": "Photos",
|
||||||
"widgetClipboard": "Presse-papiers",
|
"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.",
|
||||||
|
"continueWithProvider": "Continuer avec {provider}",
|
||||||
|
"or": "ou"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -862,5 +862,22 @@
|
|||||||
"widgetCamera": "Fotocamera",
|
"widgetCamera": "Fotocamera",
|
||||||
"widgetPhotos": "Foto",
|
"widgetPhotos": "Foto",
|
||||||
"widgetClipboard": "Appunti",
|
"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.",
|
||||||
|
"continueWithProvider": "Continua con {provider}",
|
||||||
|
"or": "o"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -640,5 +640,22 @@
|
|||||||
"widgetCamera": "카메라",
|
"widgetCamera": "카메라",
|
||||||
"widgetPhotos": "사진",
|
"widgetPhotos": "사진",
|
||||||
"widgetClipboard": "클립보드",
|
"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 인증을 사용하세요.",
|
||||||
|
"continueWithProvider": "{provider}(으)로 계속",
|
||||||
|
"or": "또는"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -862,5 +862,22 @@
|
|||||||
"widgetCamera": "Camera",
|
"widgetCamera": "Camera",
|
||||||
"widgetPhotos": "Foto's",
|
"widgetPhotos": "Foto's",
|
||||||
"widgetClipboard": "Klembord",
|
"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.",
|
||||||
|
"continueWithProvider": "Doorgaan met {provider}",
|
||||||
|
"or": "of"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -862,5 +862,22 @@
|
|||||||
"widgetCamera": "Камера",
|
"widgetCamera": "Камера",
|
||||||
"widgetPhotos": "Фотографии",
|
"widgetPhotos": "Фотографии",
|
||||||
"widgetClipboard": "Буфер обмена",
|
"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-аутентификацию.",
|
||||||
|
"continueWithProvider": "Продолжить с {provider}",
|
||||||
|
"or": "или"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -862,5 +862,22 @@
|
|||||||
"widgetCamera": "相机",
|
"widgetCamera": "相机",
|
||||||
"widgetPhotos": "照片",
|
"widgetPhotos": "照片",
|
||||||
"widgetClipboard": "剪贴板",
|
"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 验证。",
|
||||||
|
"continueWithProvider": "使用 {provider} 继续",
|
||||||
|
"or": "或"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -862,5 +862,22 @@
|
|||||||
"widgetCamera": "相機",
|
"widgetCamera": "相機",
|
||||||
"widgetPhotos": "照片",
|
"widgetPhotos": "照片",
|
||||||
"widgetClipboard": "剪貼簿",
|
"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 驗證。",
|
||||||
|
"continueWithProvider": "使用 {provider} 繼續",
|
||||||
|
"or": "或"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user