feat(auth): Add LDAP and SSO authentication support

This commit is contained in:
cogwheel0
2025-12-11 17:36:22 +05:30
parent 43ba1bcdc2
commit ea61168184
20 changed files with 1395 additions and 45 deletions

View File

@@ -8,6 +8,7 @@ import '../models/user.dart';
import '../services/optimized_storage_service.dart';
import 'token_validator.dart';
import 'auth_cache_manager.dart';
import 'webview_cookie_helper.dart';
import '../utils/debug_logger.dart';
import '../utils/user_avatar_utils.dart';
import '../../features/tools/providers/tools_providers.dart';
@@ -288,11 +289,17 @@ class AuthStateManager extends _$AuthStateManager {
}
}
/// Perform login with JWT token
/// Perform login with JWT token.
///
/// Note: API keys (sk-...) are not supported for streaming.
///
/// [authType] specifies the source of the token for credential storage:
/// - 'token': Manual JWT entry (default)
/// - 'sso': Token obtained via SSO/OAuth flow
Future<bool> loginWithApiKey(
String apiKey, {
bool rememberCredentials = false,
String authType = 'token',
}) async {
_update(
(current) => current.copyWith(
@@ -347,6 +354,7 @@ class AuthStateManager extends _$AuthStateManager {
serverId: activeServer.id,
username: 'jwt_user', // Special username to indicate JWT auth
password: tokenStr, // Store JWT in password field
authType: authType, // 'token' for manual entry, 'sso' for OAuth
);
}
}
@@ -486,6 +494,117 @@ class AuthStateManager extends _$AuthStateManager {
}
}
/// Perform login with LDAP credentials.
///
/// LDAP uses username (not email) for authentication.
/// The server must have LDAP enabled, otherwise this will throw an error.
Future<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
Future<void> _ensureApiServiceAvailable({
Duration timeout = const Duration(seconds: 2),
@@ -582,12 +701,27 @@ class AuthStateManager extends _$AuthStateManager {
return false;
}
// Attempt login (detect API key vs normal credentials)
if (username == 'api_key_user' || username == 'jwt_user') {
// This is a saved JWT token (or legacy API key)
return await loginWithApiKey(password, rememberCredentials: false);
// Attempt login based on auth type
final authType = savedCredentials['authType'] ?? 'credentials';
// Handle JWT token-based authentication (includes legacy prefixes)
// LDAP now also stores JWT tokens for re-auth (not raw passwords)
if (username == 'api_key_user' ||
username == 'jwt_user' ||
username.startsWith('ldap:') ||
authType == 'token' ||
authType == 'sso' ||
authType == 'ldap') {
// This is a saved JWT token (manual entry, SSO, or LDAP-obtained)
// For LDAP, we store the JWT token returned by the server, not the
// original password, for security reasons
return await loginWithApiKey(
password, // This is the JWT token
rememberCredentials: false,
authType: authType,
);
} else {
// Normal username/password credentials
// Standard credentials login (default)
return await login(username, password, rememberCredentials: false);
}
} catch (e, stack) {
@@ -805,6 +939,20 @@ class AuthStateManager extends _$AuthStateManager {
await storage.clearAuthData();
_updateApiServiceToken(null);
// Clear WebView cookies to ensure fresh SSO sessions on next login
try {
final cleared = await WebViewCookieHelper.clearCookies();
if (cleared) {
DebugLogger.auth('WebView cookies cleared');
}
} catch (e) {
DebugLogger.warning(
'webview-cookie-clear-failed',
scope: 'auth/state',
data: {'error': e.toString()},
);
}
// Keep active server ID so router redirects to sign-in page, not server
// connection page. Users can navigate to server settings if they need to
// change server configuration.

View File

@@ -0,0 +1,33 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// Check if WebView is supported on the current platform.
///
/// webview_flutter only supports iOS and Android.
bool get isWebViewSupported =>
!kIsWeb && (Platform.isIOS || Platform.isAndroid);
/// Helper for clearing WebView cookies on supported platforms.
///
/// This is isolated in its own file to prevent platform coupling issues
/// when the webview_flutter package isn't available.
class WebViewCookieHelper {
/// Clears all WebView cookies.
///
/// Returns true if cookies were cleared, false if not supported or failed.
/// Checks platform support internally, so safe to call on any platform.
static Future<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;
}
}
}

View File

@@ -14,6 +14,7 @@ import '../../features/auth/views/authentication_page.dart';
import '../../features/auth/views/connect_signin_page.dart';
import '../../features/auth/views/connection_issue_page.dart';
import '../../features/auth/views/server_connection_page.dart';
import '../../features/auth/views/sso_auth_page.dart';
import '../../features/chat/views/chat_page.dart';
import '../../features/navigation/views/splash_launcher_page.dart';
import '../../features/notes/views/notes_list_page.dart';
@@ -237,6 +238,16 @@ final goRouterProvider = Provider<GoRouter>((ref) {
);
},
),
GoRoute(
path: Routes.ssoAuth,
name: RouteNames.ssoAuth,
builder: (context, state) {
final config = state.extra;
return SsoAuthPage(
serverConfig: config is ServerConfig ? config : null,
);
},
),
GoRoute(
path: Routes.profile,
name: RouteNames.profile,

View File

@@ -355,6 +355,46 @@ class ApiService {
await _dio.get('/api/v1/auths/signout');
}
/// LDAP authentication - uses username instead of email.
///
/// Returns the same response format as regular login:
/// `{"token": "...", "token_type": "Bearer", "id": "...", ...}`
///
/// Throws an exception if LDAP is not enabled on the server (400 response).
Future<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
Future<User> getCurrentUser() async {
final response = await _dio.get('/api/v1/auths/');

View File

@@ -101,6 +101,7 @@ class Routes {
static const String serverConnection = '/server-connection';
static const String connectionIssue = '/connection-issue';
static const String authentication = '/authentication';
static const String ssoAuth = '/sso-auth';
static const String profile = '/profile';
static const String appCustomization = '/profile/customization';
static const String notes = '/notes';
@@ -115,6 +116,7 @@ class RouteNames {
static const String serverConnection = 'server-connection';
static const String connectionIssue = 'connection-issue';
static const String authentication = 'authentication';
static const String ssoAuth = 'sso-auth';
static const String profile = 'profile';
static const String appCustomization = 'app-customization';
static const String notes = 'notes';

View File

@@ -132,12 +132,14 @@ class OptimizedStorageService {
required String serverId,
required String username,
required String password,
String authType = 'credentials',
}) async {
try {
await _secureCredentialStorage.saveCredentials(
serverId: serverId,
username: username,
password: password,
authType: authType,
);
_cacheManager.write('has_credentials', true, ttl: _credentialsFlagTtl);

View File

@@ -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({
required String serverId,
required String username,
required String password,
String authType = 'credentials',
}) async {
try {
// First check if secure storage is available
@@ -57,9 +64,10 @@ class SecureCredentialStorage {
'serverId': serverId,
'username': username,
'password': password,
'authType': authType,
'savedAt': DateTime.now().toIso8601String(),
'deviceId': await _getDeviceFingerprint(),
'version': '2.0', // Version for migration purposes
'version': '2.1', // Version for migration purposes
};
final encryptedData = await _encryptData(jsonEncode(credentials));
@@ -76,7 +84,7 @@ class SecureCredentialStorage {
DebugLogger.storage(
'save-ok',
scope: 'credentials',
data: {'version': '2.0'},
data: {'version': '2.1'},
);
} catch (e) {
DebugLogger.error('save-failed', scope: 'credentials', error: e);
@@ -156,6 +164,7 @@ class SecureCredentialStorage {
'username': decoded['username']?.toString() ?? '',
'password': decoded['password']?.toString() ?? '',
'savedAt': decoded['savedAt']?.toString() ?? '',
'authType': decoded['authType']?.toString() ?? 'credentials',
};
} catch (e) {
DebugLogger.error('read-failed', scope: 'credentials', error: e);
@@ -355,7 +364,9 @@ class SecureCredentialStorage {
}
}
/// Migrate from old storage format if needed
/// Migrate from old storage format if needed.
///
/// Preserves the [authType] if present in old credentials.
Future<void> migrateFromOldStorage(
Map<String, String>? oldCredentials,
) async {
@@ -366,6 +377,7 @@ class SecureCredentialStorage {
serverId: oldCredentials['serverId'] ?? '',
username: oldCredentials['username'] ?? '',
password: oldCredentials['password'] ?? '',
authType: oldCredentials['authType'] ?? 'credentials',
);
DebugLogger.storage('migrate-ok', scope: 'credentials');
} catch (e) {

View File

@@ -29,10 +29,24 @@ class AuthActions {
Future<bool> loginWithApiKey(
String apiKey, {
bool rememberCredentials = false,
String authType = 'token',
}) {
return _auth.loginWithApiKey(
apiKey,
rememberCredentials: rememberCredentials,
authType: authType,
);
}
Future<bool> ldapLogin(
String username,
String password, {
bool rememberCredentials = false,
}) {
return _auth.ldapLogin(
username,
password,
rememberCredentials: rememberCredentials,
);
}

View File

@@ -17,6 +17,15 @@ import '../../../core/auth/auth_state_manager.dart';
import '../../../core/utils/debug_logger.dart';
import 'package:conduit/l10n/app_localizations.dart';
import '../providers/unified_auth_providers.dart';
import '../../../core/auth/webview_cookie_helper.dart' show isWebViewSupported;
/// Authentication mode options
enum AuthMode {
credentials, // Email/password
token, // JWT token
sso, // OAuth/OIDC via WebView
ldap, // LDAP username/password
}
class AuthenticationPage extends ConsumerStatefulWidget {
final ServerConfig? serverConfig;
@@ -32,9 +41,11 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _apiKeyController = TextEditingController();
final TextEditingController _ldapUsernameController = TextEditingController();
final TextEditingController _ldapPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _useApiKey = false;
AuthMode _authMode = AuthMode.credentials;
String? _loginError;
bool _isSigningIn = false;
bool _serverConfigSaved = false;
@@ -56,7 +67,7 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
_loginError = _formatLoginError(authState.error!);
// Switch to token tab if the error is about API keys
if (authState.error!.contains('apiKey')) {
_useApiKey = true;
_authMode = AuthMode.token;
}
});
}
@@ -77,6 +88,8 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
_usernameController.dispose();
_passwordController.dispose();
_apiKeyController.dispose();
_ldapUsernameController.dispose();
_ldapPasswordController.dispose();
super.dispose();
}
@@ -100,17 +113,27 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
final actions = ref.read(authActionsProvider);
bool success;
if (_useApiKey) {
success = await actions.loginWithApiKey(
_apiKeyController.text.trim(),
rememberCredentials: true,
);
} else {
success = await actions.login(
_usernameController.text.trim(),
_passwordController.text,
rememberCredentials: true,
);
switch (_authMode) {
case AuthMode.credentials:
success = await actions.login(
_usernameController.text.trim(),
_passwordController.text,
rememberCredentials: true,
);
case AuthMode.token:
success = await actions.loginWithApiKey(
_apiKeyController.text.trim(),
rememberCredentials: true,
);
case AuthMode.ldap:
success = await actions.ldapLogin(
_ldapUsernameController.text.trim(),
_ldapPasswordController.text,
rememberCredentials: true,
);
case AuthMode.sso:
// SSO is handled by navigating to SsoAuthPage
return;
}
if (!success) {
@@ -149,6 +172,8 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
return l10n.apiKeyNotSupported;
} else if (error.contains('apiKeyNoLongerSupported')) {
return l10n.apiKeyNoLongerSupported;
} else if (error.contains('LDAP authentication is not enabled')) {
return l10n.ldapNotEnabled;
} else if (error.contains('401') || error.contains('Unauthorized')) {
return l10n.invalidCredentials;
} else if (error.contains('redirect')) {
@@ -374,10 +399,12 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
}
Widget _buildAuthForm() {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Authentication mode toggle
// Primary authentication mode toggle (Credentials/Token)
_buildAuthModeToggle(),
const SizedBox(height: Spacing.lg),
@@ -389,11 +416,19 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
const SizedBox(height: Spacing.md),
_buildErrorMessage(_loginError!),
],
// More options section (SSO/LDAP)
const SizedBox(height: Spacing.lg),
_buildMoreOptionsSection(l10n),
],
);
}
Widget _buildAuthModeToggle() {
final l10n = AppLocalizations.of(context)!;
final isPrimaryMode =
_authMode == AuthMode.credentials || _authMode == AuthMode.token;
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
@@ -411,9 +446,13 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
icon: Platform.isIOS
? CupertinoIcons.person_circle
: Icons.account_circle_outlined,
label: AppLocalizations.of(context)!.credentials,
isSelected: !_useApiKey,
onTap: () => setState(() => _useApiKey = false),
label: l10n.credentials,
isSelected: _authMode == AuthMode.credentials && isPrimaryMode,
onTap: () => setState(() {
_authMode = AuthMode.credentials;
_loginError = null;
_obscurePassword = true; // Reset visibility on mode change
}),
),
),
Expanded(
@@ -421,9 +460,13 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
icon: Platform.isIOS
? CupertinoIcons.lock_shield
: Icons.vpn_key_outlined,
label: AppLocalizations.of(context)!.token,
isSelected: _useApiKey,
onTap: () => setState(() => _useApiKey = true),
label: l10n.token,
isSelected: _authMode == AuthMode.token && isPrimaryMode,
onTap: () => setState(() {
_authMode = AuthMode.token;
_loginError = null;
_obscurePassword = true; // Reset visibility on mode change
}),
),
),
],
@@ -498,10 +541,23 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
),
);
},
child: _useApiKey ? _buildApiKeyForm() : _buildCredentialsForm(),
child: _buildCurrentAuthForm(),
);
}
Widget _buildCurrentAuthForm() {
switch (_authMode) {
case AuthMode.credentials:
return _buildCredentialsForm();
case AuthMode.token:
return _buildApiKeyForm();
case AuthMode.ldap:
return _buildLdapForm();
case AuthMode.sso:
return _buildSsoPrompt();
}
}
/// Validates that a token is a JWT and not an API key.
/// API keys (sk-, api-, key-) don't work with WebSocket authentication.
String? _validateJwtToken(String? value) {
@@ -632,15 +688,287 @@ class _AuthenticationPageState extends ConsumerState<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) {
return Column(
children: [
// Divider with "or" text
Row(
children: [
Expanded(
child: Divider(
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
child: Text(
l10n.moreSignInOptions,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
),
Expanded(
child: Divider(
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
),
),
],
),
const SizedBox(height: Spacing.md),
// SSO and LDAP buttons
// SSO is only available on platforms that support WebView (iOS/Android)
Row(
children: [
if (isWebViewSupported) ...[
Expanded(
child: _buildOptionButton(
icon: Platform.isIOS
? CupertinoIcons.lock_shield
: Icons.security,
label: l10n.sso,
isSelected: _authMode == AuthMode.sso,
onTap: () => setState(() {
_authMode = AuthMode.sso;
_loginError = null;
_obscurePassword = true; // Reset visibility on mode change
}),
),
),
const SizedBox(width: Spacing.sm),
],
Expanded(
child: _buildOptionButton(
icon: Platform.isIOS
? CupertinoIcons.building_2_fill
: Icons.domain,
label: l10n.ldap,
isSelected: _authMode == AuthMode.ldap,
onTap: () => setState(() {
_authMode = AuthMode.ldap;
_loginError = null;
_obscurePassword = true; // Reset visibility on mode change
}),
),
),
],
),
],
);
}
Widget _buildOptionButton({
required IconData icon,
required String label,
required bool isSelected,
required VoidCallback onTap,
}) {
return Material(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1)
: context.conduitTheme.surfaceContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppBorderRadius.small),
child: Container(
padding: const EdgeInsets.symmetric(
vertical: Spacing.md,
horizontal: Spacing.sm,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
width: BorderWidth.standard,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: IconSize.small,
color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.iconSecondary,
),
const SizedBox(width: Spacing.xs),
Text(
label,
style: context.conduitTheme.bodySmall?.copyWith(
color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
),
);
}
Widget _buildSignInButton() {
final l10n = AppLocalizations.of(context)!;
// Don't show sign-in button for SSO mode (it has its own button)
if (_authMode == AuthMode.sso) {
return const SizedBox.shrink();
}
String buttonText;
if (_isSigningIn) {
buttonText = l10n.signingIn;
} else {
switch (_authMode) {
case AuthMode.credentials:
buttonText = l10n.signIn;
case AuthMode.token:
buttonText = l10n.signInWithToken;
case AuthMode.ldap:
buttonText = l10n.signInWithLdap;
case AuthMode.sso:
buttonText = l10n.signInWithSso;
}
}
return Padding(
padding: const EdgeInsets.only(top: Spacing.lg),
child: ConduitButton(
text: _isSigningIn
? AppLocalizations.of(context)!.signingIn
: _useApiKey
? AppLocalizations.of(context)!.signInWithToken
: AppLocalizations.of(context)!.signIn,
text: buttonText,
icon: _isSigningIn
? null
: (Platform.isIOS

View File

@@ -0,0 +1,565 @@
import 'dart:io' show Platform;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../core/auth/webview_cookie_helper.dart';
import '../../../core/models/server_config.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/services/navigation_service.dart';
import '../../../core/utils/debug_logger.dart';
import '../../../core/widgets/error_boundary.dart';
import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/conduit_components.dart';
import 'package:conduit/l10n/app_localizations.dart';
import '../providers/unified_auth_providers.dart';
/// SSO Authentication page that uses a WebView to handle OAuth/OIDC flows.
///
/// This page loads the Open-WebUI `/auth` page in a WebView, allowing users
/// to authenticate via configured OAuth providers (Google, Microsoft, GitHub,
/// OIDC, etc.). After successful authentication, the JWT token is captured
/// from cookies or localStorage and used to authenticate in Conduit.
class SsoAuthPage extends ConsumerStatefulWidget {
final ServerConfig? serverConfig;
const SsoAuthPage({super.key, this.serverConfig});
@override
ConsumerState<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,
),
)
..setUserAgent(_buildUserAgent());
// Clear cookies before loading to ensure fresh session
if (isWebViewSupported) {
await WebViewCookieManager().clearCookies();
}
if (!mounted) return;
// Load the auth page
await controller.loadRequest(Uri.parse('$_serverUrl/auth'));
if (!mounted) return;
setState(() {
_controller = controller;
});
}
String _buildUserAgent() {
// Use a standard mobile browser user agent to ensure OAuth providers work correctly
// Note: webview_flutter only supports iOS and Android; guard against web to be safe
if (!kIsWeb && Platform.isIOS) {
return 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
} else {
// Android (or fallback) - use mobile Chrome
return 'Mozilla/5.0 (Linux; Android 14) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
}
}
void _onPageStarted(String url) {
DebugLogger.auth('SSO page started: $url');
// Increment attempt ID to cancel any in-progress retry sequences
_captureAttemptId++;
setState(() {
_isLoading = true;
_error = null;
});
}
Future<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;
}
// Only check for token on /auth page (after OAuth callback redirect)
// or on the root page (some configurations)
if (!uri.path.endsWith('/auth') && uri.path != '/') return;
// Wait a moment for the frontend to persist the token
// The OAuth callback sets the cookie, then redirects to /auth,
// where the frontend reads the cookie and stores it in localStorage
final attemptId = _captureAttemptId;
await _attemptTokenCaptureWithRetry(uri, attemptId: attemptId);
}
/// Attempt token capture with retries to handle timing issues.
///
/// The Open-WebUI frontend needs a moment to read the token cookie
/// and store it in localStorage after the OAuth redirect.
///
/// [attemptId] is used to cancel this retry sequence if a new page load starts.
Future<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 (tokenValue.isNotEmpty) {
DebugLogger.auth('Found token in cookie');
await _handleToken(tokenValue);
return true;
}
} catch (e) {
DebugLogger.warning(
'sso-cookie-read-failed',
scope: 'auth/sso',
data: {'error': e.toString()},
);
}
// Abort if widget disposed or new page load started
if (!mounted || attemptId != _captureAttemptId) return false;
// Strategy 2: Check localStorage (fallback - frontend sets this)
try {
final result = await controller.runJavaScriptReturningResult(
'localStorage.getItem("token")',
);
// Abort if widget disposed or new page load started
if (!mounted || attemptId != _captureAttemptId) return false;
String tokenValue = _cleanJsString(result.toString());
if (tokenValue.isNotEmpty && tokenValue != 'null') {
DebugLogger.auth('Found token in localStorage');
await _handleToken(tokenValue);
return true;
}
} catch (e) {
DebugLogger.warning(
'sso-localstorage-read-failed',
scope: 'auth/sso',
data: {'error': e.toString()},
);
}
return false;
}
/// Clean JavaScript string result by removing surrounding quotes
String _cleanJsString(String value) {
if (value.startsWith('"') && value.endsWith('"')) {
return value.substring(1, value.length - 1);
}
return value;
}
Future<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;
setState(() {
_isLoading = true;
});
// Capture localized error message before async gap
final ssoFailedMessage =
AppLocalizations.of(context)?.ssoAuthFailed ??
'SSO authentication failed';
try {
final authActions = ref.read(authActionsProvider);
final success = await authActions.loginWithApiKey(
trimmedToken,
rememberCredentials: true,
authType: 'sso', // Mark as SSO-obtained token for traceability
);
if (!mounted) return;
if (success) {
DebugLogger.auth('SSO login successful, navigating to chat');
context.go(Routes.chat);
} else {
setState(() {
_error = ssoFailedMessage;
_isLoading = false;
_tokenCaptured = false;
});
}
} catch (e) {
DebugLogger.error(
'sso-token-handling-failed',
scope: 'auth/sso',
error: e,
);
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
_tokenCaptured = false;
});
}
}
void _onWebResourceError(WebResourceError error) {
DebugLogger.error(
'sso-webview-error',
scope: 'auth/sso',
data: {
'errorCode': error.errorCode,
'description': error.description,
'errorType': error.errorType?.name,
},
);
// Only show error for main frame failures
if (error.isForMainFrame ?? false) {
setState(() {
_error = error.description;
_isLoading = false;
});
}
}
NavigationDecision _onNavigationRequest(NavigationRequest request) {
final url = request.url;
DebugLogger.auth('SSO navigation request: $url');
// Allow all navigation - OAuth flows require redirects to external
// identity providers and back. The WebView is sandboxed and the token
// is only captured when the user returns to the Open-WebUI /auth page.
//
// We log the URL for debugging but don't restrict navigation since:
// 1. OAuth providers may use various redirect URLs
// 2. The user initiated this flow intentionally
// 3. Token capture only happens on the configured server's /auth page
return NavigationDecision.navigate;
}
Future<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,
),
],
),
),
);
}
}

View File

@@ -862,5 +862,20 @@
"widgetCamera": "Kamera",
"widgetPhotos": "Fotos",
"widgetClipboard": "Zwischenablage",
"widgetDescription": "Schnellzugriff auf den Conduit-Chat mit Kamera, Fotos und Zwischenablage-Verknüpfungen"
"widgetDescription": "Schnellzugriff auf den Conduit-Chat mit Kamera, Fotos und Zwischenablage-Verknüpfungen",
"sso": "SSO",
"ssoDescription": "Mit dem Identitätsanbieter Ihrer Organisation anmelden",
"signInWithSso": "Mit SSO anmelden",
"ssoAuthenticating": "Authentifizierung...",
"ssoAuthFailed": "SSO-Authentifizierung fehlgeschlagen",
"ssoTokenNotFound": "Authentifizierungstoken vom SSO-Anbieter konnte nicht abgerufen werden",
"ssoLoadingLogin": "Anmeldeseite wird geladen...",
"ldap": "LDAP",
"ldapDescription": "Mit Ihren LDAP-Verzeichnis-Anmeldedaten anmelden",
"signInWithLdap": "Mit LDAP anmelden",
"ldapUsername": "Benutzername",
"ldapUsernameHint": "Geben Sie Ihren LDAP-Benutzernamen ein",
"moreSignInOptions": "Weitere Anmeldeoptionen",
"ldapNotEnabled": "LDAP-Authentifizierung ist auf diesem Server nicht aktiviert",
"ssoPlatformNotSupported": "SSO-Authentifizierung wird auf dieser Plattform nicht unterstützt. Bitte verwenden Sie stattdessen Anmeldedaten oder LDAP-Authentifizierung."
}

View File

@@ -1858,5 +1858,65 @@
"example": "100"
}
}
},
"sso": "SSO",
"@sso": {
"description": "Label for Single Sign-On authentication option."
},
"ssoDescription": "Sign in with your organization's identity provider",
"@ssoDescription": {
"description": "Description text explaining SSO authentication."
},
"signInWithSso": "Sign in with SSO",
"@signInWithSso": {
"description": "Button text for SSO sign-in."
},
"ssoAuthenticating": "Authenticating...",
"@ssoAuthenticating": {
"description": "Loading message during SSO authentication."
},
"ssoAuthFailed": "SSO authentication failed",
"@ssoAuthFailed": {
"description": "Error message when SSO authentication fails."
},
"ssoTokenNotFound": "Could not retrieve authentication token from SSO provider",
"@ssoTokenNotFound": {
"description": "Error message when SSO token cannot be captured."
},
"ssoLoadingLogin": "Loading login page...",
"@ssoLoadingLogin": {
"description": "Loading message while SSO login page loads."
},
"ldap": "LDAP",
"@ldap": {
"description": "Label for LDAP authentication option."
},
"ldapDescription": "Sign in with your LDAP directory credentials",
"@ldapDescription": {
"description": "Description text explaining LDAP authentication."
},
"signInWithLdap": "Sign in with LDAP",
"@signInWithLdap": {
"description": "Button text for LDAP sign-in."
},
"ldapUsername": "Username",
"@ldapUsername": {
"description": "Label for LDAP username field."
},
"ldapUsernameHint": "Enter your LDAP username",
"@ldapUsernameHint": {
"description": "Hint text for LDAP username field."
},
"moreSignInOptions": "More sign-in options",
"@moreSignInOptions": {
"description": "Section header for additional authentication methods."
},
"ldapNotEnabled": "LDAP authentication is not enabled on this server",
"@ldapNotEnabled": {
"description": "Error message when LDAP is not configured on the server."
},
"ssoPlatformNotSupported": "SSO authentication is not supported on this platform. Please use credentials or LDAP authentication instead.",
"@ssoPlatformNotSupported": {
"description": "Error message when SSO is attempted on an unsupported platform (desktop/web)."
}
}

View File

@@ -862,5 +862,20 @@
"widgetCamera": "Cámara",
"widgetPhotos": "Fotos",
"widgetClipboard": "Portapapeles",
"widgetDescription": "Acceso rápido al chat de Conduit con cámara, fotos y atajos del portapapeles"
"widgetDescription": "Acceso rápido al chat de Conduit con cámara, fotos y atajos del portapapeles",
"sso": "SSO",
"ssoDescription": "Iniciar sesión con el proveedor de identidad de su organización",
"signInWithSso": "Iniciar sesión con SSO",
"ssoAuthenticating": "Autenticando...",
"ssoAuthFailed": "Error de autenticación SSO",
"ssoTokenNotFound": "No se pudo obtener el token de autenticación del proveedor SSO",
"ssoLoadingLogin": "Cargando página de inicio de sesión...",
"ldap": "LDAP",
"ldapDescription": "Iniciar sesión con sus credenciales de directorio LDAP",
"signInWithLdap": "Iniciar sesión con LDAP",
"ldapUsername": "Nombre de usuario",
"ldapUsernameHint": "Ingrese su nombre de usuario LDAP",
"moreSignInOptions": "Más opciones de inicio de sesión",
"ldapNotEnabled": "La autenticación LDAP no está habilitada en este servidor",
"ssoPlatformNotSupported": "La autenticación SSO no es compatible con esta plataforma. Por favor, use credenciales o autenticación LDAP en su lugar."
}

View File

@@ -862,5 +862,20 @@
"widgetCamera": "Appareil photo",
"widgetPhotos": "Photos",
"widgetClipboard": "Presse-papiers",
"widgetDescription": "Accès rapide au chat Conduit avec appareil photo, photos et raccourcis du presse-papiers"
"widgetDescription": "Accès rapide au chat Conduit avec appareil photo, photos et raccourcis du presse-papiers",
"sso": "SSO",
"ssoDescription": "Connectez-vous avec le fournisseur d'identité de votre organisation",
"signInWithSso": "Se connecter avec SSO",
"ssoAuthenticating": "Authentification...",
"ssoAuthFailed": "Échec de l'authentification SSO",
"ssoTokenNotFound": "Impossible de récupérer le jeton d'authentification du fournisseur SSO",
"ssoLoadingLogin": "Chargement de la page de connexion...",
"ldap": "LDAP",
"ldapDescription": "Connectez-vous avec vos identifiants d'annuaire LDAP",
"signInWithLdap": "Se connecter avec LDAP",
"ldapUsername": "Nom d'utilisateur",
"ldapUsernameHint": "Entrez votre nom d'utilisateur LDAP",
"moreSignInOptions": "Plus d'options de connexion",
"ldapNotEnabled": "L'authentification LDAP n'est pas activée sur ce serveur",
"ssoPlatformNotSupported": "L'authentification SSO n'est pas prise en charge sur cette plateforme. Veuillez utiliser les identifiants ou l'authentification LDAP à la place."
}

View File

@@ -862,5 +862,20 @@
"widgetCamera": "Fotocamera",
"widgetPhotos": "Foto",
"widgetClipboard": "Appunti",
"widgetDescription": "Accesso rapido alla chat di Conduit con fotocamera, foto e scorciatoie degli appunti"
"widgetDescription": "Accesso rapido alla chat di Conduit con fotocamera, foto e scorciatoie degli appunti",
"sso": "SSO",
"ssoDescription": "Accedi con il provider di identità della tua organizzazione",
"signInWithSso": "Accedi con SSO",
"ssoAuthenticating": "Autenticazione...",
"ssoAuthFailed": "Autenticazione SSO fallita",
"ssoTokenNotFound": "Impossibile recuperare il token di autenticazione dal provider SSO",
"ssoLoadingLogin": "Caricamento pagina di accesso...",
"ldap": "LDAP",
"ldapDescription": "Accedi con le credenziali della directory LDAP",
"signInWithLdap": "Accedi con LDAP",
"ldapUsername": "Nome utente",
"ldapUsernameHint": "Inserisci il tuo nome utente LDAP",
"moreSignInOptions": "Altre opzioni di accesso",
"ldapNotEnabled": "L'autenticazione LDAP non è abilitata su questo server",
"ssoPlatformNotSupported": "L'autenticazione SSO non è supportata su questa piattaforma. Usa invece le credenziali o l'autenticazione LDAP."
}

View File

@@ -640,5 +640,20 @@
"widgetCamera": "카메라",
"widgetPhotos": "사진",
"widgetClipboard": "클립보드",
"widgetDescription": "카메라, 사진 및 클립보드 바로가기로 Conduit 채팅에 빠르게 액세스"
"widgetDescription": "카메라, 사진 및 클립보드 바로가기로 Conduit 채팅에 빠르게 액세스",
"sso": "SSO",
"ssoDescription": "조직의 ID 공급자로 로그인",
"signInWithSso": "SSO로 로그인",
"ssoAuthenticating": "인증 중...",
"ssoAuthFailed": "SSO 인증 실패",
"ssoTokenNotFound": "SSO 공급자에서 인증 토큰을 검색할 수 없습니다",
"ssoLoadingLogin": "로그인 페이지 로딩 중...",
"ldap": "LDAP",
"ldapDescription": "LDAP 디렉토리 자격 증명으로 로그인",
"signInWithLdap": "LDAP으로 로그인",
"ldapUsername": "사용자 이름",
"ldapUsernameHint": "LDAP 사용자 이름을 입력하세요",
"moreSignInOptions": "추가 로그인 옵션",
"ldapNotEnabled": "이 서버에서 LDAP 인증이 활성화되어 있지 않습니다",
"ssoPlatformNotSupported": "이 플랫폼에서는 SSO 인증이 지원되지 않습니다. 대신 자격 증명 또는 LDAP 인증을 사용하세요."
}

View File

@@ -862,5 +862,20 @@
"widgetCamera": "Camera",
"widgetPhotos": "Foto's",
"widgetClipboard": "Klembord",
"widgetDescription": "Snelle toegang tot Conduit-chat met camera, foto's en klembordsnelkoppelingen"
"widgetDescription": "Snelle toegang tot Conduit-chat met camera, foto's en klembordsnelkoppelingen",
"sso": "SSO",
"ssoDescription": "Aanmelden met de identiteitsprovider van uw organisatie",
"signInWithSso": "Aanmelden met SSO",
"ssoAuthenticating": "Authenticeren...",
"ssoAuthFailed": "SSO-authenticatie mislukt",
"ssoTokenNotFound": "Kan authenticatietoken niet ophalen van SSO-provider",
"ssoLoadingLogin": "Inlogpagina laden...",
"ldap": "LDAP",
"ldapDescription": "Aanmelden met uw LDAP-directorygegevens",
"signInWithLdap": "Aanmelden met LDAP",
"ldapUsername": "Gebruikersnaam",
"ldapUsernameHint": "Voer uw LDAP-gebruikersnaam in",
"moreSignInOptions": "Meer aanmeldopties",
"ldapNotEnabled": "LDAP-authenticatie is niet ingeschakeld op deze server",
"ssoPlatformNotSupported": "SSO-authenticatie wordt niet ondersteund op dit platform. Gebruik in plaats daarvan inloggegevens of LDAP-authenticatie."
}

View File

@@ -862,5 +862,20 @@
"widgetCamera": "Камера",
"widgetPhotos": "Фотографии",
"widgetClipboard": "Буфер обмена",
"widgetDescription": "Быстрый доступ к чату Conduit с камерой, фотографиями и буфером обмена"
"widgetDescription": "Быстрый доступ к чату Conduit с камерой, фотографиями и буфером обмена",
"sso": "SSO",
"ssoDescription": "Войдите через провайдера идентификации вашей организации",
"signInWithSso": "Войти через SSO",
"ssoAuthenticating": "Аутентификация...",
"ssoAuthFailed": "Ошибка SSO-аутентификации",
"ssoTokenNotFound": "Не удалось получить токен аутентификации от провайдера SSO",
"ssoLoadingLogin": "Загрузка страницы входа...",
"ldap": "LDAP",
"ldapDescription": "Войдите с учётными данными каталога LDAP",
"signInWithLdap": "Войти через LDAP",
"ldapUsername": "Имя пользователя",
"ldapUsernameHint": "Введите имя пользователя LDAP",
"moreSignInOptions": "Дополнительные способы входа",
"ldapNotEnabled": "LDAP-аутентификация не включена на этом сервере",
"ssoPlatformNotSupported": "SSO-аутентификация не поддерживается на этой платформе. Пожалуйста, используйте учётные данные или LDAP-аутентификацию."
}

View File

@@ -862,5 +862,20 @@
"widgetCamera": "相机",
"widgetPhotos": "照片",
"widgetClipboard": "剪贴板",
"widgetDescription": "快速访问 Conduit 聊天,支持相机、照片和剪贴板快捷方式"
"widgetDescription": "快速访问 Conduit 聊天,支持相机、照片和剪贴板快捷方式",
"sso": "SSO",
"ssoDescription": "使用组织的身份提供商登录",
"signInWithSso": "使用 SSO 登录",
"ssoAuthenticating": "正在验证...",
"ssoAuthFailed": "SSO 验证失败",
"ssoTokenNotFound": "无法从 SSO 提供商获取验证令牌",
"ssoLoadingLogin": "正在加载登录页面...",
"ldap": "LDAP",
"ldapDescription": "使用 LDAP 目录凭据登录",
"signInWithLdap": "使用 LDAP 登录",
"ldapUsername": "用户名",
"ldapUsernameHint": "输入您的 LDAP 用户名",
"moreSignInOptions": "更多登录选项",
"ldapNotEnabled": "此服务器未启用 LDAP 验证",
"ssoPlatformNotSupported": "此平台不支持 SSO 验证。请改用凭据或 LDAP 验证。"
}

View File

@@ -862,5 +862,20 @@
"widgetCamera": "相機",
"widgetPhotos": "照片",
"widgetClipboard": "剪貼簿",
"widgetDescription": "快速存取 Conduit 聊天,支援相機、照片和剪貼簿捷徑"
"widgetDescription": "快速存取 Conduit 聊天,支援相機、照片和剪貼簿捷徑",
"sso": "SSO",
"ssoDescription": "使用組織的身份提供商登入",
"signInWithSso": "使用 SSO 登入",
"ssoAuthenticating": "正在驗證...",
"ssoAuthFailed": "SSO 驗證失敗",
"ssoTokenNotFound": "無法從 SSO 提供商取得驗證權杖",
"ssoLoadingLogin": "正在載入登入頁面...",
"ldap": "LDAP",
"ldapDescription": "使用 LDAP 目錄憑據登入",
"signInWithLdap": "使用 LDAP 登入",
"ldapUsername": "使用者名稱",
"ldapUsernameHint": "輸入您的 LDAP 使用者名稱",
"moreSignInOptions": "更多登入選項",
"ldapNotEnabled": "此伺服器未啟用 LDAP 驗證",
"ssoPlatformNotSupported": "此平台不支援 SSO 驗證。請改用憑據或 LDAP 驗證。"
}