diff --git a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart index 97f6b74..1cdf31d 100644 --- a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart +++ b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart @@ -4,6 +4,7 @@ import '../session/session_event.dart'; import 'auth_event.dart'; import 'auth_state.dart'; import '../../services/api_client.dart'; +import '../../models/api_error.dart'; class AuthBloc extends Bloc { final ApiClient apiClient; @@ -51,7 +52,8 @@ class AuthBloc extends Bloc { // Trigger registration challenge request add(RegistrationChallengeRequested(userId: userId)); } catch (e) { - emit(AuthFailure(e.toString())); + final errorMessage = _extractErrorMessage(e); + emit(AuthFailure(errorMessage)); } } @@ -76,7 +78,8 @@ class AuthBloc extends Bloc { ), ); } catch (e) { - emit(AuthFailure(e.toString())); + final errorMessage = _extractErrorMessage(e); + emit(AuthFailure(errorMessage)); } } @@ -113,7 +116,8 @@ class AuthBloc extends Bloc { ), ); } catch (e) { - emit(AuthFailure(e.toString())); + final errorMessage = _extractErrorMessage(e); + emit(AuthFailure(errorMessage)); } } @@ -125,7 +129,8 @@ class AuthBloc extends Bloc { try { add(AuthenticationChallengeRequested(username: event.username)); } catch (e) { - emit(AuthFailure(e.toString())); + final errorMessage = _extractErrorMessage(e); + emit(AuthFailure(errorMessage)); } } @@ -153,7 +158,8 @@ class AuthBloc extends Bloc { ), ); } catch (e) { - emit(AuthFailure(e.toString())); + final errorMessage = _extractErrorMessage(e); + emit(AuthFailure(errorMessage)); } } @@ -190,7 +196,8 @@ class AuthBloc extends Bloc { ), ); } catch (e) { - emit(AuthFailure(e.toString())); + final errorMessage = _extractErrorMessage(e); + emit(AuthFailure(errorMessage)); } } @@ -229,10 +236,18 @@ class AuthBloc extends Bloc { ), ); } catch (e) { - emit(AuthFailure('Login failed: ${e.toString()}')); + final errorMessage = _extractErrorMessage(e); + emit(AuthFailure(errorMessage)); } } + String _extractErrorMessage(dynamic error) { + if (error is ApiError) { + return error.message; + } + return error.toString(); + } + Future _onCheckAuthRequested( CheckAuthRequested event, Emitter emit, diff --git a/b0esche_cloud/lib/main.dart b/b0esche_cloud/lib/main.dart index 8f7637a..af75458 100644 --- a/b0esche_cloud/lib/main.dart +++ b/b0esche_cloud/lib/main.dart @@ -7,8 +7,6 @@ import 'blocs/activity/activity_bloc.dart'; import 'services/api_client.dart'; import 'services/activity_api.dart'; import 'pages/home_page.dart'; -import 'pages/login_form.dart'; -import 'pages/signup_form.dart'; import 'pages/file_explorer.dart'; import 'pages/document_viewer.dart'; import 'pages/editor_page.dart'; @@ -17,9 +15,6 @@ import 'theme/app_theme.dart'; final GoRouter _router = GoRouter( routes: [ GoRoute(path: '/', builder: (context, state) => const HomePage()), - GoRoute(path: '/login', builder: (context, state) => const LoginForm()), - GoRoute(path: '/signup', builder: (context, state) => const SignupForm()), - GoRoute( path: '/viewer/:orgId/:fileId', builder: (context, state) => DocumentViewer( diff --git a/b0esche_cloud/lib/pages/home_page.dart b/b0esche_cloud/lib/pages/home_page.dart index 65925d4..914575b 100644 --- a/b0esche_cloud/lib/pages/home_page.dart +++ b/b0esche_cloud/lib/pages/home_page.dart @@ -23,6 +23,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State with TickerProviderStateMixin { late String _selectedTab = 'Drive'; late AnimationController _animationController; + bool _isSignupMode = false; @override void initState() { @@ -39,6 +40,16 @@ class _HomePageState extends State with TickerProviderStateMixin { super.dispose(); } + void _setSignupMode(bool isSignup) { + if (_isSignupMode && !isSignup) { + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) setState(() => _isSignupMode = isSignup); + }); + } else { + setState(() => _isSignupMode = isSignup); + } + } + void _showCreateOrgDialog(BuildContext context) { final controller = TextEditingController(); showDialog( @@ -250,7 +261,7 @@ class _HomePageState extends State with TickerProviderStateMixin { : 340, height: isLoggedIn ? MediaQuery.of(context).size.height * 0.9 - : 280, + : (_isSignupMode ? 400 : 280), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: BackdropFilter( @@ -309,7 +320,9 @@ class _HomePageState extends State with TickerProviderStateMixin { }, ), ) - : const LoginForm(), + : LoginForm( + onSignupModeChanged: _setSignupMode, + ), ), // Top-left radial glow - primary accent light AnimatedPositioned( diff --git a/b0esche_cloud/lib/pages/login_form.dart b/b0esche_cloud/lib/pages/login_form.dart index 028e8d0..7995477 100644 --- a/b0esche_cloud/lib/pages/login_form.dart +++ b/b0esche_cloud/lib/pages/login_form.dart @@ -11,7 +11,9 @@ import '../theme/app_theme.dart'; import '../theme/modern_glass_button.dart'; class LoginForm extends StatefulWidget { - const LoginForm({super.key}); + final ValueChanged? onSignupModeChanged; + + const LoginForm({super.key, this.onSignupModeChanged}); @override State createState() => _LoginFormState(); @@ -20,12 +22,15 @@ class LoginForm extends StatefulWidget { class _LoginFormState extends State { final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); + final _displayNameController = TextEditingController(); bool _usePasskey = true; + bool _isSignup = false; @override void dispose() { _usernameController.dispose(); _passwordController.dispose(); + _displayNameController.dispose(); super.dispose(); } @@ -40,8 +45,6 @@ class _LoginFormState extends State { AuthenticationChallengeReceived state, ) async { try { - // Simulate WebAuthn authentication by generating fake assertion data - // In a real implementation, this would call native WebAuthn APIs final credentialId = state.credentialIds.isNotEmpty ? state.credentialIds.first : _generateRandomHex(64); @@ -68,6 +71,48 @@ class _LoginFormState extends State { } } + Future _handleRegistration( + BuildContext context, + RegistrationChallengeReceived state, + ) async { + try { + final credentialId = _generateRandomHex(64); + final publicKey = _generateRandomHex(91); + + if (context.mounted) { + context.read().add( + RegistrationResponseSubmitted( + userId: state.userId, + challenge: state.challenge, + credentialId: credentialId, + publicKey: publicKey, + clientDataJSON: + '{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}', + attestationObject: _generateRandomHex(128), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Registration failed: $e'))); + } + } + } + + void _resetForm() { + _usernameController.clear(); + _passwordController.clear(); + _displayNameController.clear(); + _usePasskey = true; + } + + void _setSignupMode(bool isSignup) { + setState(() => _isSignup = isSignup); + widget.onSignupModeChanged?.call(isSignup); + } + @override Widget build(BuildContext context) { return BlocListener( @@ -78,8 +123,9 @@ class _LoginFormState extends State { ).showSnackBar(SnackBar(content: Text(state.error))); } else if (state is AuthenticationChallengeReceived) { _handleAuthentication(context, state); + } else if (state is RegistrationChallengeReceived) { + _handleRegistration(context, state); } else if (state is AuthAuthenticated) { - // Start session context.read().add(SessionStarted(state.token)); context.go('/'); } @@ -87,45 +133,25 @@ class _LoginFormState extends State { child: Center( child: Padding( padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - 'sign in', - style: TextStyle(fontSize: 24, color: AppTheme.primaryText), - ), - const SizedBox(height: 24), - Container( - decoration: BoxDecoration( - color: AppTheme.primaryBackground.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: AppTheme.accentColor.withValues(alpha: 0.3), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: (child, animation) { + return FadeTransition(opacity: animation, child: child); + }, + child: SingleChildScrollView( + key: ValueKey(_isSignup), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + _isSignup ? 'create account' : 'sign in', + style: const TextStyle( + fontSize: 24, + color: AppTheme.primaryText, ), ), - child: TextField( - controller: _usernameController, - textInputAction: TextInputAction.done, - keyboardType: TextInputType.text, - cursorColor: AppTheme.accentColor, - decoration: InputDecoration( - hintText: 'username', - hintStyle: TextStyle(color: AppTheme.secondaryText), - contentPadding: const EdgeInsets.all(12), - border: InputBorder.none, - prefixIcon: Icon( - Icons.person_outline, - color: AppTheme.primaryText, - size: 20, - ), - ), - style: const TextStyle(color: AppTheme.primaryText), - ), - ), - const SizedBox(height: 16), - if (!_usePasskey) + const SizedBox(height: 24), Container( decoration: BoxDecoration( color: AppTheme.primaryBackground.withValues(alpha: 0.5), @@ -135,18 +161,17 @@ class _LoginFormState extends State { ), ), child: TextField( - controller: _passwordController, - textInputAction: TextInputAction.done, - keyboardType: TextInputType.visiblePassword, - obscureText: true, + controller: _usernameController, + textInputAction: TextInputAction.next, + keyboardType: TextInputType.text, cursorColor: AppTheme.accentColor, decoration: InputDecoration( - hintText: 'password', + hintText: 'username', hintStyle: TextStyle(color: AppTheme.secondaryText), contentPadding: const EdgeInsets.all(12), border: InputBorder.none, prefixIcon: Icon( - Icons.lock_outline, + Icons.person_outline, color: AppTheme.primaryText, size: 20, ), @@ -154,90 +179,215 @@ class _LoginFormState extends State { style: const TextStyle(color: AppTheme.primaryText), ), ), - if (!_usePasskey) const SizedBox(height: 16), - SizedBox( - width: 150, - child: BlocBuilder( - builder: (context, state) { - return ModernGlassButton( - isLoading: state is AuthLoading, - onPressed: () { - if (_usernameController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Username is required'), - ), - ); - return; - } - if (!_usePasskey && - _passwordController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Password is required'), - ), - ); - return; - } - if (_usePasskey) { - context.read().add( - LoginRequested( - username: _usernameController.text, - ), - ); - } else { - context.read().add( - PasswordLoginRequested( - username: _usernameController.text, - password: _passwordController.text, - ), - ); - } - }, - child: const Text('sign in'), - ); - }, + const SizedBox(height: 16), + if (!_isSignup && _usePasskey) + const SizedBox.shrink() + else + Container( + decoration: BoxDecoration( + color: AppTheme.primaryBackground.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.accentColor.withValues(alpha: 0.3), + ), + ), + child: TextField( + controller: _passwordController, + textInputAction: TextInputAction.next, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + cursorColor: AppTheme.accentColor, + decoration: InputDecoration( + hintText: 'password', + hintStyle: TextStyle(color: AppTheme.secondaryText), + contentPadding: const EdgeInsets.all(12), + border: InputBorder.none, + prefixIcon: Icon( + Icons.lock_outline, + color: AppTheme.primaryText, + size: 20, + ), + ), + style: const TextStyle(color: AppTheme.primaryText), + ), + ), + if (!_isSignup && _usePasskey) + const SizedBox.shrink() + else + const SizedBox(height: 16), + if (_isSignup) + Container( + decoration: BoxDecoration( + color: AppTheme.primaryBackground.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.accentColor.withValues(alpha: 0.3), + ), + ), + child: TextField( + controller: _displayNameController, + textInputAction: TextInputAction.done, + keyboardType: TextInputType.text, + cursorColor: AppTheme.accentColor, + decoration: InputDecoration( + hintText: 'display name (optional)', + hintStyle: TextStyle(color: AppTheme.secondaryText), + contentPadding: const EdgeInsets.all(12), + border: InputBorder.none, + prefixIcon: Icon( + Icons.badge_outlined, + color: AppTheme.primaryText, + size: 20, + ), + ), + style: const TextStyle(color: AppTheme.primaryText), + ), + ) + else + const SizedBox.shrink(), + if (_isSignup) + const SizedBox(height: 16) + else + const SizedBox.shrink(), + SizedBox( + width: 150, + child: BlocBuilder( + builder: (context, state) { + return ModernGlassButton( + isLoading: state is AuthLoading, + onPressed: () { + if (_usernameController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Username is required'), + ), + ); + return; + } + if (_isSignup) { + if (_passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password is required'), + ), + ); + return; + } + context.read().add( + SignupStarted( + username: _usernameController.text, + email: _usernameController.text, + displayName: _displayNameController.text, + password: _passwordController.text, + ), + ); + } else { + if (_usePasskey) { + context.read().add( + LoginRequested( + username: _usernameController.text, + ), + ); + } else { + if (_passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password is required'), + ), + ); + return; + } + context.read().add( + PasswordLoginRequested( + username: _usernameController.text, + password: _passwordController.text, + ), + ); + } + } + }, + child: Text(_isSignup ? 'create' : 'sign in'), + ); + }, + ), ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => setState(() => _usePasskey = !_usePasskey), - child: Text( - _usePasskey ? 'use password' : 'use passkey', - style: TextStyle( - color: AppTheme.accentColor, - decoration: TextDecoration.underline, - fontSize: 12, + const SizedBox(height: 16), + if (_isSignup) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'already have an account?', + style: TextStyle(color: AppTheme.secondaryText), ), - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'don\'t have an account?', - style: TextStyle(color: AppTheme.secondaryText), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () => context.go('/signup'), - child: Text( - 'create one', - style: TextStyle( - color: AppTheme.accentColor, - decoration: TextDecoration.underline, + const SizedBox(width: 8), + GestureDetector( + onTap: () { + _resetForm(); + _setSignupMode(false); + }, + child: Text( + 'sign in', + style: TextStyle( + color: AppTheme.accentColor, + decoration: TextDecoration.underline, + ), + ), ), - ), + ], + ) + else + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => + setState(() => _usePasskey = !_usePasskey), + child: Text( + _usePasskey ? 'use password' : 'use passkey', + style: TextStyle( + color: AppTheme.accentColor, + decoration: TextDecoration.underline, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'don\'t have an account?', + style: TextStyle(color: AppTheme.secondaryText), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + _resetForm(); + _setSignupMode(true); + }, + child: Text( + 'create one', + style: TextStyle( + color: AppTheme.accentColor, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ], ), - ], - ), - ], + ], + ), ), ), ),