2025-08-16 15:51:27 +05:30
|
|
|
import 'dart:io' show Platform;
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
2025-09-22 14:36:43 +05:30
|
|
|
import 'package:go_router/go_router.dart';
|
2025-08-16 15:51:27 +05:30
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
import '../../../core/models/backend_config.dart';
|
2025-08-16 15:51:27 +05:30
|
|
|
import '../../../core/models/server_config.dart';
|
|
|
|
|
import '../../../core/providers/app_providers.dart';
|
|
|
|
|
import '../../../core/services/input_validation_service.dart';
|
|
|
|
|
import '../../../core/services/navigation_service.dart';
|
|
|
|
|
import '../../../core/widgets/error_boundary.dart';
|
|
|
|
|
import '../../../shared/services/brand_service.dart';
|
|
|
|
|
import '../../../shared/theme/theme_extensions.dart';
|
|
|
|
|
import '../../../shared/widgets/conduit_components.dart';
|
|
|
|
|
import '../../../core/auth/auth_state_manager.dart';
|
2025-08-20 22:15:26 +05:30
|
|
|
import '../../../core/utils/debug_logger.dart';
|
2025-08-23 20:09:43 +05:30
|
|
|
import 'package:conduit/l10n/app_localizations.dart';
|
2025-08-29 12:58:56 +05:30
|
|
|
import '../providers/unified_auth_providers.dart';
|
2025-12-11 17:36:22 +05:30
|
|
|
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
|
|
|
|
|
}
|
2025-08-16 15:51:27 +05:30
|
|
|
|
|
|
|
|
class AuthenticationPage extends ConsumerStatefulWidget {
|
2025-09-28 20:41:35 +05:30
|
|
|
final ServerConfig? serverConfig;
|
2025-12-11 18:45:18 +05:30
|
|
|
final BackendConfig? backendConfig;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
const AuthenticationPage({super.key, this.serverConfig, this.backendConfig});
|
2025-08-16 15:51:27 +05:30
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ConsumerState<AuthenticationPage> createState() => _AuthenticationPageState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
|
|
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
|
final TextEditingController _usernameController = TextEditingController();
|
|
|
|
|
final TextEditingController _passwordController = TextEditingController();
|
|
|
|
|
final TextEditingController _apiKeyController = TextEditingController();
|
2025-12-11 17:36:22 +05:30
|
|
|
final TextEditingController _ldapUsernameController = TextEditingController();
|
|
|
|
|
final TextEditingController _ldapPasswordController = TextEditingController();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
bool _obscurePassword = true;
|
2025-12-11 17:36:22 +05:30
|
|
|
AuthMode _authMode = AuthMode.credentials;
|
2025-08-16 15:51:27 +05:30
|
|
|
String? _loginError;
|
|
|
|
|
bool _isSigningIn = false;
|
2025-11-26 15:25:02 +05:30
|
|
|
bool _serverConfigSaved = false;
|
2025-12-11 18:45:18 +05:30
|
|
|
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();
|
2025-08-16 15:51:27 +05:30
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
2025-12-11 18:45:18 +05:30
|
|
|
_setDefaultAuthMode();
|
2025-08-16 15:51:27 +05:30
|
|
|
_loadSavedCredentials();
|
2025-12-05 13:50:26 +05:30
|
|
|
// Check for auth errors (e.g., forced logout due to API key)
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
_checkAuthStateError();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
/// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 13:50:26 +05:30
|
|
|
void _checkAuthStateError() {
|
|
|
|
|
final authState = ref.read(authStateManagerProvider).asData?.value;
|
|
|
|
|
if (authState?.error != null && authState!.error!.isNotEmpty) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_loginError = _formatLoginError(authState.error!);
|
|
|
|
|
// Switch to token tab if the error is about API keys
|
|
|
|
|
if (authState.error!.contains('apiKey')) {
|
2025-12-11 17:36:22 +05:30
|
|
|
_authMode = AuthMode.token;
|
2025-12-05 13:50:26 +05:30
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-08-16 15:51:27 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _loadSavedCredentials() async {
|
|
|
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
|
|
|
final savedCredentials = await storage.getSavedCredentials();
|
|
|
|
|
if (savedCredentials != null) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_usernameController.text = savedCredentials['username'] ?? '';
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_usernameController.dispose();
|
|
|
|
|
_passwordController.dispose();
|
|
|
|
|
_apiKeyController.dispose();
|
2025-12-11 17:36:22 +05:30
|
|
|
_ldapUsernameController.dispose();
|
|
|
|
|
_ldapPasswordController.dispose();
|
2025-08-16 15:51:27 +05:30
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _signIn() async {
|
2025-08-23 20:09:43 +05:30
|
|
|
final l10n = AppLocalizations.of(context)!;
|
2025-08-16 15:51:27 +05:30
|
|
|
if (!_formKey.currentState!.validate()) return;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
setState(() {
|
|
|
|
|
_isSigningIn = true;
|
|
|
|
|
_loginError = null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-26 15:25:02 +05:30
|
|
|
// Save server config on first sign-in attempt if it's a new config
|
|
|
|
|
// This persists the server so user can retry with different credentials
|
|
|
|
|
if (widget.serverConfig != null && !_serverConfigSaved) {
|
|
|
|
|
await _saveServerConfig(widget.serverConfig!);
|
|
|
|
|
_serverConfigSaved = true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 12:58:56 +05:30
|
|
|
final actions = ref.read(authActionsProvider);
|
2025-08-16 15:51:27 +05:30
|
|
|
bool success;
|
|
|
|
|
|
2025-12-11 17:36:22 +05:30
|
|
|
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;
|
2025-08-16 15:51:27 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
final authState = ref.read(authStateManagerProvider);
|
2025-08-23 20:09:43 +05:30
|
|
|
throw Exception(authState.error ?? l10n.loginFailed);
|
2025-08-16 15:51:27 +05:30
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
// Success - navigation will be handled by auth state change
|
|
|
|
|
} catch (e) {
|
2025-11-26 15:25:02 +05:30
|
|
|
// Don't clear server config on auth failure - user should be able to retry
|
|
|
|
|
// The server config is valid (passed OpenWebUI verification), only the
|
|
|
|
|
// credentials were wrong or there was a network issue
|
2025-08-16 15:51:27 +05:30
|
|
|
setState(() {
|
|
|
|
|
_loginError = _formatLoginError(e.toString());
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_isSigningIn = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 15:25:02 +05:30
|
|
|
Future<void> _saveServerConfig(ServerConfig config) async {
|
|
|
|
|
final storage = ref.read(optimizedStorageServiceProvider);
|
|
|
|
|
await storage.saveServerConfigs([config]);
|
|
|
|
|
await storage.setActiveServerId(config.id);
|
|
|
|
|
ref.invalidate(serverConfigsProvider);
|
|
|
|
|
ref.invalidate(activeServerProvider);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
String _formatLoginError(String error) {
|
2025-12-05 13:50:26 +05:30
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
|
if (error.contains('apiKeyNotSupported')) {
|
|
|
|
|
return l10n.apiKeyNotSupported;
|
|
|
|
|
} else if (error.contains('apiKeyNoLongerSupported')) {
|
|
|
|
|
return l10n.apiKeyNoLongerSupported;
|
2025-12-11 17:36:22 +05:30
|
|
|
} else if (error.contains('LDAP authentication is not enabled')) {
|
|
|
|
|
return l10n.ldapNotEnabled;
|
2025-12-05 13:50:26 +05:30
|
|
|
} else if (error.contains('401') || error.contains('Unauthorized')) {
|
|
|
|
|
return l10n.invalidCredentials;
|
2025-08-16 15:51:27 +05:30
|
|
|
} else if (error.contains('redirect')) {
|
2025-12-05 13:50:26 +05:30
|
|
|
return l10n.serverRedirectingHttps;
|
2025-08-16 15:51:27 +05:30
|
|
|
} else if (error.contains('SocketException')) {
|
2025-12-05 13:50:26 +05:30
|
|
|
return l10n.unableToConnectServer;
|
2025-08-16 15:51:27 +05:30
|
|
|
} else if (error.contains('timeout')) {
|
2025-12-05 13:50:26 +05:30
|
|
|
return l10n.requestTimedOut;
|
2025-08-16 15:51:27 +05:30
|
|
|
}
|
2025-12-05 13:50:26 +05:30
|
|
|
return l10n.genericSignInFailed;
|
2025-08-16 15:51:27 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
// Listen for auth state changes to navigate on successful login
|
2025-09-28 23:18:24 +05:30
|
|
|
ref.listen<AsyncValue<AuthState>>(authStateManagerProvider, (
|
|
|
|
|
previous,
|
|
|
|
|
next,
|
|
|
|
|
) {
|
|
|
|
|
final nextState = next.asData?.value;
|
|
|
|
|
final prevState = previous?.asData?.value;
|
2025-08-20 22:15:26 +05:30
|
|
|
if (mounted &&
|
2025-09-28 23:18:24 +05:30
|
|
|
nextState?.isAuthenticated == true &&
|
|
|
|
|
prevState?.isAuthenticated != true) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.auth(
|
|
|
|
|
'Authentication successful, initializing background resources',
|
|
|
|
|
);
|
|
|
|
|
|
2025-08-17 16:11:19 +05:30
|
|
|
// Model selection and onboarding will be handled by the chat page
|
|
|
|
|
// to avoid widget disposal issues
|
2025-08-20 22:15:26 +05:30
|
|
|
|
|
|
|
|
DebugLogger.auth('Navigating to chat page');
|
2025-08-16 15:51:27 +05:30
|
|
|
// Navigate directly to chat page on successful authentication
|
2025-09-22 14:36:43 +05:30
|
|
|
context.go(Routes.chat);
|
2025-08-16 15:51:27 +05:30
|
|
|
}
|
|
|
|
|
});
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
return ErrorBoundary(
|
|
|
|
|
child: Scaffold(
|
|
|
|
|
backgroundColor: context.conduitTheme.surfaceBackground,
|
|
|
|
|
body: SafeArea(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: Spacing.pagePadding,
|
|
|
|
|
vertical: Spacing.lg,
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
// Header with progress indicator
|
|
|
|
|
_buildHeader(),
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-10-05 00:29:27 +05:30
|
|
|
const SizedBox(height: Spacing.xl),
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
// Main content
|
|
|
|
|
Expanded(
|
|
|
|
|
child: SingleChildScrollView(
|
2025-11-26 15:25:02 +05:30
|
|
|
keyboardDismissBehavior:
|
|
|
|
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
2025-08-16 15:51:27 +05:30
|
|
|
child: ConstrainedBox(
|
2025-10-05 00:29:27 +05:30
|
|
|
constraints: const BoxConstraints(maxWidth: 480),
|
2025-08-16 15:51:27 +05:30
|
|
|
child: Form(
|
|
|
|
|
key: _formKey,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
|
|
|
|
// Server connection status
|
|
|
|
|
_buildServerStatus(),
|
|
|
|
|
|
2025-10-05 00:29:27 +05:30
|
|
|
const SizedBox(height: Spacing.xl),
|
2025-08-16 15:51:27 +05:30
|
|
|
|
|
|
|
|
// Welcome section
|
|
|
|
|
_buildWelcomeSection(),
|
|
|
|
|
|
2025-10-05 00:29:27 +05:30
|
|
|
const SizedBox(height: Spacing.xl),
|
2025-08-16 15:51:27 +05:30
|
|
|
|
|
|
|
|
// Authentication form
|
|
|
|
|
_buildAuthForm(),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
// Bottom action button
|
|
|
|
|
_buildSignInButton(),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildHeader() {
|
|
|
|
|
return Row(
|
|
|
|
|
children: [
|
|
|
|
|
ConduitIconButton(
|
|
|
|
|
icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back,
|
2025-10-10 22:08:23 +05:30
|
|
|
onPressed: () => context.go(Routes.serverConnection),
|
2025-08-23 20:09:43 +05:30
|
|
|
tooltip: AppLocalizations.of(context)!.backToServerSetup,
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
const Spacer(),
|
|
|
|
|
// Progress indicator (step 2 of 2)
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
2025-10-05 00:29:27 +05:30
|
|
|
width: 24,
|
|
|
|
|
height: 4,
|
2025-08-16 15:51:27 +05:30
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: context.conduitTheme.buttonPrimary,
|
|
|
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: Spacing.xs),
|
|
|
|
|
Container(
|
2025-10-05 00:29:27 +05:30
|
|
|
width: 24,
|
|
|
|
|
height: 4,
|
2025-08-16 15:51:27 +05:30
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: context.conduitTheme.buttonPrimary,
|
|
|
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const Spacer(),
|
|
|
|
|
const SizedBox(width: TouchTarget.minimum), // Balance the back button
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildServerStatus() {
|
2025-09-28 20:41:35 +05:30
|
|
|
// Prefer route-provided config; otherwise fall back to active server
|
|
|
|
|
final activeServerAsync = ref.watch(activeServerProvider);
|
|
|
|
|
final cfg =
|
|
|
|
|
widget.serverConfig ??
|
|
|
|
|
activeServerAsync.maybeWhen(data: (s) => s, orElse: () => null);
|
|
|
|
|
final hostText = () {
|
|
|
|
|
try {
|
|
|
|
|
final url = cfg?.url;
|
|
|
|
|
if (url != null && url.isNotEmpty) return Uri.parse(url).host;
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
return 'Server';
|
|
|
|
|
}();
|
2025-10-05 00:29:27 +05:30
|
|
|
return Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: Spacing.md,
|
|
|
|
|
vertical: Spacing.sm,
|
|
|
|
|
),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: context.conduitTheme.success.withValues(alpha: 0.08),
|
|
|
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
|
|
|
|
border: Border.all(
|
|
|
|
|
color: context.conduitTheme.success.withValues(alpha: 0.2),
|
|
|
|
|
width: BorderWidth.standard,
|
|
|
|
|
),
|
|
|
|
|
),
|
2025-08-16 15:51:27 +05:30
|
|
|
child: Row(
|
|
|
|
|
children: [
|
2025-10-05 00:29:27 +05:30
|
|
|
Icon(
|
|
|
|
|
Platform.isIOS
|
|
|
|
|
? CupertinoIcons.checkmark_circle
|
|
|
|
|
: Icons.check_circle_outline,
|
|
|
|
|
color: context.conduitTheme.success,
|
|
|
|
|
size: IconSize.small,
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
2025-10-05 00:29:27 +05:30
|
|
|
const SizedBox(width: Spacing.sm),
|
2025-08-16 15:51:27 +05:30
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
2025-08-23 20:09:43 +05:30
|
|
|
AppLocalizations.of(context)!.connectedToServer,
|
2025-10-05 00:29:27 +05:30
|
|
|
style: context.conduitTheme.bodySmall?.copyWith(
|
|
|
|
|
fontWeight: FontWeight.w500,
|
2025-08-16 15:51:27 +05:30
|
|
|
color: context.conduitTheme.success,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Text(
|
2025-09-28 20:41:35 +05:30
|
|
|
hostText,
|
2025-08-16 15:51:27 +05:30
|
|
|
style: context.conduitTheme.bodySmall?.copyWith(
|
|
|
|
|
color: context.conduitTheme.textSecondary,
|
2025-10-21 00:09:12 +05:30
|
|
|
fontFamily: AppTypography.monospaceFontFamily,
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildWelcomeSection() {
|
|
|
|
|
return Column(
|
|
|
|
|
children: [
|
|
|
|
|
BrandService.createBrandIcon(
|
|
|
|
|
size: 48,
|
2025-10-05 00:29:27 +05:30
|
|
|
useGradient: false,
|
|
|
|
|
addShadow: false,
|
2025-10-02 11:39:17 +05:30
|
|
|
context: context,
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
const SizedBox(height: Spacing.lg),
|
|
|
|
|
Text(
|
2025-08-23 20:09:43 +05:30
|
|
|
AppLocalizations.of(context)!.signIn,
|
2025-08-16 15:51:27 +05:30
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
style: context.conduitTheme.headingLarge?.copyWith(
|
2025-10-05 00:29:27 +05:30
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
height: 1.3,
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: Spacing.sm),
|
|
|
|
|
Text(
|
2025-08-23 20:09:43 +05:30
|
|
|
AppLocalizations.of(context)!.enterCredentials,
|
2025-08-16 15:51:27 +05:30
|
|
|
textAlign: TextAlign.center,
|
2025-10-05 00:29:27 +05:30
|
|
|
style: context.conduitTheme.bodyMedium?.copyWith(
|
2025-08-16 15:51:27 +05:30
|
|
|
color: context.conduitTheme.textSecondary,
|
2025-10-05 00:29:27 +05:30
|
|
|
height: 1.4,
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildAuthForm() {
|
2025-12-11 17:36:22 +05:30
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
|
|
2025-10-05 00:29:27 +05:30
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
2025-12-11 18:45:18 +05:30
|
|
|
// 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),
|
|
|
|
|
],
|
|
|
|
|
],
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
// 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(),
|
|
|
|
|
],
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-10-05 00:29:27 +05:30
|
|
|
if (_loginError != null) ...[
|
|
|
|
|
const SizedBox(height: Spacing.md),
|
|
|
|
|
_buildErrorMessage(_loginError!),
|
2025-08-16 15:51:27 +05:30
|
|
|
],
|
2025-12-11 17:36:22 +05:30
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
// More options section - always show for additional auth methods
|
2025-12-11 17:36:22 +05:30
|
|
|
const SizedBox(height: Spacing.lg),
|
|
|
|
|
_buildMoreOptionsSection(l10n),
|
2025-10-05 00:29:27 +05:30
|
|
|
],
|
2025-08-16 15:51:27 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
Widget _buildDividerWithText(String text) {
|
|
|
|
|
return Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Divider(
|
|
|
|
|
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
2025-12-11 18:45:18 +05:30
|
|
|
),
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
|
|
|
|
|
child: Text(
|
|
|
|
|
text,
|
|
|
|
|
style: context.conduitTheme.bodySmall?.copyWith(
|
|
|
|
|
color: context.conduitTheme.textSecondary,
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
),
|
2025-12-11 18:45:18 +05:30
|
|
|
),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Divider(
|
|
|
|
|
color: context.conduitTheme.dividerColor.withValues(alpha: 0.5),
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
),
|
2025-12-11 18:45:18 +05:30
|
|
|
],
|
2025-08-16 15:51:27 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
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),
|
|
|
|
|
],
|
|
|
|
|
],
|
2025-08-16 15:51:27 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
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;
|
2025-12-11 17:36:22 +05:30
|
|
|
}
|
2025-12-11 18:45:18 +05:30
|
|
|
|
|
|
|
|
return ConduitButton(
|
|
|
|
|
text: l10n.continueWithProvider(displayName),
|
|
|
|
|
icon: icon,
|
|
|
|
|
onPressed: _navigateToSso,
|
|
|
|
|
isSecondary: true,
|
|
|
|
|
isFullWidth: true,
|
|
|
|
|
);
|
2025-12-11 17:36:22 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-05 11:24:03 +05:30
|
|
|
/// 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) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return AppLocalizations.of(context)!.validationMissingRequired;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final trimmed = value.trim();
|
|
|
|
|
final lowerTrimmed = trimmed.toLowerCase();
|
|
|
|
|
|
|
|
|
|
// Reject API keys - they don't work with socket authentication
|
|
|
|
|
// Case-insensitive check to catch SK-, API-, KEY- variants
|
|
|
|
|
if (lowerTrimmed.startsWith('sk-') ||
|
|
|
|
|
lowerTrimmed.startsWith('api-') ||
|
|
|
|
|
lowerTrimmed.startsWith('key-')) {
|
|
|
|
|
return AppLocalizations.of(context)!.apiKeyNotSupported;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check minimum length
|
|
|
|
|
if (trimmed.length < 10) {
|
|
|
|
|
return AppLocalizations.of(context)!.tokenTooShort;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
Widget _buildApiKeyForm() {
|
|
|
|
|
return Column(
|
|
|
|
|
key: const ValueKey('api_key_form'),
|
|
|
|
|
children: [
|
|
|
|
|
AccessibleFormField(
|
2025-12-05 11:24:03 +05:30
|
|
|
label: AppLocalizations.of(context)!.token,
|
|
|
|
|
hint: 'eyJ...',
|
2025-08-16 15:51:27 +05:30
|
|
|
controller: _apiKeyController,
|
2025-12-05 11:24:03 +05:30
|
|
|
validator: _validateJwtToken,
|
2025-08-16 15:51:27 +05:30
|
|
|
obscureText: _obscurePassword,
|
2025-12-05 11:24:03 +05:30
|
|
|
semanticLabel: AppLocalizations.of(context)!.enterToken,
|
2025-08-16 15:51:27 +05:30
|
|
|
prefixIcon: Icon(
|
2025-08-20 22:15:26 +05:30
|
|
|
Platform.isIOS
|
|
|
|
|
? CupertinoIcons.lock_shield
|
|
|
|
|
: Icons.vpn_key_outlined,
|
2025-08-16 15:51:27 +05:30
|
|
|
color: context.conduitTheme.iconSecondary,
|
|
|
|
|
),
|
|
|
|
|
suffixIcon: IconButton(
|
|
|
|
|
icon: Icon(
|
|
|
|
|
_obscurePassword
|
2025-08-20 22:15:26 +05:30
|
|
|
? (Platform.isIOS
|
|
|
|
|
? CupertinoIcons.eye_slash
|
|
|
|
|
: Icons.visibility_off)
|
2025-08-16 15:51:27 +05:30
|
|
|
: (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility),
|
|
|
|
|
color: context.conduitTheme.iconSecondary,
|
|
|
|
|
),
|
2025-08-20 22:15:26 +05:30
|
|
|
onPressed: () =>
|
|
|
|
|
setState(() => _obscurePassword = !_obscurePassword),
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
onSubmitted: (_) => _signIn(),
|
|
|
|
|
isRequired: true,
|
|
|
|
|
autofillHints: const [AutofillHints.password],
|
|
|
|
|
),
|
2025-12-05 11:24:03 +05:30
|
|
|
const SizedBox(height: Spacing.sm),
|
|
|
|
|
Text(
|
|
|
|
|
AppLocalizations.of(context)!.tokenHint,
|
|
|
|
|
style: context.conduitTheme.bodySmall?.copyWith(
|
|
|
|
|
color: context.conduitTheme.textSecondary,
|
|
|
|
|
),
|
|
|
|
|
),
|
2025-08-16 15:51:27 +05:30
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildCredentialsForm() {
|
|
|
|
|
return Column(
|
|
|
|
|
key: const ValueKey('credentials_form'),
|
|
|
|
|
children: [
|
|
|
|
|
AccessibleFormField(
|
2025-08-23 20:09:43 +05:30
|
|
|
label: AppLocalizations.of(context)!.usernameOrEmail,
|
2025-08-27 20:26:09 +05:30
|
|
|
hint: AppLocalizations.of(context)!.usernameOrEmailHint,
|
2025-08-16 15:51:27 +05:30
|
|
|
controller: _usernameController,
|
|
|
|
|
validator: InputValidationService.combine([
|
|
|
|
|
InputValidationService.validateRequired,
|
|
|
|
|
(value) => InputValidationService.validateEmailOrUsername(value),
|
|
|
|
|
]),
|
|
|
|
|
keyboardType: TextInputType.emailAddress,
|
2025-08-27 20:26:09 +05:30
|
|
|
semanticLabel: AppLocalizations.of(context)!.usernameOrEmailHint,
|
2025-08-16 15:51:27 +05:30
|
|
|
prefixIcon: Icon(
|
|
|
|
|
Platform.isIOS ? CupertinoIcons.person : Icons.person_outline,
|
|
|
|
|
color: context.conduitTheme.iconSecondary,
|
|
|
|
|
),
|
2025-08-20 22:15:26 +05:30
|
|
|
autofillHints: const [AutofillHints.username, AutofillHints.email],
|
2025-08-16 15:51:27 +05:30
|
|
|
isRequired: true,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: Spacing.lg),
|
|
|
|
|
AccessibleFormField(
|
2025-08-23 20:09:43 +05:30
|
|
|
label: AppLocalizations.of(context)!.password,
|
2025-08-27 20:26:09 +05:30
|
|
|
hint: AppLocalizations.of(context)!.passwordHint,
|
2025-08-16 15:51:27 +05:30
|
|
|
controller: _passwordController,
|
|
|
|
|
validator: InputValidationService.combine([
|
|
|
|
|
InputValidationService.validateRequired,
|
|
|
|
|
(value) => InputValidationService.validateMinLength(
|
|
|
|
|
value,
|
|
|
|
|
1,
|
2025-08-27 20:26:09 +05:30
|
|
|
fieldName: AppLocalizations.of(context)!.password,
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
]),
|
|
|
|
|
obscureText: _obscurePassword,
|
2025-08-27 20:26:09 +05:30
|
|
|
semanticLabel: AppLocalizations.of(context)!.passwordHint,
|
2025-08-16 15:51:27 +05:30
|
|
|
prefixIcon: Icon(
|
|
|
|
|
Platform.isIOS ? CupertinoIcons.lock : Icons.lock_outline,
|
|
|
|
|
color: context.conduitTheme.iconSecondary,
|
|
|
|
|
),
|
|
|
|
|
suffixIcon: IconButton(
|
|
|
|
|
icon: Icon(
|
|
|
|
|
_obscurePassword
|
2025-08-20 22:15:26 +05:30
|
|
|
? (Platform.isIOS
|
|
|
|
|
? CupertinoIcons.eye_slash
|
|
|
|
|
: Icons.visibility_off)
|
2025-08-16 15:51:27 +05:30
|
|
|
: (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility),
|
|
|
|
|
color: context.conduitTheme.iconSecondary,
|
|
|
|
|
),
|
2025-08-20 22:15:26 +05:30
|
|
|
onPressed: () =>
|
|
|
|
|
setState(() => _obscurePassword = !_obscurePassword),
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
onSubmitted: (_) => _signIn(),
|
|
|
|
|
autofillHints: const [AutofillHints.password],
|
|
|
|
|
isRequired: true,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 17:36:22 +05:30
|
|
|
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) {
|
2025-12-11 18:45:18 +05:30
|
|
|
// Build list of available options - always show all available options
|
|
|
|
|
// with the current one highlighted for consistency
|
|
|
|
|
final options = <Widget>[];
|
|
|
|
|
|
|
|
|
|
// Credentials option (if login form is enabled)
|
|
|
|
|
if (_hasLoginFormEnabled) {
|
|
|
|
|
options.add(
|
|
|
|
|
_buildOptionButton(
|
|
|
|
|
icon: Platform.isIOS
|
|
|
|
|
? CupertinoIcons.person_circle
|
|
|
|
|
: Icons.account_circle_outlined,
|
|
|
|
|
label: l10n.credentials,
|
|
|
|
|
isSelected: _authMode == AuthMode.credentials,
|
|
|
|
|
onTap: () => setState(() {
|
|
|
|
|
_authMode = AuthMode.credentials;
|
|
|
|
|
_loginError = null;
|
|
|
|
|
_obscurePassword = true;
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SSO option (only if WebView supported and no OAuth buttons shown above)
|
|
|
|
|
if (isWebViewSupported && !_hasSsoEnabled) {
|
|
|
|
|
options.add(
|
|
|
|
|
_buildOptionButton(
|
|
|
|
|
icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.security,
|
|
|
|
|
label: l10n.sso,
|
|
|
|
|
isSelected: _authMode == AuthMode.sso,
|
|
|
|
|
onTap: () => setState(() {
|
|
|
|
|
_authMode = AuthMode.sso;
|
|
|
|
|
_loginError = null;
|
|
|
|
|
_obscurePassword = true;
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LDAP option (if enabled on server)
|
|
|
|
|
if (_hasLdapEnabled) {
|
|
|
|
|
options.add(
|
|
|
|
|
_buildOptionButton(
|
|
|
|
|
icon: Platform.isIOS ? CupertinoIcons.building_2_fill : Icons.domain,
|
|
|
|
|
label: l10n.ldap,
|
|
|
|
|
isSelected: _authMode == AuthMode.ldap,
|
|
|
|
|
onTap: () => setState(() {
|
|
|
|
|
_authMode = AuthMode.ldap;
|
|
|
|
|
_loginError = null;
|
|
|
|
|
_obscurePassword = true;
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Token option (always available as fallback)
|
|
|
|
|
options.add(
|
|
|
|
|
_buildOptionButton(
|
|
|
|
|
icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key,
|
|
|
|
|
label: l10n.token,
|
|
|
|
|
isSelected: _authMode == AuthMode.token,
|
|
|
|
|
onTap: () => setState(() {
|
|
|
|
|
_authMode = AuthMode.token;
|
|
|
|
|
_loginError = null;
|
|
|
|
|
_obscurePassword = true;
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (options.isEmpty) return const SizedBox.shrink();
|
|
|
|
|
|
2025-12-11 17:36:22 +05:30
|
|
|
return Column(
|
|
|
|
|
children: [
|
2025-12-11 18:45:18 +05:30
|
|
|
// Expandable header
|
|
|
|
|
InkWell(
|
|
|
|
|
onTap: () => setState(() => _showMoreOptions = !_showMoreOptions),
|
|
|
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: Spacing.md,
|
|
|
|
|
vertical: Spacing.sm,
|
2025-12-11 17:36:22 +05:30
|
|
|
),
|
2025-12-11 18:45:18 +05:30
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
l10n.moreSignInOptions,
|
|
|
|
|
style: context.conduitTheme.bodySmall?.copyWith(
|
|
|
|
|
color: context.conduitTheme.textSecondary,
|
|
|
|
|
),
|
2025-12-11 17:36:22 +05:30
|
|
|
),
|
2025-12-11 18:45:18 +05:30
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
2025-12-11 17:36:22 +05:30
|
|
|
),
|
2025-12-11 18:45:18 +05:30
|
|
|
),
|
2025-12-11 17:36:22 +05:30
|
|
|
),
|
|
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
// 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]),
|
|
|
|
|
],
|
|
|
|
|
],
|
2025-12-11 17:36:22 +05:30
|
|
|
),
|
2025-12-11 18:45:18 +05:30
|
|
|
),
|
2025-12-11 17:36:22 +05:30
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
Widget _buildSignInButton() {
|
2025-12-11 17:36:22 +05:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: Spacing.lg),
|
2025-10-05 00:29:27 +05:30
|
|
|
child: ConduitButton(
|
2025-12-11 17:36:22 +05:30
|
|
|
text: buttonText,
|
2025-10-05 00:29:27 +05:30
|
|
|
icon: _isSigningIn
|
|
|
|
|
? null
|
|
|
|
|
: (Platform.isIOS
|
|
|
|
|
? CupertinoIcons.arrow_right
|
|
|
|
|
: Icons.arrow_forward),
|
|
|
|
|
onPressed: _isSigningIn ? null : _signIn,
|
|
|
|
|
isLoading: _isSigningIn,
|
|
|
|
|
isFullWidth: true,
|
|
|
|
|
),
|
2025-08-16 15:51:27 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildErrorMessage(String message) {
|
2025-09-23 11:00:25 +05:30
|
|
|
return Semantics(
|
|
|
|
|
liveRegion: true,
|
|
|
|
|
label: message,
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.all(Spacing.md),
|
|
|
|
|
decoration: BoxDecoration(
|
2025-10-05 00:29:27 +05:30
|
|
|
color: context.conduitTheme.error.withValues(alpha: 0.08),
|
|
|
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
2025-09-23 11:00:25 +05:30
|
|
|
border: Border.all(
|
2025-10-05 00:29:27 +05:30
|
|
|
color: context.conduitTheme.error.withValues(alpha: 0.2),
|
2025-09-23 11:00:25 +05:30
|
|
|
width: BorderWidth.standard,
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
2025-09-23 11:00:25 +05:30
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(
|
|
|
|
|
Platform.isIOS
|
2025-10-05 00:29:27 +05:30
|
|
|
? CupertinoIcons.exclamationmark_circle
|
2025-09-23 11:00:25 +05:30
|
|
|
: Icons.error_outline,
|
|
|
|
|
color: context.conduitTheme.error,
|
2025-10-05 00:29:27 +05:30
|
|
|
size: IconSize.small,
|
2025-09-23 11:00:25 +05:30
|
|
|
),
|
2025-10-05 00:29:27 +05:30
|
|
|
const SizedBox(width: Spacing.sm),
|
2025-09-23 11:00:25 +05:30
|
|
|
Expanded(
|
|
|
|
|
child: Text(
|
|
|
|
|
message,
|
2025-10-05 00:29:27 +05:30
|
|
|
style: context.conduitTheme.bodySmall?.copyWith(
|
2025-09-23 11:00:25 +05:30
|
|
|
color: context.conduitTheme.error,
|
|
|
|
|
),
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
),
|
2025-09-23 11:00:25 +05:30
|
|
|
],
|
|
|
|
|
),
|
2025-08-16 15:51:27 +05:30
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
}
|