1
0
forked from baron/baron-sso

대시보드 qr 페이지

This commit is contained in:
2026-01-19 15:09:07 +09:00
parent ebfd60f81a
commit 27b8ff2ac1
11 changed files with 338 additions and 21 deletions

View File

@@ -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}"

View File

@@ -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>

View File

@@ -0,0 +1,9 @@
import 'package:flutter/foundation.dart';
class AuthNotifier extends ChangeNotifier {
static final AuthNotifier instance = AuthNotifier();
void notify() {
notifyListeners();
}
}

View File

@@ -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}');
}
}

View File

@@ -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 {

View File

@@ -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');
}
}

View File

@@ -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)),
),
],
),
),
),
);
}
}

View 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}'),
],
),
);
},
),
);
}
}

View File

@@ -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),
),
],
),
),
),
);

View File

@@ -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) {

View File

@@ -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: