import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../../../core/services/audit_service.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; final TextEditingController _emailController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); final TextEditingController _phoneController = TextEditingController(); final TextEditingController _smsCodeController = TextEditingController(); bool _smsSent = false; String? _redirectUrl; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); // Check for 't' token in URL (Magic Link / Enchanted Link verification) WidgetsBinding.instance.addPostFrameCallback((_) { final uri = Uri.base; if (uri.queryParameters.containsKey('t')) { _verifyToken(uri.queryParameters['t']!); } if (uri.queryParameters.containsKey('redirect_url')) { _redirectUrl = uri.queryParameters['redirect_url']; } }); } Future _verifyToken(String token) async { try { // Use Backend to verify the token (Backend-Driven Flow) // The backend will validate the local token, and then trigger Descope JWT generation. // This approves the pending session for the Polling device. await AuthProxyService.verifyMagicLink(token); // Note: If this device (Mobile) also needs to login, we would need to // parse the response from verifyMagicLink which contains the JWT. // For now, we assume this action primarily approves the PC session. if (mounted) { _showSuccessDialog(); } } catch (e) { if (mounted) { _showError("Verification failed: $e"); } } } void _showSuccessDialog() { showDialog( context: context, barrierDismissible: false, builder: (context) => const AlertDialog( title: Text("Authentication Successful"), content: Text("You can close this tab and return to the application."), ), ); } @override void dispose() { _tabController.dispose(); _emailController.dispose(); _passwordController.dispose(); _phoneController.dispose(); _smsCodeController.dispose(); super.dispose(); } Future _handleEmailLogin() async { final email = _emailController.text.trim(); if (email.isEmpty) return; final password = _passwordController.text; if (password.isNotEmpty) { try { final authResponse = await Descope.password.signIn( loginId: email, password: password, ); final session = DescopeSession.fromAuthenticationResponse(authResponse); Descope.sessionManager.manageSession(session); if (mounted) _onLoginSuccess(session.sessionToken.jwt); } catch (e) { _showError("Email/Password Login Failed: $e"); } } else { // Email Enchanted Link (Descope Standard) _initiateDescopeLinkFlow(email, isSms: false); } } Future _handleSmsLogin() async { final rawPhone = _phoneController.text.trim(); if (rawPhone.isEmpty) return; // Sanitize phone number String phone = rawPhone.replaceAll(RegExp(r'[-\s]'), ''); // Ensure 010 format if needed, but backend handles it too try { if (mounted) { showDialog( context: context, barrierDismissible: false, builder: (context) => const Center(child: CircularProgressIndicator()), ); } // 1. Init via Backend API (Not Descope SDK) final initResponse = await AuthProxyService.initEnchantedLink(phone); final pendingRef = initResponse['pendingRef']; if (mounted) { Navigator.of(context).pop(); // Close Loading // Show Waiting Dialog showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text("SMS Sent"), content: Column( mainAxisSize: MainAxisSize.min, children: [ const Text("Please check the link sent to your phone."), const SizedBox(height: 16), const LinearProgressIndicator(), const SizedBox(height: 16), TextButton( onPressed: () { Navigator.of(context).pop(); // Allow canceling }, child: const Text("Cancel") ) ], ), ), ); // 2. Poll Backend manually _pollForSession(pendingRef); } } catch (e) { if (mounted && Navigator.canPop(context)) Navigator.of(context).pop(); _showError("Failed to send SMS: $e"); } } Future _pollForSession(String pendingRef) async { int attempts = 0; const maxAttempts = 60; // 2 minutes while (attempts < maxAttempts && mounted) { await Future.delayed(const Duration(seconds: 2)); attempts++; try { final result = await AuthProxyService.pollEnchantedLink(pendingRef); if (result['status'] == 'ok') { final jwt = result['sessionJwt']; if (jwt != null) { // Note: Manually constructing DescopeSession can be complex due to abstract classes. // In a real production app, you should use the SDK's built-in exchange/verify methods. // For this prototype, we will proceed with the login success callback. // If session management is required immediately, we'd need to match the specific // SDK version's Token implementation. if (mounted) { Navigator.of(context).pop(); // Close Polling Dialog _onLoginSuccess(jwt); } return; } } } catch (e) { print("Polling error: $e"); // Continue polling even on temporary network error? // Or break? Let's continue. } } if (mounted) { Navigator.of(context).pop(); // Close Polling Dialog _showError("Login timed out."); } } Future _initiateDescopeLinkFlow(String loginId, {required bool isSms}) async { try { if (mounted) { showDialog( context: context, barrierDismissible: false, builder: (context) => const Center(child: CircularProgressIndicator()), ); } // 1. Init via Descope SDK final signUpOrInResponse = await Descope.enchantedLink.signUpOrIn( loginId: loginId, redirectUrl: "http://ssologin.hmac.kr/auth/callback", ); if (mounted) { Navigator.of(context).pop(); // Close Loading showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: Text(isSms ? "SMS Sent" : "Email Sent"), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text("We sent a login link to ${isSms ? loginId.split('@')[0] : loginId}"), const SizedBox(height: 16), // For SMS, we might not get a Link ID in the message if the template doesn't include it. // But Enchanted Link always has one. Text( "Security Number: ${signUpOrInResponse.linkId}", style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.blue), ), const SizedBox(height: 8), const Text("Click the matching number."), const SizedBox(height: 16), const LinearProgressIndicator(), ], ), ), ); // 2. Poll via Descope SDK final authResponse = await Descope.enchantedLink.pollForSession(pendingRef: signUpOrInResponse.pendingRef); final session = DescopeSession.fromAuthenticationResponse(authResponse); Descope.sessionManager.manageSession(session); if (mounted) { Navigator.of(context).pop(); // Close Dialog _onLoginSuccess(session.sessionToken.jwt); } } } catch (e) { if (mounted && Navigator.canPop(context)) Navigator.of(context).pop(); _showError("Login Failed: $e"); } } void _onLoginSuccess(String token) { if (!mounted) return; if (WebAuthIntegration.isPopup()) { WebAuthIntegration.sendLoginSuccess(token); _showError("Login Successful! You can close this window."); } else if (_redirectUrl != null) { final target = "$_redirectUrl?token=$token"; launchUrlString(target, webOnlyWindowName: '_self'); } else { _showError("Login Successful (Token Received)"); } } void _showError(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), backgroundColor: Colors.red), ); try { AuthProxyService.logError(message); } catch (e) { // ignore } } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Container( constraints: const BoxConstraints(maxWidth: 400), padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( "Baron SSO", style: GoogleFonts.outfit( fontSize: 32, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), const SizedBox(height: 40), TabBar( controller: _tabController, tabs: const [ Tab(text: "Email"), Tab(text: "Phone (SMS)"), ], ), const SizedBox(height: 24), SizedBox( height: 300, child: TabBarView( controller: _tabController, children: [ // Email Form Column( children: [ TextField( controller: _emailController, decoration: const InputDecoration( labelText: "Email", border: OutlineInputBorder(), prefixIcon: Icon(Icons.email_outlined), ), ), const SizedBox(height: 16), TextField( controller: _passwordController, obscureText: true, decoration: const InputDecoration( labelText: "Password (Optional)", border: OutlineInputBorder(), prefixIcon: Icon(Icons.lock_outline), ), ), const SizedBox(height: 24), FilledButton( onPressed: _handleEmailLogin, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(50), ), child: const Text("Sign In / Send Link"), ), ], ), // Phone Form Column( children: [ TextField( controller: _phoneController, decoration: const InputDecoration( labelText: "Phone Number", hintText: "+82 10-1234-5678", border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone_android), ), ), const SizedBox(height: 24), FilledButton( onPressed: _handleSmsLogin, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(50), ), child: const Text("Send Login Link"), ), const SizedBox(height: 16), const Text( "We will send a login link to your phone via SMS.", style: TextStyle(color: Colors.grey, fontSize: 12), textAlign: TextAlign.center, ), ], ), ], ), ), ], ), ), ), ); } }