feat(auth): Add OAuth providers and improve authentication flow
This commit is contained in:
@@ -939,15 +939,13 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
await storage.clearAuthData();
|
||||
_updateApiServiceToken(null);
|
||||
|
||||
// Clear WebView cookies to ensure fresh SSO sessions on next login
|
||||
// Clear all WebView data (cookies, localStorage, cache) to ensure
|
||||
// fresh SSO sessions on next login
|
||||
try {
|
||||
final cleared = await WebViewCookieHelper.clearCookies();
|
||||
if (cleared) {
|
||||
DebugLogger.auth('WebView cookies cleared');
|
||||
}
|
||||
await WebViewCookieHelper.clearAllWebViewData();
|
||||
} catch (e) {
|
||||
DebugLogger.warning(
|
||||
'webview-cookie-clear-failed',
|
||||
'webview-data-clear-failed',
|
||||
scope: 'auth/state',
|
||||
data: {'error': e.toString()},
|
||||
);
|
||||
|
||||
@@ -3,13 +3,15 @@ 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 cookies on supported platforms.
|
||||
/// 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.
|
||||
@@ -29,5 +31,44 @@ class WebViewCookieHelper {
|
||||
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';
|
||||
|
||||
/// 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.
|
||||
@immutable
|
||||
class BackendConfig {
|
||||
@@ -14,6 +89,9 @@ class BackendConfig {
|
||||
this.audioSampleRate,
|
||||
this.audioFrameSize,
|
||||
this.vadEnabled,
|
||||
this.oauthProviders = const OAuthProviders(),
|
||||
this.enableLdap = false,
|
||||
this.enableLoginForm = true,
|
||||
});
|
||||
|
||||
/// Mirrors `features.enable_websocket` from OpenWebUI.
|
||||
@@ -28,6 +106,18 @@ class BackendConfig {
|
||||
final int? audioFrameSize;
|
||||
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.
|
||||
BackendConfig copyWith({
|
||||
bool? enableWebsocket,
|
||||
@@ -40,6 +130,9 @@ class BackendConfig {
|
||||
int? audioSampleRate,
|
||||
int? audioFrameSize,
|
||||
bool? vadEnabled,
|
||||
OAuthProviders? oauthProviders,
|
||||
bool? enableLdap,
|
||||
bool? enableLoginForm,
|
||||
}) {
|
||||
return BackendConfig(
|
||||
enableWebsocket: enableWebsocket ?? this.enableWebsocket,
|
||||
@@ -52,6 +145,9 @@ class BackendConfig {
|
||||
audioSampleRate: audioSampleRate ?? this.audioSampleRate,
|
||||
audioFrameSize: audioFrameSize ?? this.audioFrameSize,
|
||||
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_frame_size': audioFrameSize,
|
||||
'vad_enabled': vadEnabled,
|
||||
'oauth': {'providers': oauthProviders.toJson()},
|
||||
'enable_ldap': enableLdap,
|
||||
'enable_login_form': enableLoginForm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +199,10 @@ class BackendConfig {
|
||||
int? audioSampleRate;
|
||||
int? audioFrameSize;
|
||||
bool? vadEnabled;
|
||||
OAuthProviders oauthProviders = const OAuthProviders();
|
||||
bool enableLdap = false;
|
||||
bool enableLoginForm = true;
|
||||
|
||||
// Try canonical format first
|
||||
final value = json['enable_websocket'];
|
||||
if (value is bool) {
|
||||
@@ -129,6 +232,21 @@ class BackendConfig {
|
||||
final vad = json['vad_enabled'];
|
||||
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
|
||||
final features = json['features'];
|
||||
if (features is Map<String, dynamic>) {
|
||||
@@ -172,6 +290,11 @@ class BackendConfig {
|
||||
if (nestedVad is bool && vadEnabled == null) {
|
||||
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(
|
||||
@@ -185,6 +308,9 @@ class BackendConfig {
|
||||
audioSampleRate: audioSampleRate,
|
||||
audioFrameSize: audioFrameSize,
|
||||
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 'backend_config.dart';
|
||||
|
||||
part 'server_config.freezed.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
|
||||
sealed class ServerConfig with _$ServerConfig {
|
||||
const factory ServerConfig({
|
||||
|
||||
@@ -180,7 +180,8 @@ class RouterNotifier extends ChangeNotifier {
|
||||
return location == Routes.serverConnection ||
|
||||
location == Routes.login ||
|
||||
location == Routes.authentication ||
|
||||
location == Routes.connectionIssue;
|
||||
location == Routes.connectionIssue ||
|
||||
location == Routes.ssoAuth;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -232,9 +233,16 @@ final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
path: Routes.authentication,
|
||||
name: RouteNames.authentication,
|
||||
builder: (context, state) {
|
||||
final config = state.extra;
|
||||
final extra = state.extra;
|
||||
// Support both AuthFlowConfig (new) and ServerConfig (legacy)
|
||||
if (extra is AuthFlowConfig) {
|
||||
return AuthenticationPage(
|
||||
serverConfig: config is ServerConfig ? config : null,
|
||||
serverConfig: extra.serverConfig,
|
||||
backendConfig: extra.backendConfig,
|
||||
);
|
||||
}
|
||||
return AuthenticationPage(
|
||||
serverConfig: extra is ServerConfig ? extra : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -232,15 +232,24 @@ class ApiService {
|
||||
///
|
||||
/// Returns `true` if the server appears to be a valid OpenWebUI instance.
|
||||
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 {
|
||||
final response = await _dio.get('/api/config');
|
||||
if (response.statusCode != 200) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for OpenWebUI-specific fields
|
||||
@@ -250,9 +259,13 @@ class ApiService {
|
||||
data['version'] is String && (data['version'] as String).isNotEmpty;
|
||||
final hasFeatures = data['features'] is Map;
|
||||
|
||||
return hasStatus && hasVersion && hasFeatures;
|
||||
if (!hasStatus || !hasVersion || !hasFeatures) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BackendConfig.fromJson(data);
|
||||
} catch (e) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/models/backend_config.dart';
|
||||
import '../../../core/models/server_config.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/services/input_validation_service.dart';
|
||||
@@ -29,8 +30,9 @@ enum AuthMode {
|
||||
|
||||
class AuthenticationPage extends ConsumerStatefulWidget {
|
||||
final ServerConfig? serverConfig;
|
||||
final BackendConfig? backendConfig;
|
||||
|
||||
const AuthenticationPage({super.key, this.serverConfig});
|
||||
const AuthenticationPage({super.key, this.serverConfig, this.backendConfig});
|
||||
|
||||
@override
|
||||
ConsumerState<AuthenticationPage> createState() => _AuthenticationPageState();
|
||||
@@ -49,10 +51,27 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
String? _loginError;
|
||||
bool _isSigningIn = 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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setDefaultAuthMode();
|
||||
_loadSavedCredentials();
|
||||
// Check for auth errors (e.g., forced logout due to API key)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -60,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() {
|
||||
final authState = ref.read(authStateManagerProvider).asData?.value;
|
||||
if (authState?.error != null && authState!.error!.isNotEmpty) {
|
||||
@@ -404,158 +439,108 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Primary authentication mode toggle (Credentials/Token)
|
||||
_buildAuthModeToggle(),
|
||||
|
||||
// Show SSO buttons prominently if OAuth providers are configured
|
||||
if (_hasSsoEnabled) ...[
|
||||
_buildSsoButtons(l10n),
|
||||
if (_hasLoginFormEnabled || _hasLdapEnabled) ...[
|
||||
const SizedBox(height: Spacing.lg),
|
||||
_buildDividerWithText(l10n.or),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
],
|
||||
],
|
||||
|
||||
// Authentication form fields
|
||||
_buildAuthFields(),
|
||||
// Show the appropriate form based on auth mode
|
||||
// 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) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
_buildErrorMessage(_loginError!),
|
||||
],
|
||||
|
||||
// More options section (SSO/LDAP)
|
||||
// More options section - always show for additional auth methods
|
||||
const SizedBox(height: Spacing.lg),
|
||||
_buildMoreOptionsSection(l10n),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAuthModeToggle() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final isPrimaryMode =
|
||||
_authMode == AuthMode.credentials || _authMode == AuthMode.token;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||
border: Border.all(
|
||||
Widget _buildDividerWithText(String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
|
||||
width: BorderWidth.standard,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildAuthToggleOption(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.person_circle
|
||||
: Icons.account_circle_outlined,
|
||||
label: l10n.credentials,
|
||||
isSelected: _authMode == AuthMode.credentials && isPrimaryMode,
|
||||
onTap: () => setState(() {
|
||||
_authMode = AuthMode.credentials;
|
||||
_loginError = null;
|
||||
_obscurePassword = true; // Reset visibility on mode change
|
||||
}),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildAuthToggleOption(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.lock_shield
|
||||
: Icons.vpn_key_outlined,
|
||||
label: l10n.token,
|
||||
isSelected: _authMode == AuthMode.token && isPrimaryMode,
|
||||
onTap: () => setState(() {
|
||||
_authMode = AuthMode.token;
|
||||
_loginError = null;
|
||||
_obscurePassword = true; // Reset visibility on mode change
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
|
||||
child: Text(
|
||||
text,
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: isSelected
|
||||
? context.conduitTheme.buttonPrimaryText
|
||||
: context.conduitTheme.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAuthFields() {
|
||||
return AnimatedSwitcher(
|
||||
duration: AnimationDuration.pageTransition,
|
||||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.1),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _buildCurrentAuthForm(),
|
||||
Widget _buildSsoButtons(AppLocalizations l10n) {
|
||||
final providers = _oauthProviders.enabledProviders;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (int i = 0; i < providers.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: Spacing.sm),
|
||||
_buildOAuthButton(providers[i], l10n),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentAuthForm() {
|
||||
switch (_authMode) {
|
||||
case AuthMode.credentials:
|
||||
return _buildCredentialsForm();
|
||||
case AuthMode.token:
|
||||
return _buildApiKeyForm();
|
||||
case AuthMode.ldap:
|
||||
return _buildLdapForm();
|
||||
case AuthMode.sso:
|
||||
return _buildSsoPrompt();
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates that a token is a JWT and not an API key.
|
||||
@@ -817,71 +802,137 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
}
|
||||
|
||||
Widget _buildMoreOptionsSection(AppLocalizations l10n) {
|
||||
return Column(
|
||||
children: [
|
||||
// Divider with "or" text
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
|
||||
child: Text(
|
||||
l10n.moreSignInOptions,
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
// Build list of available options - always show all available options
|
||||
// with the current one highlighted for consistency
|
||||
final options = <Widget>[];
|
||||
|
||||
// SSO and LDAP buttons
|
||||
// SSO is only available on platforms that support WebView (iOS/Android)
|
||||
Row(
|
||||
children: [
|
||||
if (isWebViewSupported) ...[
|
||||
Expanded(
|
||||
child: _buildOptionButton(
|
||||
// Credentials option (if login form is enabled)
|
||||
if (_hasLoginFormEnabled) {
|
||||
options.add(
|
||||
_buildOptionButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.lock_shield
|
||||
: Icons.security,
|
||||
? 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; // Reset visibility on mode change
|
||||
_obscurePassword = true;
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
],
|
||||
Expanded(
|
||||
child: _buildOptionButton(
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.building_2_fill
|
||||
: Icons.domain,
|
||||
);
|
||||
}
|
||||
|
||||
// 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; // Reset visibility on mode change
|
||||
_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]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
'Verifying OpenWebUI server...',
|
||||
scope: 'auth/connection',
|
||||
);
|
||||
final isOpenWebUI = await api.verifyIsOpenWebUIServer();
|
||||
final backendConfig = await api.verifyAndGetConfig();
|
||||
DebugLogger.log(
|
||||
'OpenWebUI verification result: $isOpenWebUI',
|
||||
'OpenWebUI verification result: ${backendConfig != null}',
|
||||
scope: 'auth/connection',
|
||||
);
|
||||
if (!isOpenWebUI) {
|
||||
if (backendConfig == null) {
|
||||
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
|
||||
// The config is passed to the authentication page
|
||||
// The config is passed to the authentication page along with backend config
|
||||
if (mounted) {
|
||||
context.pushNamed(RouteNames.authentication, extra: tempConfig);
|
||||
final authFlowConfig = AuthFlowConfig(
|
||||
serverConfig: tempConfig,
|
||||
backendConfig: backendConfig,
|
||||
);
|
||||
context.pushNamed(RouteNames.authentication, extra: authFlowConfig);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
DebugLogger.error(
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:webview_flutter/webview_flutter.dart';
|
||||
import '../../../core/auth/webview_cookie_helper.dart';
|
||||
import '../../../core/models/server_config.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/services/navigation_service.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../core/widgets/error_boundary.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
@@ -104,6 +103,7 @@ class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||
onPageFinished: _onPageFinished,
|
||||
onWebResourceError: _onWebResourceError,
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
onUrlChange: _onUrlChange,
|
||||
),
|
||||
)
|
||||
..setUserAgent(_buildUserAgent());
|
||||
@@ -148,6 +148,28 @@ class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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');
|
||||
|
||||
@@ -169,12 +191,35 @@ class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check for token on /auth page (after OAuth callback redirect)
|
||||
// or on the root page (some configurations)
|
||||
if (!uri.path.endsWith('/auth') && uri.path != '/') return;
|
||||
// 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,
|
||||
// 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);
|
||||
@@ -248,16 +293,16 @@ class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||
if (!mounted || attemptId != _captureAttemptId) return false;
|
||||
|
||||
String tokenValue = _cleanJsString(cookieResult.toString());
|
||||
if (tokenValue.isNotEmpty) {
|
||||
DebugLogger.auth('Found token in cookie');
|
||||
if (_isValidJwtFormat(tokenValue)) {
|
||||
DebugLogger.auth('Found valid token in cookie');
|
||||
await _handleToken(tokenValue);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.warning(
|
||||
'sso-cookie-read-failed',
|
||||
// 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',
|
||||
data: {'error': e.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,16 +319,16 @@ class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||
if (!mounted || attemptId != _captureAttemptId) return false;
|
||||
|
||||
String tokenValue = _cleanJsString(result.toString());
|
||||
if (tokenValue.isNotEmpty && tokenValue != 'null') {
|
||||
DebugLogger.auth('Found token in localStorage');
|
||||
if (_isValidJwtFormat(tokenValue)) {
|
||||
DebugLogger.auth('Found valid token in localStorage');
|
||||
await _handleToken(tokenValue);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.warning(
|
||||
'sso-localstorage-read-failed',
|
||||
// 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',
|
||||
data: {'error': e.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,26 +343,30 @@ class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||
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;
|
||||
|
||||
// Basic validation before attempting login
|
||||
final trimmedToken = token.trim();
|
||||
// JWT tokens have 3 dot-separated segments and are typically 100+ chars
|
||||
final isValidFormat =
|
||||
trimmedToken.length >= 50 && trimmedToken.split('.').length == 3;
|
||||
if (trimmedToken.isEmpty || !isValidFormat) {
|
||||
DebugLogger.warning(
|
||||
'sso-token-invalid',
|
||||
scope: 'auth/sso',
|
||||
data: {
|
||||
'length': trimmedToken.length,
|
||||
'segments': trimmedToken.split('.').length,
|
||||
},
|
||||
);
|
||||
return; // Invalid token, don't mark as captured - allow retry
|
||||
}
|
||||
|
||||
DebugLogger.auth('Handling captured SSO token');
|
||||
_tokenCaptured = true;
|
||||
|
||||
@@ -341,8 +390,10 @@ class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
DebugLogger.auth('SSO login successful, navigating to chat');
|
||||
context.go(Routes.chat);
|
||||
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;
|
||||
@@ -562,4 +613,3 @@ class _SsoAuthPageState extends ConsumerState<SsoAuthPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -877,5 +877,7 @@
|
||||
"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."
|
||||
"ssoPlatformNotSupported": "SSO-Authentifizierung wird auf dieser Plattform nicht unterstützt. Bitte verwenden Sie stattdessen Anmeldedaten oder LDAP-Authentifizierung.",
|
||||
"continueWithProvider": "Weiter mit {provider}",
|
||||
"or": "oder"
|
||||
}
|
||||
|
||||
@@ -1918,5 +1918,19 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,5 +877,7 @@
|
||||
"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."
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -877,5 +877,7 @@
|
||||
"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."
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -877,5 +877,7 @@
|
||||
"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."
|
||||
"ssoPlatformNotSupported": "L'autenticazione SSO non è supportata su questa piattaforma. Usa invece le credenziali o l'autenticazione LDAP.",
|
||||
"continueWithProvider": "Continua con {provider}",
|
||||
"or": "o"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,7 @@
|
||||
"ldapUsernameHint": "LDAP 사용자 이름을 입력하세요",
|
||||
"moreSignInOptions": "추가 로그인 옵션",
|
||||
"ldapNotEnabled": "이 서버에서 LDAP 인증이 활성화되어 있지 않습니다",
|
||||
"ssoPlatformNotSupported": "이 플랫폼에서는 SSO 인증이 지원되지 않습니다. 대신 자격 증명 또는 LDAP 인증을 사용하세요."
|
||||
"ssoPlatformNotSupported": "이 플랫폼에서는 SSO 인증이 지원되지 않습니다. 대신 자격 증명 또는 LDAP 인증을 사용하세요.",
|
||||
"continueWithProvider": "{provider}(으)로 계속",
|
||||
"or": "또는"
|
||||
}
|
||||
|
||||
@@ -877,5 +877,7 @@
|
||||
"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."
|
||||
"ssoPlatformNotSupported": "SSO-authenticatie wordt niet ondersteund op dit platform. Gebruik in plaats daarvan inloggegevens of LDAP-authenticatie.",
|
||||
"continueWithProvider": "Doorgaan met {provider}",
|
||||
"or": "of"
|
||||
}
|
||||
|
||||
@@ -877,5 +877,7 @@
|
||||
"ldapUsernameHint": "Введите имя пользователя LDAP",
|
||||
"moreSignInOptions": "Дополнительные способы входа",
|
||||
"ldapNotEnabled": "LDAP-аутентификация не включена на этом сервере",
|
||||
"ssoPlatformNotSupported": "SSO-аутентификация не поддерживается на этой платформе. Пожалуйста, используйте учётные данные или LDAP-аутентификацию."
|
||||
"ssoPlatformNotSupported": "SSO-аутентификация не поддерживается на этой платформе. Пожалуйста, используйте учётные данные или LDAP-аутентификацию.",
|
||||
"continueWithProvider": "Продолжить с {provider}",
|
||||
"or": "или"
|
||||
}
|
||||
|
||||
@@ -877,5 +877,7 @@
|
||||
"ldapUsernameHint": "输入您的 LDAP 用户名",
|
||||
"moreSignInOptions": "更多登录选项",
|
||||
"ldapNotEnabled": "此服务器未启用 LDAP 验证",
|
||||
"ssoPlatformNotSupported": "此平台不支持 SSO 验证。请改用凭据或 LDAP 验证。"
|
||||
"ssoPlatformNotSupported": "此平台不支持 SSO 验证。请改用凭据或 LDAP 验证。",
|
||||
"continueWithProvider": "使用 {provider} 继续",
|
||||
"or": "或"
|
||||
}
|
||||
|
||||
@@ -877,5 +877,7 @@
|
||||
"ldapUsernameHint": "輸入您的 LDAP 使用者名稱",
|
||||
"moreSignInOptions": "更多登入選項",
|
||||
"ldapNotEnabled": "此伺服器未啟用 LDAP 驗證",
|
||||
"ssoPlatformNotSupported": "此平台不支援 SSO 驗證。請改用憑據或 LDAP 驗證。"
|
||||
"ssoPlatformNotSupported": "此平台不支援 SSO 驗證。請改用憑據或 LDAP 驗證。",
|
||||
"continueWithProvider": "使用 {provider} 繼續",
|
||||
"or": "或"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user