From 27b8ff2ac1cfc2d67a919459581715b95a22d351 Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 19 Jan 2026 15:09:07 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20qr=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/app/src/main/AndroidManifest.xml | 1 + frontend/ios/Runner/Info.plist | 2 + .../lib/core/notifiers/auth_notifier.dart | 9 ++ .../lib/core/services/auth_proxy_service.dart | 6 +- .../auth/presentation/approve_qr_screen.dart | 5 + .../auth/presentation/login_screen.dart | 68 ++++++++-- .../presentation/login_success_screen.dart | 63 +++++++++ .../auth/presentation/qr_scan_screen.dart | 122 ++++++++++++++++++ .../presentation/dashboard_screen.dart | 72 +++++++++-- frontend/lib/main.dart | 10 ++ frontend/pubspec.yaml | 1 + 11 files changed, 338 insertions(+), 21 deletions(-) create mode 100644 frontend/lib/core/notifiers/auth_notifier.dart create mode 100644 frontend/lib/features/auth/presentation/login_success_screen.dart create mode 100644 frontend/lib/features/auth/presentation/qr_scan_screen.dart diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index 0fd500e3..e8d342e9 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + CADisableMinimumFrameDurationOnPhone + NSCameraUsageDescription + This app requires camera access to scan QR codes for login. UIApplicationSupportsIndirectInputEvents diff --git a/frontend/lib/core/notifiers/auth_notifier.dart b/frontend/lib/core/notifiers/auth_notifier.dart new file mode 100644 index 00000000..24282ca5 --- /dev/null +++ b/frontend/lib/core/notifiers/auth_notifier.dart @@ -0,0 +1,9 @@ +import 'package:flutter/foundation.dart'; + +class AuthNotifier extends ChangeNotifier { + static final AuthNotifier instance = AuthNotifier(); + + void notify() { + notifyListeners(); + } +} diff --git a/frontend/lib/core/services/auth_proxy_service.dart b/frontend/lib/core/services/auth_proxy_service.dart index c2833446..85361e62 100644 --- a/frontend/lib/core/services/auth_proxy_service.dart +++ b/frontend/lib/core/services/auth_proxy_service.dart @@ -48,7 +48,7 @@ class AuthProxyService { } } - static Future verifyMagicLink(String token) async { + static Future> verifyMagicLink(String token) async { final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify'); final response = await http.post( @@ -59,7 +59,9 @@ class AuthProxyService { }), ); - if (response.statusCode != 200) { + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { throw Exception('Verification failed: ${response.body}'); } } diff --git a/frontend/lib/features/auth/presentation/approve_qr_screen.dart b/frontend/lib/features/auth/presentation/approve_qr_screen.dart index eda1ab12..c60a31be 100644 --- a/frontend/lib/features/auth/presentation/approve_qr_screen.dart +++ b/frontend/lib/features/auth/presentation/approve_qr_screen.dart @@ -40,6 +40,11 @@ class _ApproveQrScreenState extends State { _success = true; _message = "Login Approved! Your browser should now be logged in."; }); + + // Automatically go to dashboard after a short delay + Future.delayed(const Duration(seconds: 1), () { + if (mounted) context.go('/dashboard'); + }); } catch (e) { setState(() => _message = "Error: $e"); } finally { diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index 01ccb57e..7c76a480 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -10,6 +10,7 @@ import 'package:qr_flutter/qr_flutter.dart'; import '../../../core/services/audit_service.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/notifiers/auth_notifier.dart'; class LoginScreen extends ConsumerStatefulWidget { final String? verificationToken; @@ -122,7 +123,33 @@ class _LoginScreenState extends ConsumerState if (res['status'] == 'ok' && res['sessionJwt'] != null) { timer.cancel(); _qrCountdownTimer?.cancel(); - _onLoginSuccess(res['sessionJwt']); + + final jwt = res['sessionJwt']; + // Create Dummy User & Session for Descope SDK + final dummyUser = DescopeUser( + 'unknown', // userId + [], // loginIds + 0, // createdAt + 'User', // name + null, // picture (Uri?) + '', // email + false, // isVerifiedEmail + '', // phone + false, // isVerifiedPhone + {}, // customAttributes + '', // givenName + '', // middleName + '', // familyName + false, // hasPassword + 'enabled', // status + [], // roleNames + [], // ssoAppIds + [], // oauthProviders (List) + ); + final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); + Descope.sessionManager.manageSession(session); + + _onLoginSuccess(jwt); } } catch (e) { debugPrint("[QR] Polling error: $e"); @@ -148,11 +175,20 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Starting verification for token: $token"); try { // Use Backend to verify the token (Backend-Driven Flow) - await AuthProxyService.verifyMagicLink(token); + final res = await AuthProxyService.verifyMagicLink(token); + final jwt = res['token']; debugPrint("[Auth] Verification successful for token: $token"); - if (mounted) { - _showSuccessDialog(); + if (jwt != null && mounted) { + // Create Dummy User & Session for Descope SDK to log in this tab + final dummyUser = DescopeUser( + 'unknown', [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], + ); + final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); + Descope.sessionManager.manageSession(session); + + // Notify and Go to Dashboard + _onLoginSuccess(jwt); } } catch (e) { debugPrint("[Auth] Verification FAILED for token: $token. Error: $e"); @@ -405,15 +441,25 @@ class _LoginScreenState extends ConsumerState details: "User logged in via Baron SSO", ); - if (WebAuthIntegration.isPopup()) { - WebAuthIntegration.sendLoginSuccess(token); - _showError("Login Successful! You can close this window."); - } else if (_redirectUrl != null && _redirectUrl!.isNotEmpty) { + // 1. Handle Redirect Flow (Redirect to another app) + if (_redirectUrl != null && _redirectUrl!.isNotEmpty) { final target = "$_redirectUrl?token=$token"; launchUrlString(target, webOnlyWindowName: '_self'); - } else { - // Standalone mode: Go to dashboard to act as an auth platform - if (mounted) context.go('/dashboard'); + return; + } + + // 2. Handle Popup Flow (Send message to opener) + if (WebAuthIntegration.isPopup()) { + WebAuthIntegration.sendLoginSuccess(token); + // If this window was truly a popup for another app, it should close now. + // If it's still here, we allow it to fall through to the dashboard. + } + + // 3. Standalone mode: Go to dashboard + // We call notify() to update the router's state, and go() to ensure navigation. + AuthNotifier.instance.notify(); + if (mounted) { + context.go('/dashboard'); } } diff --git a/frontend/lib/features/auth/presentation/login_success_screen.dart b/frontend/lib/features/auth/presentation/login_success_screen.dart new file mode 100644 index 00000000..7ca7293a --- /dev/null +++ b/frontend/lib/features/auth/presentation/login_success_screen.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class LoginSuccessScreen extends StatelessWidget { + const LoginSuccessScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle_outline, size: 80, color: Colors.green), + const SizedBox(height: 24), + Text( + "로그인 완료", + style: GoogleFonts.outfit( + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + const Text( + "성공적으로 로그인되었습니다.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + const SizedBox(height: 48), + + // 이 버튼이 QR 카메라를 켜는 버튼입니다. + FilledButton.icon( + onPressed: () { + context.push('/qr-scan'); + }, + icon: const Icon(Icons.camera_alt, size: 28), + label: const Text("QR 인증 (카메라 켜기)"), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게 + backgroundColor: Colors.blue.shade700, + textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + const SizedBox(height: 24), + TextButton( + onPressed: () { + context.go('/dashboard'); + }, + child: const Text("나중에 하기 (대시보드로 이동)", style: TextStyle(color: Colors.grey)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/features/auth/presentation/qr_scan_screen.dart b/frontend/lib/features/auth/presentation/qr_scan_screen.dart new file mode 100644 index 00000000..d750808d --- /dev/null +++ b/frontend/lib/features/auth/presentation/qr_scan_screen.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:descope/descope.dart'; +import '../../../core/services/auth_proxy_service.dart'; + +class QRScanScreen extends StatefulWidget { + const QRScanScreen({super.key}); + + @override + State createState() => _QRScanScreenState(); +} + +class _QRScanScreenState extends State { + final _log = Logger('QRScanScreen'); + final MobileScannerController controller = MobileScannerController( + detectionSpeed: DetectionSpeed.noDuplicates, + ); + bool _isScanned = false; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + Future _onDetect(BarcodeCapture capture) async { + if (_isScanned) return; + + final List barcodes = capture.barcodes; + for (final barcode in barcodes) { + if (barcode.rawValue != null) { + _isScanned = true; + String qrData = barcode.rawValue!; + String pendingRef = qrData; + + // URL 형식이라면 'ref' 파라미터 추출 시도 + if (qrData.startsWith('http')) { + try { + final uri = Uri.parse(qrData); + if (uri.queryParameters.containsKey('ref')) { + pendingRef = uri.queryParameters['ref']!; + } + } catch (e) { + _log.warning('Failed to parse QR URL: $qrData', e); + } + } + + _log.info('QR Code detected raw: $qrData, ref: $pendingRef'); + + final sessionToken = Descope.sessionManager.session?.sessionToken.jwt; + if (sessionToken == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('로그인이 필요합니다.'), backgroundColor: Colors.red), + ); + context.pop(); + } + return; + } + + try { + // Call backend API to approve login with clean ref + await AuthProxyService.approveQrLogin(pendingRef, sessionToken); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('로그인 승인 완료!'), + backgroundColor: Colors.green, + ), + ); + // Wait a bit and go back + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) context.pop(); + } + } catch (e) { + _log.severe("QR Approval Failed", e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('승인 실패: $e'), backgroundColor: Colors.red), + ); + // Allow rescanning after a delay + await Future.delayed(const Duration(seconds: 2)); + _isScanned = false; + } + } + break; + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Scan QR Code'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + body: MobileScanner( + controller: controller, + onDetect: _onDetect, + errorBuilder: (context, error, child) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, color: Colors.red, size: 50), + const SizedBox(height: 10), + Text('Camera Error: ${error.errorCode}'), + ], + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/features/dashboard/presentation/dashboard_screen.dart b/frontend/lib/features/dashboard/presentation/dashboard_screen.dart index f429b836..e125b046 100644 --- a/frontend/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/frontend/lib/features/dashboard/presentation/dashboard_screen.dart @@ -2,13 +2,19 @@ import 'package:flutter/material.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../../../../core/notifiers/auth_notifier.dart'; class DashboardScreen extends StatelessWidget { const DashboardScreen({super.key}); Future _logout(BuildContext context) async { + // ignore: use_build_context_synchronously Descope.sessionManager.clearSession(); - if (context.mounted) context.go('/'); + AuthNotifier.instance.notify(); + } + + void _onScanQR(BuildContext context) { + context.push('/scan'); } @override @@ -17,8 +23,12 @@ class DashboardScreen extends StatelessWidget { final userName = user?.name ?? user?.email ?? user?.phone ?? 'User'; return Scaffold( + backgroundColor: Colors.grey[50], appBar: AppBar( title: Text('Baron Launcher', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)), + elevation: 0, + backgroundColor: Colors.white, + foregroundColor: Colors.black, actions: [ IconButton( icon: const Icon(Icons.logout), @@ -28,13 +38,59 @@ class DashboardScreen extends StatelessWidget { ], ), body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Dashboard Loaded Successfully', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - const SizedBox(height: 20), - Text('Welcome, $userName'), - ], + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle_outline, size: 80, color: Colors.green), + const SizedBox(height: 24), + Text( + '로그인 성공!', + style: GoogleFonts.notoSans( + fontSize: 28, + fontWeight: FontWeight.bold, + color: const Color(0xFF1A1F2C), + ), + ), + const SizedBox(height: 8), + Text( + '반갑습니다, $userName님', + style: GoogleFonts.notoSans( + fontSize: 16, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 48), + + // QR Camera Button + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () => _onScanQR(context), + icon: const Icon(Icons.qr_code_scanner, size: 28), + label: Text( + 'QR 스캔하기', + style: GoogleFonts.notoSans(fontSize: 18, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1A1F2C), + foregroundColor: Colors.white, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 16), + const Text( + 'PC 화면의 QR 코드를 스캔하여 로그인하세요.', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + ], + ), ), ), ); diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 81e37ab6..f235ba21 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -8,10 +8,12 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'features/auth/presentation/login_screen.dart'; import 'features/auth/presentation/approve_qr_screen.dart'; +import 'features/auth/presentation/qr_scan_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; import 'features/admin/presentation/user_management_screen.dart'; import 'core/services/auth_proxy_service.dart'; import 'core/services/logger_service.dart'; +import 'core/notifiers/auth_notifier.dart'; import 'package:logging/logging.dart'; final _log = Logger('Main'); @@ -64,6 +66,7 @@ final _routerLogger = Logger('Router'); final _router = GoRouter( initialLocation: '/', debugLogDiagnostics: true, // Enable diagnostic logs + refreshListenable: AuthNotifier.instance, routes: [ GoRoute( path: '/', @@ -95,6 +98,13 @@ final _router = GoRouter( return const DashboardScreen(); }, ), + GoRoute( + path: '/scan', + builder: (context, state) { + _routerLogger.info("Navigating to /scan"); + return const QRScanScreen(); + }, + ), GoRoute( path: '/admin/users', builder: (context, state) { diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 1f13a129..3217012f 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: logging: ^1.2.0 logger: ^2.0.0 qr_flutter: ^4.1.0 + mobile_scanner: ^6.0.0 dev_dependencies: flutter_test: