forked from baron/baron-sso
qr 로그인
This commit is contained in:
@@ -132,6 +132,9 @@ func main() {
|
||||
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||
auth.Post("/sms", authHandler.SendSms)
|
||||
auth.Post("/verify-sms", authHandler.VerifySms)
|
||||
auth.Post("/qr/init", authHandler.InitQRLogin)
|
||||
auth.Post("/qr/poll", authHandler.PollQRLogin)
|
||||
auth.Post("/qr/approve", authHandler.ScanQRLogin)
|
||||
|
||||
// Admin Routes
|
||||
admin := api.Group("/admin")
|
||||
|
||||
@@ -24,4 +24,10 @@ type EnchantedLinkPollResponse struct {
|
||||
|
||||
type MagicLinkVerifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type QRInitResponse struct {
|
||||
QRCode string `json:"qrCode"` // Base64 or URL
|
||||
PendingRef string `json:"pendingRef"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
||||
|
||||
loginID := strings.ReplaceAll(req.LoginID, "-", "")
|
||||
loginID = strings.ReplaceAll(loginID, " ", "")
|
||||
|
||||
|
||||
// Generate secure tokens
|
||||
token := GenerateSecureToken(3)
|
||||
pendingRef := GenerateSecureToken(3)
|
||||
@@ -150,7 +150,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
||||
}
|
||||
link := fmt.Sprintf("%s/verify/%s", frontendURL, token)
|
||||
content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s", link)
|
||||
|
||||
|
||||
log.Printf("[Enchanted] Sending SMS to %s via Naver Cloud", loginID)
|
||||
|
||||
if err := h.SmsService.SendSms(loginID, content); err != nil {
|
||||
@@ -230,8 +230,8 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
||||
if strings.HasPrefix(searchPhone, "010") {
|
||||
searchPhone = "+82" + searchPhone[1:]
|
||||
} else if strings.HasPrefix(searchPhone, "82") {
|
||||
searchPhone = "+" + searchPhone
|
||||
}
|
||||
searchPhone = "+" + searchPhone
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[Verify] Searching for user with phone: %s", searchPhone)
|
||||
@@ -239,10 +239,10 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
||||
Phones: []string{searchPhone},
|
||||
Limit: 1,
|
||||
}
|
||||
|
||||
|
||||
var targetLoginID string
|
||||
users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions)
|
||||
|
||||
|
||||
if errSearch == nil && len(users) > 0 {
|
||||
if len(users[0].LoginIDs) > 0 {
|
||||
targetLoginID = users[0].LoginIDs[0]
|
||||
@@ -264,7 +264,7 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "User not found") || strings.Contains(err.Error(), "E062108") {
|
||||
log.Printf("[Verify] User %s not found. Creating...", targetLoginID)
|
||||
|
||||
|
||||
// Create User with Explicit Phone Attribute
|
||||
userObj := &descope.UserRequest{}
|
||||
if strings.Contains(targetLoginID, "@") {
|
||||
@@ -278,7 +278,7 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
||||
log.Printf("[Verify] Failed to create user: %v", errCreate)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create new user"})
|
||||
}
|
||||
|
||||
|
||||
embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0)
|
||||
if err != nil {
|
||||
log.Printf("[Verify] Failed to generate token after creation: %v", err)
|
||||
@@ -304,12 +304,93 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
||||
"jwt": sessionToken,
|
||||
})
|
||||
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
|
||||
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"token": sessionToken,
|
||||
"message": "Login successful",
|
||||
})
|
||||
}
|
||||
|
||||
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
||||
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
||||
pendingRef := GenerateSecureToken(16)
|
||||
|
||||
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
|
||||
frontendURL := os.Getenv("FRONTEND_URL")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "https://ssologin.hmac.kr"
|
||||
}
|
||||
qrPayload := fmt.Sprintf("%s/approve?ref=%s", frontendURL, pendingRef)
|
||||
|
||||
log.Printf("[QR] Init: PendingRef=%s, URL=%s", pendingRef, qrPayload)
|
||||
|
||||
// Redis에 초기 상태 저장 (5분 만료)
|
||||
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환
|
||||
"pendingRef": pendingRef,
|
||||
"expiresIn": 300,
|
||||
})
|
||||
}
|
||||
|
||||
// PollQRLogin - Step 2: 웹에서 승인 여부를 폴링합니다.
|
||||
func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
PendingRef string `json:"pendingRef"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
|
||||
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
||||
if err != nil || val == "" {
|
||||
return c.JSON(fiber.Map{"status": "expired"})
|
||||
}
|
||||
|
||||
var data map[string]string
|
||||
json.Unmarshal([]byte(val), &data)
|
||||
|
||||
if data["status"] == statusSuccess {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"sessionJwt": data["jwt"],
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"status": statusPending})
|
||||
}
|
||||
|
||||
// ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다.
|
||||
// (이미 로그인된 세션이 필요함)
|
||||
func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
PendingRef string `json:"pendingRef"`
|
||||
Token string `json:"token"` // 모바일 사용자의 세션 토큰 (검증용)
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
|
||||
log.Printf("[QR] Scan & Approve: PendingRef=%s", req.PendingRef)
|
||||
|
||||
// 1. Redis에서 세션 확인
|
||||
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
||||
if err != nil || val == "" {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"})
|
||||
}
|
||||
|
||||
// 2. 모바일 유저의 토큰으로 새 세션 토큰(웹용)을 발행하거나 그대로 전달
|
||||
|
||||
sessionData, _ := json.Marshal(map[string]string{
|
||||
"status": statusSuccess,
|
||||
"jwt": req.Token,
|
||||
})
|
||||
h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute)
|
||||
|
||||
return c.JSON(fiber.Map{"message": "QR Login Approved"})
|
||||
}
|
||||
|
||||
// ProxyToDescope (Placeholder)
|
||||
func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interface{}) error {
|
||||
return c.Status(501).SendString("Descope Proxy Disabled")
|
||||
|
||||
@@ -99,6 +99,51 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> initQrLogin() async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/init');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to init QR login: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> pollQrStatus(String pendingRef) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/poll');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'pendingRef': pendingRef}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('QR Polling failed: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> approveQrLogin(String pendingRef, String token) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'pendingRef': pendingRef,
|
||||
'token': token,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('QR Approval failed: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> checkAdminAuth(String adminPassword) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/admin/check');
|
||||
try {
|
||||
|
||||
117
frontend/lib/features/auth/presentation/approve_qr_screen.dart
Normal file
117
frontend/lib/features/auth/presentation/approve_qr_screen.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
|
||||
class ApproveQrScreen extends StatefulWidget {
|
||||
final String? pendingRef;
|
||||
const ApproveQrScreen({super.key, this.pendingRef});
|
||||
|
||||
@override
|
||||
State<ApproveQrScreen> createState() => _ApproveQrScreenState();
|
||||
}
|
||||
|
||||
class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
bool _isLoading = false;
|
||||
String? _message;
|
||||
bool _success = false;
|
||||
|
||||
Future<void> _handleApprove() async {
|
||||
if (widget.pendingRef == null) return;
|
||||
|
||||
final session = Descope.sessionManager.session;
|
||||
if (session == null || session.refreshToken.isExpired) {
|
||||
setState(() => _message = "Please log in on your phone first.");
|
||||
context.go('/'); // Redirect to login
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_message = null;
|
||||
});
|
||||
// jwt 유효성 확인
|
||||
try {
|
||||
await AuthProxyService.approveQrLogin(
|
||||
widget.pendingRef!,
|
||||
session.sessionToken.jwt,
|
||||
);
|
||||
setState(() {
|
||||
_success = true;
|
||||
_message = "Login Approved! Your browser should now be logged in.";
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = "Error: $e");
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("QR Login Approval")),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.phonelink_lock, size: 80, color: Colors.blue),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"Web Login Request",
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"A computer is trying to log in using this QR code.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
if (_message != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
_message!,
|
||||
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
if (!_success)
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading || !isLoggedIn ? null : _handleApprove,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text("Approve Login"),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(60),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
),
|
||||
|
||||
if (!isLoggedIn && !_success)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: TextButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text("Login on this device first"),
|
||||
),
|
||||
),
|
||||
|
||||
if (_success)
|
||||
FilledButton(
|
||||
onPressed: () => context.go('/dashboard'),
|
||||
child: const Text("Go to My Dashboard"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
@@ -5,6 +6,7 @@ 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 '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';
|
||||
@@ -27,10 +29,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
bool _smsSent = false;
|
||||
String? _redirectUrl;
|
||||
|
||||
// QR Login Variables
|
||||
String? _qrImageBase64;
|
||||
String? _qrPendingRef;
|
||||
bool _isQrLoading = false;
|
||||
Timer? _qrPollingTimer;
|
||||
int _qrRemainingSeconds = 0;
|
||||
Timer? _qrCountdownTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this, initialIndex: 1);
|
||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||
_tabController.addListener(_handleTabSelection);
|
||||
|
||||
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -50,6 +61,89 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTabSelection() {
|
||||
if (_tabController.index == 2 && _qrPendingRef == null) {
|
||||
_startQrFlow();
|
||||
} else if (_tabController.index != 2) {
|
||||
_stopQrPolling();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startQrFlow() async {
|
||||
if (_isQrLoading) return;
|
||||
setState(() {
|
||||
_isQrLoading = true;
|
||||
_qrImageBase64 = null;
|
||||
_qrRemainingSeconds = 0;
|
||||
});
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.initQrLogin();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_qrImageBase64 = res['qrCode'];
|
||||
_qrPendingRef = res['pendingRef'];
|
||||
_qrRemainingSeconds = res['expiresIn'] ?? 300;
|
||||
_isQrLoading = false;
|
||||
});
|
||||
_startQrPolling();
|
||||
_startCountdown();
|
||||
}
|
||||
} catch (e) {
|
||||
_showError("Failed to init QR: $e");
|
||||
if (mounted) setState(() => _isQrLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_qrCountdownTimer?.cancel();
|
||||
_qrCountdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
if (_qrRemainingSeconds <= 0) _stopQrPolling();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_qrRemainingSeconds--;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _startQrPolling() {
|
||||
_qrPollingTimer?.cancel();
|
||||
_qrPollingTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async {
|
||||
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
||||
if (res['status'] == 'ok' && res['sessionJwt'] != null) {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
_onLoginSuccess(res['sessionJwt']);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[QR] Polling error: $e");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopQrPolling() {
|
||||
_qrPollingTimer?.cancel();
|
||||
_qrPollingTimer = null;
|
||||
_qrCountdownTimer?.cancel();
|
||||
_qrCountdownTimer = null;
|
||||
_qrPendingRef = null;
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final m = seconds ~/ 60;
|
||||
final s = seconds % 60;
|
||||
return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}";
|
||||
}
|
||||
|
||||
Future<void> _verifyToken(String token) async {
|
||||
debugPrint("[Auth] Starting verification for token: $token");
|
||||
try {
|
||||
@@ -81,6 +175,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopQrPolling();
|
||||
_tabController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
@@ -192,6 +287,32 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final jwt = result['sessionJwt'];
|
||||
if (jwt != null) {
|
||||
debugPrint("[Auth] Polling SUCCESS. Token received.");
|
||||
|
||||
// Descope SDK 세션 강제 주입
|
||||
// Note: DescopeUser in 0.9.11 requires 18 positional arguments.
|
||||
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);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Polling Dialog
|
||||
_onLoginSuccess(jwt);
|
||||
@@ -287,11 +408,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (WebAuthIntegration.isPopup()) {
|
||||
WebAuthIntegration.sendLoginSuccess(token);
|
||||
_showError("Login Successful! You can close this window.");
|
||||
} else if (_redirectUrl != null) {
|
||||
} else if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
|
||||
final target = "$_redirectUrl?token=$token";
|
||||
launchUrlString(target, webOnlyWindowName: '_self');
|
||||
} else {
|
||||
_showError("Login Successful (Token Received)");
|
||||
// Standalone mode: Go to dashboard to act as an auth platform
|
||||
if (mounted) context.go('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,12 +455,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
tabs: const [
|
||||
Tab(text: "Email"),
|
||||
Tab(text: "Phone (SMS)"),
|
||||
Tab(text: "QR"),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
height: 300,
|
||||
height: 350,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
@@ -402,6 +525,53 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// QR Login View
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isQrLoading)
|
||||
const CircularProgressIndicator()
|
||||
else if (_qrImageBase64 != null)
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: _qrImageBase64!,
|
||||
version: QrVersions.auto,
|
||||
size: 200.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_qrRemainingSeconds > 0
|
||||
? "Remaining Time: ${_formatTime(_qrRemainingSeconds)}"
|
||||
: "QR Code Expired",
|
||||
style: TextStyle(
|
||||
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"Scan with your mobile app",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _startQrFlow,
|
||||
child: const Text("Refresh QR")
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
const Text("Failed to load QR code."),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
||||
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/dashboard/presentation/dashboard_screen.dart';
|
||||
import 'features/admin/presentation/user_management_screen.dart';
|
||||
import 'core/services/auth_proxy_service.dart';
|
||||
@@ -79,6 +80,14 @@ final _router = GoRouter(
|
||||
return LoginScreen(verificationToken: token);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/approve',
|
||||
builder: (context, state) {
|
||||
final ref = state.uri.queryParameters['ref'];
|
||||
_routerLogger.info("Navigating to /approve with ref: $ref");
|
||||
return ApproveQrScreen(pendingRef: ref);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/dashboard',
|
||||
builder: (context, state) {
|
||||
@@ -98,7 +107,7 @@ final _router = GoRouter(
|
||||
final isLoggedIn =
|
||||
Descope.sessionManager.session?.refreshToken.isExpired == false;
|
||||
final path = state.uri.path;
|
||||
final isLoggingIn = path == '/' || path.startsWith('/verify/') || path.startsWith('/admin/');
|
||||
final isLoggingIn = path == '/' || path.startsWith('/verify/') || path.startsWith('/admin/') || path == '/approve';
|
||||
|
||||
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ dependencies:
|
||||
url_launcher: ^6.3.2
|
||||
logging: ^1.2.0
|
||||
logger: ^2.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user