forked from baron/baron-sso
대시보드 qr 페이지
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<application
|
||||
android:label="frontend"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app requires camera access to scan QR codes for login.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
9
frontend/lib/core/notifiers/auth_notifier.dart
Normal file
9
frontend/lib/core/notifiers/auth_notifier.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AuthNotifier extends ChangeNotifier {
|
||||
static final AuthNotifier instance = AuthNotifier();
|
||||
|
||||
void notify() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> verifyMagicLink(String token) async {
|
||||
static Future<Map<String, dynamic>> 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}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
_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 {
|
||||
|
||||
@@ -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<LoginScreen>
|
||||
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<String>)
|
||||
);
|
||||
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<LoginScreen>
|
||||
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<LoginScreen>
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
122
frontend/lib/features/auth/presentation/qr_scan_screen.dart
Normal file
122
frontend/lib/features/auth/presentation/qr_scan_screen.dart
Normal file
@@ -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<QRScanScreen> createState() => _QRScanScreenState();
|
||||
}
|
||||
|
||||
class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final _log = Logger('QRScanScreen');
|
||||
final MobileScannerController controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
);
|
||||
bool _isScanned = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _onDetect(BarcodeCapture capture) async {
|
||||
if (_isScanned) return;
|
||||
|
||||
final List<Barcode> 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}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user