From bb918932f4b8cc272040104392c5c61fb93cbcf0 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 19 May 2026 17:59:10 +0900 Subject: [PATCH] feat(userfront): improve magic link approval UX on mobile - Fixes issue #852 where 'verify_failed' error was shown on remote approval - Added specialized success view for remote-originated approval requests - Added 'Close Window' action for mobile browsers - Improved error handling for already verified/used tokens - Added necessary i18n strings in Korean and English --- .../auth/presentation/login_screen.dart | 120 ++++++++++++++++-- userfront/lib/i18n_data.dart | 8 ++ 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index b3824dc0..dc196414 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -730,6 +730,7 @@ class _LoginScreenState extends ConsumerState String actionPath = '/', bool autoRedirect = false, Duration redirectDelay = const Duration(seconds: 2), + VoidCallback? onAction, }) { if (!mounted) return; final resolvedTitle = title ?? tr('ui.userfront.login.verification.title'); @@ -743,16 +744,23 @@ class _LoginScreenState extends ConsumerState _verificationTitle = resolvedTitle; _verificationPageTitle = resolvedPageTitle; _verificationActionLabel = resolvedActionLabel; + _onVerificationAction = onAction; }); _verificationRedirectTimer?.cancel(); if (autoRedirect) { _verificationRedirectTimer = Timer(redirectDelay, () { if (!mounted) return; - context.go(actionPath); + if (onAction != null) { + onAction(); + } else { + context.go(actionPath); + } }); } } + VoidCallback? _onVerificationAction; + Widget _buildVerificationResultView() { return Center( child: Padding( @@ -785,6 +793,10 @@ class _LoginScreenState extends ConsumerState const SizedBox(height: 24), FilledButton( onPressed: () { + if (_onVerificationAction != null) { + _onVerificationAction!(); + return; + } final hasLocalSession = (AuthTokenStore.getToken()?.isNotEmpty ?? false) || AuthTokenStore.usesCookie(); @@ -810,6 +822,8 @@ class _LoginScreenState extends ConsumerState Future _verifyToken(String token) async { debugPrint("[Auth] Starting verification for token: $token"); final approvedMessage = tr('msg.userfront.login.verification.approved'); + final remoteApprovedMessage = + tr('msg.userfront.login.verification.approved_remote'); final localSessionMessage = tr( 'msg.userfront.login.verification.approved_local', ); @@ -829,7 +843,12 @@ class _LoginScreenState extends ConsumerState if (status == 'approved' || (jwt == null && _verificationOnly)) { if (mounted) { - _markVerificationApproved(approvedMessage, actionPath: actionPath); + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); } return; } @@ -851,6 +870,23 @@ class _LoginScreenState extends ConsumerState } } catch (e) { debugPrint("[Auth] Verification FAILED for token: $token. Error: $e"); + + // Handle the case where the token is already verified/used (common in remote flows) + final errorStr = e.toString(); + if (errorStr.contains('already_used') || + errorStr.contains('already_verified') || + errorStr.contains('session_active')) { + if (mounted) { + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); + } + return; + } + if (mounted) { _showError( tr( @@ -872,6 +908,8 @@ class _LoginScreenState extends ConsumerState "[Auth] Starting code verification for loginId: $sanitizedLoginId", ); final approvedMessage = tr('msg.userfront.login.verification.approved'); + final remoteApprovedMessage = + tr('msg.userfront.login.verification.approved_remote'); final localSessionMessage = tr( 'msg.userfront.login.verification.approved_local', ); @@ -894,7 +932,12 @@ class _LoginScreenState extends ConsumerState if (jwt == null && status == 'approved') { if (mounted) { - _markVerificationApproved(approvedMessage, actionPath: actionPath); + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); } return; } @@ -908,7 +951,12 @@ class _LoginScreenState extends ConsumerState return; } if (_verificationOnly) { - _markVerificationApproved(approvedMessage, actionPath: actionPath); + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); return; } _onLoginSuccess(jwt, provider: res['provider'] as String?); @@ -916,12 +964,34 @@ class _LoginScreenState extends ConsumerState } if (_verificationOnly && mounted) { - _markVerificationApproved(approvedMessage, actionPath: actionPath); + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); } } catch (e) { debugPrint( "[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e", ); + + // Handle the case where the code is already verified/used (common in remote flows) + final errorStr = e.toString(); + if (errorStr.contains('already_used') || + errorStr.contains('already_verified') || + errorStr.contains('session_active')) { + if (mounted) { + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); + } + return; + } + if (mounted) { _showError( tr( @@ -938,6 +1008,8 @@ class _LoginScreenState extends ConsumerState if (sanitized.isEmpty) return; debugPrint("[Auth] Starting short code verification for code: $sanitized"); final approvedMessage = tr('msg.userfront.login.verification.approved'); + final remoteApprovedMessage = + tr('msg.userfront.login.verification.approved_remote'); final localSessionMessage = tr( 'msg.userfront.login.verification.approved_local', ); @@ -956,7 +1028,12 @@ class _LoginScreenState extends ConsumerState if (jwt == null && status == 'approved') { if (mounted) { - _markVerificationApproved(approvedMessage, actionPath: actionPath); + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); } return; } @@ -970,7 +1047,12 @@ class _LoginScreenState extends ConsumerState return; } if (_verificationOnly) { - _markVerificationApproved(approvedMessage, actionPath: actionPath); + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); return; } _onLoginSuccess(jwt, provider: res['provider'] as String?); @@ -978,10 +1060,32 @@ class _LoginScreenState extends ConsumerState } if (_verificationOnly && mounted) { - _markVerificationApproved(approvedMessage, actionPath: actionPath); + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); } } catch (e) { debugPrint("[Auth] Short code verification FAILED. Error: $e"); + + // Handle the case where the code is already verified/used (common in remote flows) + final errorStr = e.toString(); + if (errorStr.contains('already_used') || + errorStr.contains('already_verified') || + errorStr.contains('session_active')) { + if (mounted) { + _markVerificationApproved( + remoteApprovedMessage, + title: tr('ui.userfront.login.verification.title_remote'), + actionLabel: tr('ui.userfront.login.verification.action_label_close'), + onAction: () => webWindow.close(), + ); + } + return; + } + if (mounted) { _showError( tr( diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index cee5b67e..43336b7b 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -645,6 +645,8 @@ const Map koStrings = { "msg.userfront.login.token_missing": "로그인 토큰을 확인할 수 없습니다.", "msg.userfront.login.unregistered.body": "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요.", "msg.userfront.login.verification.approved": "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + "msg.userfront.login.verification.approved_remote": + "승인되었습니다. 요청하신 브라우저 또는 PC 화면으로 돌아가 주세요.", "msg.userfront.login.verification.approved_local": "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", "msg.userfront.login.verification.success": "로그인 승인에 성공했습니다.", @@ -1914,8 +1916,10 @@ const Map koStrings = { "ui.userfront.login.unregistered.action": "회원가입 하기", "ui.userfront.login.unregistered.title": "미등록 회원", "ui.userfront.login.verification.action_label": "확인", + "ui.userfront.login.verification.action_label_close": "창 닫기", "ui.userfront.login.verification.page_title": "로그인 승인", "ui.userfront.login.verification.title": "승인 완료", + "ui.userfront.login.verification.title_remote": "로그인 승인 완료", "ui.userfront.login_success.later": "나중에 하기 (대시보드로 이동)", "ui.userfront.login_success.qr": "QR 인증 (카메라 켜기)", "ui.userfront.login_success.title": "로그인 완료", @@ -2777,6 +2781,8 @@ const Map enStrings = { "We could not find an account for that information.\\\\\\\\\\\\\\\\nPlease sign up before continuing.", "msg.userfront.login.verification.approved": "Approved. Complete sign-in in the original window.", + "msg.userfront.login.verification.approved_remote": + "Approved. Please return to the original browser or PC screen.", "msg.userfront.login.verification.approved_local": "Approved. This device is already signed in, and the remote window will be signed in shortly.", "msg.userfront.login.verification.success": "Sign-in approval completed.", @@ -4110,8 +4116,10 @@ const Map enStrings = { "ui.userfront.login.unregistered.action": "Create an account", "ui.userfront.login.unregistered.title": "Account not found", "ui.userfront.login.verification.action_label": "Done", + "ui.userfront.login.verification.action_label_close": "Close Window", "ui.userfront.login.verification.page_title": "Sign-in approval", "ui.userfront.login.verification.title": "Approval complete", + "ui.userfront.login.verification.title_remote": "Sign-in approved", "ui.userfront.login_success.later": "Do this later (go to dashboard)", "ui.userfront.login_success.qr": "Use QR approval", "ui.userfront.login_success.title": "Sign-in complete",