forked from baron/baron-sso
2417 lines
90 KiB
Dart
2417 lines
90 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:qr_flutter/qr_flutter.dart';
|
|
import 'package:userfront/i18n.dart';
|
|
import '../../../core/widgets/language_selector.dart';
|
|
import '../../../core/widgets/theme_toggle_button.dart';
|
|
import '../../../core/services/web_auth_integration.dart';
|
|
import '../../../core/services/auth_proxy_service.dart';
|
|
import '../../../core/services/auth_token_store.dart';
|
|
import '../../../core/services/login_challenge_loop_guard.dart';
|
|
import '../../../core/i18n/locale_utils.dart';
|
|
import '../../../core/services/oidc_redirect_guard.dart';
|
|
import '../../../core/notifiers/auth_notifier.dart';
|
|
import '../domain/login_challenge_resolver.dart';
|
|
import '../domain/cookie_session_policy.dart';
|
|
import '../domain/login_link_route_policy.dart';
|
|
import '../domain/verification_completion_route.dart';
|
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
|
import '../../../core/services/web_window.dart';
|
|
import '../../../core/ui/toast_service.dart';
|
|
|
|
class LoginScreen extends ConsumerStatefulWidget {
|
|
final String? verificationToken;
|
|
final String? loginChallenge;
|
|
final String? redirectUrl;
|
|
final bool verificationCompleteOnly;
|
|
|
|
const LoginScreen({
|
|
super.key,
|
|
this.verificationToken,
|
|
this.loginChallenge,
|
|
this.redirectUrl,
|
|
this.verificationCompleteOnly = false,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends ConsumerState<LoginScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
final TextEditingController _linkIdController = TextEditingController();
|
|
final TextEditingController _passwordLoginIdController =
|
|
TextEditingController();
|
|
final TextEditingController _passwordController = TextEditingController();
|
|
final FocusNode _passwordFocusNode = FocusNode();
|
|
String? _redirectUrl;
|
|
String? _loginChallenge;
|
|
bool _isPasswordCapsLockOn = false;
|
|
String? _loginIdLabel;
|
|
|
|
// QR Login Variables
|
|
String? _qrImageBase64;
|
|
String? _qrPendingRef;
|
|
bool _isQrLoading = false;
|
|
bool _qrExpired = false;
|
|
Timer? _qrPollingTimer;
|
|
int _qrRemainingSeconds = 0;
|
|
Timer? _qrCountdownTimer;
|
|
int _qrPollIntervalMs = 2000;
|
|
final TextEditingController _shortCodePrefixController =
|
|
TextEditingController();
|
|
final TextEditingController _shortCodeDigitsController =
|
|
TextEditingController();
|
|
String? _linkPendingRef;
|
|
String? _lastLinkLoginId;
|
|
bool _lastLinkIsEmail = true;
|
|
int _linkResendSeconds = 0;
|
|
Timer? _linkResendTimer;
|
|
int _linkExpireSeconds = 0;
|
|
Timer? _linkExpireTimer;
|
|
bool _linkExpired = false;
|
|
bool _verificationOnly = false;
|
|
bool _verificationApproved = false;
|
|
bool _dismissedOverlays = false;
|
|
bool _localNavigationCompleted = false;
|
|
String? _verificationMessageKey;
|
|
String _verificationTitleKey = 'ui.userfront.login.verification.title';
|
|
String _verificationPageTitleKey =
|
|
'ui.userfront.login.verification.page_title';
|
|
String _verificationActionLabelKey =
|
|
'ui.userfront.login.verification.action_label';
|
|
Timer? _verificationRedirectTimer;
|
|
bool _noticeHandled = false;
|
|
bool _drySendEnabled = false;
|
|
bool _oidcAutoAcceptTried = false;
|
|
bool _verificationHandoffStarted = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 0);
|
|
_tabController.addListener(_handleTabSelection);
|
|
_drySendEnabled =
|
|
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
|
!AuthProxyService.isProdEnv;
|
|
_redirectUrl = widget.redirectUrl;
|
|
_passwordFocusNode.addListener(_handlePasswordFocusChange);
|
|
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
|
|
_verificationOnly =
|
|
widget.verificationCompleteOnly || _isVerificationOnlyUri(Uri.base);
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
final uri = Uri.base;
|
|
|
|
if (_redirectUrl == null) {
|
|
if (uri.queryParameters.containsKey('redirect_url')) {
|
|
_redirectUrl = uri.queryParameters['redirect_url'];
|
|
} else if (uri.queryParameters.containsKey('redirect_uri')) {
|
|
_redirectUrl = uri.queryParameters['redirect_uri'];
|
|
}
|
|
}
|
|
|
|
final challengeResolution = _resolveLoginChallenge(uri);
|
|
_loginChallenge = challengeResolution.value;
|
|
_logLoginChallengeDiagnostics(
|
|
phase: 'init',
|
|
resolution: challengeResolution,
|
|
);
|
|
final loginIdParam = uri.queryParameters['loginId'];
|
|
final codeParam = uri.queryParameters['code'];
|
|
final pendingRefParam = uri.queryParameters['pendingRef'];
|
|
final shortCodeFromPath = extractLoginShortCode(uri);
|
|
final hasShortCodePath = shortCodeFromPath != null;
|
|
final acceptsVerificationPayload = isDedicatedVerificationRoute(uri);
|
|
final waitsForVerificationHandoff =
|
|
!acceptsVerificationPayload && hasVerificationPayload(uri);
|
|
final hasTokenParam =
|
|
acceptsVerificationPayload && uri.queryParameters.containsKey('t');
|
|
final hasVerificationToken =
|
|
widget.verificationToken != null || hasTokenParam;
|
|
final hasLoginCode =
|
|
acceptsVerificationPayload &&
|
|
loginIdParam != null &&
|
|
codeParam != null;
|
|
final notice = uri.queryParameters['notice'];
|
|
|
|
if (widget.verificationCompleteOnly) {
|
|
_markVerificationApproved(
|
|
'msg.userfront.login.verification.approved_remote',
|
|
titleKey: 'ui.userfront.login.verification.title_remote',
|
|
actionLabelKey: 'ui.userfront.login.verification.action_label_remote',
|
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (waitsForVerificationHandoff) {
|
|
final localeCode =
|
|
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode();
|
|
final target = buildDedicatedVerificationRedirect(
|
|
uri,
|
|
localeCode: localeCode,
|
|
);
|
|
if (target != null && mounted) {
|
|
context.go(target);
|
|
_startVerificationHandoff(Uri.parse(target));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (hasShortCodePath) {
|
|
_verifyShortCode(shortCodeFromPath);
|
|
}
|
|
if (hasLoginCode) {
|
|
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
|
} else if (hasVerificationToken) {
|
|
final verificationToken =
|
|
widget.verificationToken ?? uri.queryParameters['t'];
|
|
if (verificationToken != null && verificationToken.isNotEmpty) {
|
|
_verifyToken(verificationToken);
|
|
}
|
|
}
|
|
|
|
if (!_noticeHandled && notice == 'qr_login_required') {
|
|
_noticeHandled = true;
|
|
_showInfo(tr('msg.userfront.login.qr_login_required'));
|
|
}
|
|
|
|
if (!_verificationOnly) {
|
|
await _attemptOidcAutoAccept();
|
|
if (!mounted) return;
|
|
|
|
// Fetch Tenant Info to check for custom login ID label
|
|
try {
|
|
final info = await AuthProxyService.getTenantInfo();
|
|
if (info['loginIdLabel'] != null) {
|
|
setState(() {
|
|
_loginIdLabel = info['loginIdLabel'];
|
|
});
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] Failed to fetch tenant info: $e");
|
|
}
|
|
|
|
// login_challenge 흐름에서는 auto-accept에서 이미 쿠키 세션까지 확인하므로
|
|
// 동일 프레임에서 중복 체크를 피합니다.
|
|
if (!_hasLoginChallenge) {
|
|
await _tryCookieSession();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
bool _isVerificationOnlyUri(Uri uri) {
|
|
if (!isDedicatedVerificationRoute(uri) &&
|
|
widget.verificationToken == null) {
|
|
return false;
|
|
}
|
|
final loginIdParam = uri.queryParameters['loginId'];
|
|
final codeParam = uri.queryParameters['code'];
|
|
return widget.verificationToken != null ||
|
|
uri.queryParameters.containsKey('t') ||
|
|
(loginIdParam != null && codeParam != null) ||
|
|
extractLoginShortCode(uri) != null;
|
|
}
|
|
|
|
void _startVerificationHandoff(Uri targetUri) {
|
|
if (_verificationHandoffStarted) {
|
|
return;
|
|
}
|
|
_verificationHandoffStarted = true;
|
|
if (mounted) {
|
|
setState(() {
|
|
_verificationOnly = true;
|
|
});
|
|
} else {
|
|
_verificationOnly = true;
|
|
}
|
|
|
|
final loginIdParam = targetUri.queryParameters['loginId'];
|
|
final codeParam = targetUri.queryParameters['code'];
|
|
final pendingRefParam = targetUri.queryParameters['pendingRef'];
|
|
final tokenParam = targetUri.queryParameters['t'];
|
|
|
|
if (loginIdParam != null && codeParam != null) {
|
|
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
|
return;
|
|
}
|
|
if (tokenParam != null && tokenParam.isNotEmpty) {
|
|
_verifyToken(tokenParam);
|
|
}
|
|
}
|
|
|
|
void _handlePasswordFocusChange() {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
if (_passwordFocusNode.hasFocus) {
|
|
_syncPasswordCapsLockState();
|
|
return;
|
|
}
|
|
if (_isPasswordCapsLockOn) {
|
|
setState(() {
|
|
_isPasswordCapsLockOn = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
bool _handleHardwareKeyEvent(KeyEvent event) {
|
|
if (_passwordFocusNode.hasFocus) {
|
|
_syncPasswordCapsLockState();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void _syncPasswordCapsLockState() {
|
|
final isEnabled = HardwareKeyboard.instance.lockModesEnabled.contains(
|
|
KeyboardLockMode.capsLock,
|
|
);
|
|
if (!mounted || isEnabled == _isPasswordCapsLockOn) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_isPasswordCapsLockOn = isEnabled;
|
|
});
|
|
}
|
|
|
|
Future<void> _tryCookieSession({bool silent = true}) async {
|
|
if (AuthTokenStore.consumeSkipCookieSessionCheck()) {
|
|
debugPrint(
|
|
"[Auth] Skipping one cookie session check after verification handoff.",
|
|
);
|
|
return;
|
|
}
|
|
final loginChallenge = _loginChallenge;
|
|
final token = AuthTokenStore.getToken();
|
|
if (!shouldPromoteCookieSession(
|
|
currentToken: token,
|
|
loginChallenge: loginChallenge,
|
|
)) {
|
|
return;
|
|
}
|
|
final pendingProvider = AuthTokenStore.getPendingProvider();
|
|
final provider = pendingProvider ?? AuthTokenStore.getProvider() ?? 'ory';
|
|
|
|
try {
|
|
final status = await AuthProxyService.getSessionStatus(useCookie: true);
|
|
if (status != 200) {
|
|
debugPrint(
|
|
"[Auth] Cookie session check: No active session (status: $status)",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!shouldPromoteCookieSession(
|
|
currentToken: AuthTokenStore.getToken(),
|
|
loginChallenge: loginChallenge,
|
|
)) {
|
|
return;
|
|
}
|
|
AuthTokenStore.setCookieMode(provider: provider);
|
|
AuthTokenStore.clearPendingProvider();
|
|
if (mounted) {
|
|
await ref.read(profileProvider.notifier).loadProfile();
|
|
await _onCookieLoginSuccess(provider);
|
|
}
|
|
} catch (e) {
|
|
if (!silent) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.cookie_check_failed',
|
|
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _onCookieLoginSuccess(String provider) async {
|
|
debugPrint("[Auth] Cookie-based login success. Provider: $provider");
|
|
if (_hasLoginChallenge) {
|
|
final accepted = await _acceptOidcLoginAndRedirect();
|
|
if (accepted) {
|
|
return;
|
|
}
|
|
if (mounted) {
|
|
_showError(tr('msg.userfront.login.oidc_failed'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
final token = AuthTokenStore.getToken();
|
|
if (token != null && token.isNotEmpty) {
|
|
final redirectUrl = _redirectUrl;
|
|
if (WebAuthIntegration.isPopup() ||
|
|
(redirectUrl != null && redirectUrl.isNotEmpty)) {
|
|
debugPrint(
|
|
"[Auth] Cookie session with external integration. Notifying...",
|
|
);
|
|
WebAuthIntegration.sendLoginSuccess(token);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
_goLocalizedHomeOnce();
|
|
}
|
|
}
|
|
|
|
void _goLocalizedHomeOnce() {
|
|
if (!mounted || _localNavigationCompleted) {
|
|
return;
|
|
}
|
|
_localNavigationCompleted = true;
|
|
context.go(buildLocalizedHomePath(Uri.base));
|
|
}
|
|
|
|
Future<void> _attemptOidcAutoAccept() async {
|
|
if (_oidcAutoAcceptTried) return;
|
|
_oidcAutoAcceptTried = true;
|
|
final loginChallenge = _loginChallenge;
|
|
if (loginChallenge == null || loginChallenge.isEmpty) {
|
|
return;
|
|
}
|
|
if (!loginChallengeLoopGuard.shouldAllowAutoAccept(loginChallenge)) {
|
|
debugPrint(
|
|
"[Auth] OIDC auto-accept blocked by loop guard for login_challenge",
|
|
);
|
|
return;
|
|
}
|
|
loginChallengeLoopGuard.markAutoAcceptAttempt(loginChallenge);
|
|
|
|
final token = AuthTokenStore.getToken();
|
|
if (token != null && token.isNotEmpty) {
|
|
final accepted = await _acceptOidcLoginAndRedirect(token: token);
|
|
if (accepted) {
|
|
loginChallengeLoopGuard.clear(loginChallenge);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// 401 응답은 세션이 없는 정상적인 상태이므로 예외로 처리하지 않고 우아하게 중단합니다.
|
|
final status = await AuthProxyService.getSessionStatus(useCookie: true);
|
|
if (status == 200) {
|
|
AuthTokenStore.setCookieMode(
|
|
provider: AuthTokenStore.getProvider() ?? 'ory',
|
|
);
|
|
final accepted = await _acceptOidcLoginAndRedirect();
|
|
if (accepted) {
|
|
loginChallengeLoopGuard.clear(loginChallenge);
|
|
return;
|
|
}
|
|
} else {
|
|
debugPrint(
|
|
"[Auth] OIDC auto-accept: No active session (status: $status)",
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
|
|
}
|
|
}
|
|
|
|
Future<bool> _acceptOidcLoginAndRedirect({String? token}) async {
|
|
final loginChallenge = _loginChallenge;
|
|
if (loginChallenge == null || loginChallenge.isEmpty) {
|
|
return false;
|
|
}
|
|
try {
|
|
final res = await AuthProxyService.acceptOidcLogin(
|
|
loginChallenge,
|
|
token: token,
|
|
);
|
|
|
|
// IMPORTANT: If backend returned a token during OIDC flow, save it to fix login state.
|
|
final jwt = res['sessionJwt'] ?? res['token'] ?? token;
|
|
if (jwt != null && jwt.isNotEmpty) {
|
|
final provider =
|
|
res['provider'] as String? ?? AuthTokenStore.getProvider();
|
|
await AuthNotifier.instance.onLoginSuccess(jwt, provider: provider);
|
|
}
|
|
|
|
final redirectTo = res['redirectTo'] as String?;
|
|
if (redirectTo != null && redirectTo.isNotEmpty) {
|
|
// Give 50ms delay for localStorage to settle
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
return _redirectToOidcTarget(redirectTo, source: 'accept_oidc_login');
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] OIDC login auto-accept failed: $e");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool _redirectToOidcTarget(String redirectTo, {required String source}) {
|
|
final checked = validateOidcRedirectTarget(redirectTo);
|
|
_logOidcRedirectDiagnostics(source: source, checked: checked);
|
|
debugPrint(
|
|
"[Auth] OIDC redirect check ($source): valid=${checked.isValid}, reason=${checked.reason}, len=${checked.length}, host=${checked.host}, path=${checked.path}, has_login_verifier=${checked.hasLoginVerifier}",
|
|
);
|
|
|
|
if (!checked.isValid || checked.uri == null) {
|
|
if (mounted) {
|
|
_showError(tr('msg.userfront.login.oidc_failed'));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
debugPrint(
|
|
"[Auth] OIDC redirect execute ($source): host=${checked.host}, path=${checked.path}, redirect_uri_host=${checked.redirectUriHost}, redirect_uri_port=${checked.redirectUriPort}, state_len=${checked.stateLength}, login_verifier_len=${checked.loginVerifierLength}",
|
|
);
|
|
webWindow.redirectTo(checked.uri.toString());
|
|
return true;
|
|
} catch (e) {
|
|
debugPrint("[Auth] OIDC redirect failed ($source): $e");
|
|
if (mounted) {
|
|
_showError(tr('msg.userfront.login.oidc_failed'));
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool get _hasLoginChallenge {
|
|
final loginChallenge = _loginChallenge;
|
|
return loginChallenge != null && loginChallenge.isNotEmpty;
|
|
}
|
|
|
|
LoginChallengeResolution _resolveLoginChallenge(Uri uri) {
|
|
return resolveLoginChallenge(
|
|
widgetLoginChallenge: widget.loginChallenge,
|
|
uri: uri,
|
|
rawSearch: webWindow.currentSearch(),
|
|
rawHref: webWindow.currentHref(),
|
|
);
|
|
}
|
|
|
|
void _logLoginChallengeDiagnostics({
|
|
required String phase,
|
|
required LoginChallengeResolution resolution,
|
|
}) {
|
|
final current = Uri.base;
|
|
final currentQueryKeys = current.queryParameters.keys.toList()..sort();
|
|
final payload = <String, Object?>{
|
|
'phase': phase,
|
|
'current_path': current.path,
|
|
'current_query_keys': currentQueryKeys,
|
|
'stored_has_login_challenge': _hasLoginChallenge,
|
|
'stored_login_challenge_len': _loginChallenge?.length ?? 0,
|
|
...resolution.toDiagnostics(),
|
|
};
|
|
debugPrint("[Auth] login_challenge diagnostics: ${jsonEncode(payload)}");
|
|
}
|
|
|
|
void _logOidcRedirectDiagnostics({
|
|
required String source,
|
|
required OidcRedirectCheckResult checked,
|
|
}) {
|
|
final current = Uri.base;
|
|
final currentQueryKeys = current.queryParameters.keys.toList()..sort();
|
|
|
|
final payload = <String, Object?>{
|
|
'source': source,
|
|
'current_path': current.path,
|
|
'current_query_param_count': current.queryParameters.length,
|
|
'current_query_keys': currentQueryKeys,
|
|
'has_login_challenge': _hasLoginChallenge,
|
|
'login_challenge_len': _loginChallenge?.length ?? 0,
|
|
...checked.toDiagnostics(),
|
|
};
|
|
|
|
debugPrint("[Auth] OIDC redirect diagnostics: ${jsonEncode(payload)}");
|
|
}
|
|
|
|
void _resetLinkLoginState() {
|
|
_linkPendingRef = null;
|
|
_lastLinkLoginId = null;
|
|
_lastLinkIsEmail = true;
|
|
_linkResendTimer?.cancel();
|
|
_linkResendTimer = null;
|
|
_linkResendSeconds = 0;
|
|
_linkExpireTimer?.cancel();
|
|
_linkExpireTimer = null;
|
|
_linkExpireSeconds = 0;
|
|
_linkExpired = false;
|
|
_shortCodePrefixController.clear();
|
|
_shortCodeDigitsController.clear();
|
|
}
|
|
|
|
void _dismissOverlays() {
|
|
if (!mounted || _dismissedOverlays) {
|
|
return;
|
|
}
|
|
_dismissedOverlays = true;
|
|
final navigator = Navigator.of(context, rootNavigator: true);
|
|
navigator.popUntil((route) => route is! PopupRoute);
|
|
}
|
|
|
|
bool _parseBoolParam(String? value) {
|
|
if (value == null) {
|
|
return false;
|
|
}
|
|
final normalized = value.toLowerCase();
|
|
return normalized == 'true' || normalized == '1' || normalized == 'yes';
|
|
}
|
|
|
|
void _startLinkResendTimer(int seconds) {
|
|
_linkResendSeconds = seconds;
|
|
_linkResendTimer?.cancel();
|
|
_linkResendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
if (_linkResendSeconds > 0) {
|
|
_linkResendSeconds--;
|
|
} else {
|
|
timer.cancel();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
void _startLinkExpireTimer(int seconds) {
|
|
_linkExpireSeconds = seconds;
|
|
_linkExpireTimer?.cancel();
|
|
_linkExpireTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (!mounted) return;
|
|
if (_linkExpireSeconds > 0) {
|
|
setState(() {
|
|
_linkExpireSeconds--;
|
|
});
|
|
return;
|
|
}
|
|
timer.cancel();
|
|
if (mounted) {
|
|
setState(() {
|
|
_linkExpired = true;
|
|
});
|
|
_showInfo(tr('msg.userfront.login.link_timeout'));
|
|
}
|
|
});
|
|
}
|
|
|
|
String _getLoginIdFromJwt(String jwt) {
|
|
try {
|
|
final parts = jwt.split('.');
|
|
if (parts.length != 3) return 'User';
|
|
|
|
final payload = utf8.decode(
|
|
base64Url.decode(base64Url.normalize(parts[1])),
|
|
);
|
|
final data = json.decode(payload);
|
|
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
|
|
} catch (e) {
|
|
debugPrint("[JWT] Decode error: $e");
|
|
return 'User';
|
|
}
|
|
}
|
|
|
|
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;
|
|
_qrExpired = false;
|
|
});
|
|
|
|
try {
|
|
final res = await AuthProxyService.initQrLogin();
|
|
if (mounted) {
|
|
setState(() {
|
|
_qrImageBase64 = res['qrCode'];
|
|
_qrPendingRef = res['pendingRef'];
|
|
_qrRemainingSeconds = res['expiresIn'] ?? 300;
|
|
final interval = res['interval'];
|
|
if (interval is int && interval > 0) {
|
|
_qrPollIntervalMs = interval * 1000;
|
|
} else {
|
|
_qrPollIntervalMs = 2000;
|
|
}
|
|
_isQrLoading = false;
|
|
});
|
|
_startQrPolling();
|
|
_startCountdown();
|
|
}
|
|
} catch (e) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.qr_init_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
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();
|
|
if (mounted) {
|
|
setState(() {
|
|
_qrExpired = true;
|
|
});
|
|
_showInfo(tr('msg.userfront.login.qr_expired'));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
setState(() {
|
|
_qrRemainingSeconds--;
|
|
});
|
|
});
|
|
}
|
|
|
|
void _startQrPolling() {
|
|
_qrPollingTimer?.cancel();
|
|
_qrPollingTimer = Timer.periodic(
|
|
Duration(milliseconds: _qrPollIntervalMs),
|
|
(timer) async {
|
|
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final pendingRef = _qrPendingRef;
|
|
if (pendingRef == null || pendingRef.isEmpty) {
|
|
return;
|
|
}
|
|
final res = await AuthProxyService.pollQrStatus(pendingRef);
|
|
if (res['error'] == 'slow_down') {
|
|
final interval = res['interval'];
|
|
if (interval is int && interval > 0) {
|
|
final nextIntervalMs = interval * 1000;
|
|
if (nextIntervalMs != _qrPollIntervalMs) {
|
|
_qrPollIntervalMs = nextIntervalMs;
|
|
timer.cancel();
|
|
_startQrPolling();
|
|
return;
|
|
}
|
|
} else {
|
|
_qrPollIntervalMs += 500;
|
|
timer.cancel();
|
|
_startQrPolling();
|
|
return;
|
|
}
|
|
}
|
|
if (res['error'] == 'authorization_pending') {
|
|
return;
|
|
}
|
|
if (res['error'] == 'expired_token') {
|
|
timer.cancel();
|
|
_qrCountdownTimer?.cancel();
|
|
if (mounted) {
|
|
setState(() {
|
|
_qrExpired = true;
|
|
});
|
|
}
|
|
_showError(tr('msg.userfront.login.qr_expired'));
|
|
return;
|
|
}
|
|
|
|
if (res['status'] == 'ok') {
|
|
timer.cancel();
|
|
_qrCountdownTimer?.cancel();
|
|
final token =
|
|
res['sessionJwt'] ?? res['sessionToken'] ?? res['token'];
|
|
if (token is String && token.isNotEmpty) {
|
|
_completeLoginFromToken(token);
|
|
} else {
|
|
_showError(tr('msg.userfront.login.token_missing'));
|
|
}
|
|
}
|
|
} 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')}";
|
|
}
|
|
|
|
void _completeLoginFromToken(
|
|
String token, {
|
|
String? provider,
|
|
bool closeDialog = false,
|
|
}) {
|
|
final isJwt = token.split('.').length == 3;
|
|
if (isJwt) {
|
|
_getLoginIdFromJwt(token);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
if (closeDialog && Navigator.canPop(context)) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
_onLoginSuccess(token, provider: provider);
|
|
}
|
|
|
|
Future<bool> _hasValidLocalSession() async {
|
|
final token = AuthTokenStore.getToken();
|
|
final usesCookie = AuthTokenStore.usesCookie();
|
|
if (token == null && !usesCookie) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
final status = await AuthProxyService.getSessionStatus(
|
|
token: token,
|
|
useCookie: usesCookie,
|
|
);
|
|
if (status == 200) {
|
|
return true;
|
|
}
|
|
if (status == 401 || status == 403) {
|
|
AuthTokenStore.clear();
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] 세션 확인 실패: $e");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool _moveVerificationOnlyResultToCleanRoute() {
|
|
if (!_verificationOnly || widget.verificationCompleteOnly) {
|
|
return false;
|
|
}
|
|
final localeCode =
|
|
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
|
|
final target = buildLocalizedVerificationCompletePath(localeCode);
|
|
if (mounted) {
|
|
context.go(target);
|
|
} else {
|
|
webWindow.redirectTo(target);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void _markVerificationApproved(
|
|
String messageKey, {
|
|
String? titleKey,
|
|
String? pageTitleKey,
|
|
String? actionLabelKey,
|
|
String actionPath = '/',
|
|
bool autoRedirect = false,
|
|
Duration redirectDelay = const Duration(seconds: 2),
|
|
VoidCallback? onAction,
|
|
}) {
|
|
if (!mounted) return;
|
|
if (_moveVerificationOnlyResultToCleanRoute()) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_verificationApproved = true;
|
|
_verificationMessageKey = messageKey;
|
|
_verificationTitleKey =
|
|
titleKey ?? 'ui.userfront.login.verification.title';
|
|
_verificationPageTitleKey =
|
|
pageTitleKey ?? 'ui.userfront.login.verification.page_title';
|
|
_verificationActionLabelKey =
|
|
actionLabelKey ?? 'ui.userfront.login.verification.action_label';
|
|
_onVerificationAction = onAction;
|
|
});
|
|
_verificationRedirectTimer?.cancel();
|
|
if (autoRedirect) {
|
|
_verificationRedirectTimer = Timer(redirectDelay, () {
|
|
if (!mounted) return;
|
|
if (onAction != null) {
|
|
onAction();
|
|
} else {
|
|
context.go(actionPath);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
VoidCallback? _onVerificationAction;
|
|
|
|
void _runVerificationExitAction() {
|
|
_onVerificationAction?.call();
|
|
}
|
|
|
|
void _closeVerificationWindowIfPossible() {
|
|
webWindow.close();
|
|
}
|
|
|
|
void _moveToSigninOrCloseVerificationWindow() {
|
|
if (webWindow.hasOpener()) {
|
|
webWindow.close();
|
|
return;
|
|
}
|
|
AuthTokenStore.skipNextCookieSessionCheck();
|
|
context.go(buildLocalizedSigninPath(Uri.base));
|
|
}
|
|
|
|
void _handleVerificationResultPrimaryAction() {
|
|
if (_onVerificationAction != null) {
|
|
_runVerificationExitAction();
|
|
return;
|
|
}
|
|
if (_verificationOnly) {
|
|
_closeVerificationWindowIfPossible();
|
|
return;
|
|
}
|
|
final hasLocalSession =
|
|
(AuthTokenStore.getToken()?.isNotEmpty ?? false) ||
|
|
AuthTokenStore.usesCookie();
|
|
final target = hasLocalSession
|
|
? buildLocalizedHomePath(Uri.base)
|
|
: buildLocalizedSigninPath(Uri.base);
|
|
if (mounted) {
|
|
setState(() {
|
|
_verificationOnly = false;
|
|
_verificationApproved = false;
|
|
});
|
|
}
|
|
context.go(target);
|
|
}
|
|
|
|
void _markRemoteVerificationApproved() {
|
|
_markVerificationApproved(
|
|
'msg.userfront.login.verification.approved_remote',
|
|
titleKey: 'ui.userfront.login.verification.title_remote',
|
|
actionLabelKey: 'ui.userfront.login.verification.action_label_remote',
|
|
onAction: _moveToSigninOrCloseVerificationWindow,
|
|
);
|
|
}
|
|
|
|
Widget _buildVerificationResultView() {
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
final accentColor = colorScheme.brightness == Brightness.dark
|
|
? const Color(0xFF93C5FD)
|
|
: const Color(0xFF1E3A8A);
|
|
final message = tr(
|
|
_verificationMessageKey ?? 'msg.userfront.login.verification.success',
|
|
);
|
|
final verificationTitle = tr(_verificationTitleKey);
|
|
final closeHint = tr('msg.userfront.login.verification.close_hint');
|
|
final showCloseHint =
|
|
_verificationActionLabelKey ==
|
|
'ui.userfront.login.verification.action_label_close';
|
|
|
|
return SafeArea(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 468),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isCompact = constraints.maxWidth < 360;
|
|
final iconBoxSize = isCompact ? 76.0 : 88.0;
|
|
final iconSize = isCompact ? 48.0 : 56.0;
|
|
final verticalGap = isCompact ? 16.0 : 20.0;
|
|
final controlsOffset = isCompact ? 150.0 : 170.0;
|
|
final cardRadius = isCompact ? 24.0 : 28.0;
|
|
final cardPadding = isCompact
|
|
? const EdgeInsets.fromLTRB(20, 24, 20, 22)
|
|
: const EdgeInsets.fromLTRB(30, 34, 30, 28);
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(height: controlsOffset),
|
|
DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(cardRadius),
|
|
border: Border.all(
|
|
color: colorScheme.outlineVariant.withValues(
|
|
alpha: 0.7,
|
|
),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: colorScheme.shadow.withValues(alpha: 0.08),
|
|
blurRadius: 28,
|
|
offset: const Offset(0, 16),
|
|
),
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: cardPadding,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: iconBoxSize,
|
|
height: iconBoxSize,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
colorScheme.primary.withValues(alpha: 0.18),
|
|
colorScheme.tertiary.withValues(
|
|
alpha: 0.14,
|
|
),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Icon(
|
|
Icons.person,
|
|
size: iconSize,
|
|
color: colorScheme.primary,
|
|
),
|
|
),
|
|
SizedBox(height: verticalGap),
|
|
Text(
|
|
verificationTitle,
|
|
textAlign: TextAlign.center,
|
|
softWrap: true,
|
|
style: TextStyle(
|
|
fontSize: isCompact ? 22 : 26,
|
|
fontWeight: FontWeight.w800,
|
|
color: accentColor,
|
|
letterSpacing: -0.4,
|
|
height: 1.25,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
softWrap: true,
|
|
style:
|
|
(isCompact
|
|
? theme.textTheme.bodyMedium
|
|
: theme.textTheme.bodyLarge)
|
|
?.copyWith(
|
|
height: 1.6,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
minWidth: 180,
|
|
maxWidth: 320,
|
|
),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed:
|
|
_handleVerificationResultPrimaryAction,
|
|
child: Text(
|
|
tr(_verificationActionLabelKey),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (showCloseHint) ...[
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
closeHint,
|
|
textAlign: TextAlign.center,
|
|
softWrap: true,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
height: 1.5,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
const Wrap(
|
|
alignment: WrapAlignment.center,
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: [ThemeToggleButton(), LanguageSelector()],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVerificationPendingView() {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const SizedBox(
|
|
width: 48,
|
|
height: 48,
|
|
child: CircularProgressIndicator(strokeWidth: 4),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
tr('ui.userfront.login.verification.title_pending'),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
tr('msg.userfront.login.verification.pending_remote'),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Colors.black54),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVerificationOnlyScaffold() {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
automaticallyImplyLeading: false,
|
|
title: Text(tr(_verificationPageTitleKey)),
|
|
leading: _verificationApproved && _onVerificationAction != null
|
|
? IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: _runVerificationExitAction,
|
|
)
|
|
: null,
|
|
actions: const [ThemeToggleButton(compact: true)],
|
|
),
|
|
body: _verificationApproved
|
|
? _buildVerificationResultView()
|
|
: _buildVerificationPendingView(),
|
|
);
|
|
}
|
|
|
|
Future<void> _verifyToken(String token) async {
|
|
debugPrint("[Auth] Starting verification for token: $token");
|
|
try {
|
|
// Use Backend to verify the token (Backend-Driven Flow)
|
|
final res = await AuthProxyService.verifyMagicLink(
|
|
token,
|
|
verifyOnly: _verificationOnly,
|
|
);
|
|
debugPrint("[Auth] Verification successful for token: $token");
|
|
final jwt = res['token'] ?? res['sessionJwt'] ?? res['sessionToken'];
|
|
final status = res['status']?.toString();
|
|
final hasLocalSession = await _hasValidLocalSession();
|
|
final actionPath = hasLocalSession
|
|
? buildLocalizedHomePath(Uri.base)
|
|
: buildLocalizedSigninPath(Uri.base);
|
|
|
|
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
|
if (mounted) {
|
|
_markRemoteVerificationApproved();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (jwt is String && jwt.isNotEmpty) {
|
|
if (_verificationOnly) {
|
|
_markRemoteVerificationApproved();
|
|
return;
|
|
}
|
|
if (hasLocalSession) {
|
|
_markVerificationApproved(
|
|
'msg.userfront.login.verification.approved_local',
|
|
actionPath: actionPath,
|
|
);
|
|
return;
|
|
}
|
|
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
|
return;
|
|
}
|
|
|
|
if (mounted) {
|
|
_markVerificationApproved(
|
|
'msg.userfront.login.verification.approved',
|
|
actionPath: actionPath,
|
|
);
|
|
}
|
|
} 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) {
|
|
_markRemoteVerificationApproved();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mounted) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.verification_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _verifyLoginCode(
|
|
String loginId,
|
|
String code, {
|
|
String? pendingRef,
|
|
}) async {
|
|
final sanitizedLoginId = loginId.replaceAll(' ', '+');
|
|
debugPrint(
|
|
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
|
|
);
|
|
try {
|
|
final res = await AuthProxyService.verifyLoginCode(
|
|
sanitizedLoginId,
|
|
code,
|
|
pendingRef: pendingRef,
|
|
verifyOnly: _verificationOnly,
|
|
);
|
|
final jwt = res['sessionJwt'] ?? res['sessionToken'] ?? res['token'];
|
|
final status = res['status']?.toString();
|
|
debugPrint(
|
|
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
|
|
);
|
|
final hasLocalSession = await _hasValidLocalSession();
|
|
final actionPath = hasLocalSession
|
|
? buildLocalizedHomePath(Uri.base)
|
|
: buildLocalizedSigninPath(Uri.base);
|
|
|
|
if (jwt == null && status == 'approved') {
|
|
if (mounted) {
|
|
_markRemoteVerificationApproved();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (jwt is String && jwt.isNotEmpty) {
|
|
if (hasLocalSession) {
|
|
_markVerificationApproved(
|
|
'msg.userfront.login.verification.approved_local',
|
|
actionPath: actionPath,
|
|
);
|
|
return;
|
|
}
|
|
if (_verificationOnly) {
|
|
_markRemoteVerificationApproved();
|
|
return;
|
|
}
|
|
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
|
return;
|
|
}
|
|
|
|
if (_verificationOnly && mounted) {
|
|
_markRemoteVerificationApproved();
|
|
}
|
|
} 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) {
|
|
_markRemoteVerificationApproved();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mounted) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.verification_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _verifyShortCode(String shortCode) async {
|
|
final sanitized = shortCode.trim().toUpperCase();
|
|
if (sanitized.isEmpty) return;
|
|
debugPrint("[Auth] Starting short code verification for code: $sanitized");
|
|
try {
|
|
final res = await AuthProxyService.verifyLoginShortCode(
|
|
sanitized,
|
|
verifyOnly: _verificationOnly,
|
|
);
|
|
final jwt = res['sessionJwt'] ?? res['sessionToken'] ?? res['token'];
|
|
final status = res['status']?.toString();
|
|
debugPrint("[Auth] Short code verification successful");
|
|
final hasLocalSession = await _hasValidLocalSession();
|
|
final actionPath = hasLocalSession
|
|
? buildLocalizedHomePath(Uri.base)
|
|
: buildLocalizedSigninPath(Uri.base);
|
|
|
|
if (jwt == null && status == 'approved') {
|
|
if (mounted) {
|
|
_markRemoteVerificationApproved();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (jwt is String && jwt.isNotEmpty) {
|
|
if (hasLocalSession) {
|
|
_markVerificationApproved(
|
|
'msg.userfront.login.verification.approved_local',
|
|
actionPath: actionPath,
|
|
);
|
|
return;
|
|
}
|
|
if (_verificationOnly) {
|
|
_markRemoteVerificationApproved();
|
|
return;
|
|
}
|
|
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
|
return;
|
|
}
|
|
|
|
if (_verificationOnly && mounted) {
|
|
_markRemoteVerificationApproved();
|
|
}
|
|
} 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) {
|
|
_markRemoteVerificationApproved();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mounted) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.verification_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stopQrPolling();
|
|
_verificationRedirectTimer?.cancel();
|
|
_tabController.dispose();
|
|
_linkIdController.dispose();
|
|
_passwordLoginIdController.dispose();
|
|
_passwordController.dispose();
|
|
_passwordFocusNode
|
|
..removeListener(_handlePasswordFocusChange)
|
|
..dispose();
|
|
HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent);
|
|
_shortCodePrefixController.dispose();
|
|
_shortCodeDigitsController.dispose();
|
|
_linkResendTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _handlePasswordLogin() async {
|
|
final input = _passwordLoginIdController.text.trim();
|
|
final password = _passwordController.text.trim();
|
|
if (input.isEmpty || password.isEmpty) {
|
|
_showError(tr('msg.userfront.login.password.missing_credentials'));
|
|
return;
|
|
}
|
|
|
|
String loginId = input;
|
|
if (!input.contains('@')) {
|
|
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
|
|
if (loginId.startsWith('010')) {
|
|
loginId = '+82${loginId.substring(1)}';
|
|
}
|
|
}
|
|
|
|
try {
|
|
final res = await AuthProxyService.loginWithPassword(
|
|
loginId,
|
|
password,
|
|
loginChallenge: _loginChallenge,
|
|
);
|
|
|
|
final jwt = res['sessionJwt'] ?? res['sessionToken'] ?? res['token'];
|
|
final provider = res['provider'] as String?;
|
|
final redirectTo = res['redirectTo'] as String?;
|
|
|
|
if (jwt != null) {
|
|
_onLoginSuccess(jwt, provider: provider, redirectTo: redirectTo);
|
|
} else if (redirectTo != null && redirectTo.isNotEmpty) {
|
|
webWindow.redirectTo(redirectTo);
|
|
} else {}
|
|
} catch (e) {
|
|
final errorMessage = e.toString().replaceFirst('Exception: ', '');
|
|
try {
|
|
await AuthProxyService.logError(
|
|
'[PasswordLogin] $errorMessage',
|
|
error: e,
|
|
);
|
|
} catch (_) {
|
|
// Ignore client-log relay failures and continue with user feedback.
|
|
}
|
|
if (e.toString().contains("User not registered")) {
|
|
_showUnregisteredDialog();
|
|
} else {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.password.failed',
|
|
params: {'error': errorMessage},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _handleLinkLogin() async {
|
|
final input = _linkIdController.text.trim();
|
|
if (input.isEmpty) return;
|
|
|
|
String loginId = input;
|
|
if (!input.contains('@')) {
|
|
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
|
|
if (loginId.startsWith('010')) {
|
|
loginId = '+82${loginId.substring(1)}';
|
|
}
|
|
}
|
|
|
|
debugPrint("[Auth] Initiating Enchanted Link for: $loginId");
|
|
|
|
try {
|
|
await _startEnchantedFlow(loginId, isEmail: input.contains('@'));
|
|
} catch (e) {
|
|
if (e.toString().contains("User not registered")) {
|
|
_showUnregisteredDialog();
|
|
} else {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.link_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _startEnchantedFlow(
|
|
String loginId, {
|
|
required bool isEmail,
|
|
bool codeOnly = false,
|
|
}) async {
|
|
try {
|
|
final initResponse = await AuthProxyService.initEnchantedLink(
|
|
loginId,
|
|
codeOnly: codeOnly,
|
|
drySend: _drySendEnabled,
|
|
);
|
|
final pendingRef = initResponse['pendingRef'];
|
|
final mode = (initResponse['mode'] ?? '').toString();
|
|
final provider = (initResponse['provider'] ?? '').toString();
|
|
final interval = initResponse['interval'];
|
|
final resendAfter = initResponse['resendAfter'];
|
|
final expiresIn = initResponse['expiresIn'];
|
|
debugPrint(
|
|
"[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider",
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_linkPendingRef = pendingRef?.toString();
|
|
_lastLinkLoginId = loginId;
|
|
_lastLinkIsEmail = isEmail;
|
|
_linkExpired = false;
|
|
});
|
|
_dismissOverlays();
|
|
_showInfo(
|
|
isEmail
|
|
? tr('msg.userfront.login.link_sent_email')
|
|
: tr('msg.userfront.login.link_sent_phone'),
|
|
);
|
|
|
|
final initialInterval = (interval is int && interval > 0)
|
|
? Duration(seconds: interval)
|
|
: const Duration(seconds: 2);
|
|
if (resendAfter is int && resendAfter > 0) {
|
|
_startLinkResendTimer(resendAfter);
|
|
}
|
|
if (expiresIn is int && expiresIn > 0) {
|
|
_startLinkExpireTimer(expiresIn);
|
|
}
|
|
_pollForSession(pendingRef, initialInterval: initialInterval);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] Initialization failed: $e");
|
|
if (mounted) {
|
|
setState(_resetLinkLoginState);
|
|
}
|
|
if (e.toString().contains("User not registered")) {
|
|
_showUnregisteredDialog();
|
|
} else {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.link_send_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _pollForSession(
|
|
String pendingRef, {
|
|
Duration? initialInterval,
|
|
}) async {
|
|
int attempts = 0;
|
|
const maxAttempts = 60;
|
|
var pollInterval = initialInterval ?? const Duration(seconds: 2);
|
|
debugPrint("[Auth] Starting poll for ref: $pendingRef");
|
|
|
|
while (attempts < maxAttempts && mounted) {
|
|
if (_linkPendingRef != pendingRef) {
|
|
return;
|
|
}
|
|
await Future.delayed(pollInterval);
|
|
attempts++;
|
|
|
|
try {
|
|
final result = await AuthProxyService.pollEnchantedLink(pendingRef);
|
|
|
|
if (result['error'] == 'slow_down') {
|
|
final interval = result['interval'];
|
|
if (interval is int && interval > 0) {
|
|
pollInterval = Duration(seconds: interval);
|
|
} else {
|
|
pollInterval += const Duration(seconds: 1);
|
|
}
|
|
continue;
|
|
}
|
|
if (result['error'] == 'authorization_pending') {
|
|
continue;
|
|
}
|
|
if (result['error'] == 'expired_token') {
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
_showError(tr('msg.userfront.login.link_timeout'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (result['status'] == 'ok') {
|
|
final token =
|
|
result['sessionJwt'] ?? result['sessionToken'] ?? result['token'];
|
|
if (token is String && token.isNotEmpty) {
|
|
debugPrint("[Auth] Polling SUCCESS. Token received.");
|
|
_completeLoginFromToken(
|
|
token,
|
|
provider: result['provider'] as String?,
|
|
closeDialog: true,
|
|
);
|
|
return;
|
|
}
|
|
debugPrint("[Auth] Polling SUCCESS but token missing.");
|
|
if (mounted && Navigator.canPop(context)) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
_showError(tr('msg.userfront.login.token_missing'));
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] Polling error (attempt $attempts): $e");
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
|
Navigator.of(context).pop();
|
|
_showError(tr('msg.userfront.login.link_timeout'));
|
|
}
|
|
}
|
|
|
|
void _showError(String message) {
|
|
if (!mounted) return;
|
|
ToastService.error(message);
|
|
try {
|
|
AuthProxyService.logError(message);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
void _showInfo(String message) {
|
|
if (!mounted) return;
|
|
ToastService.success(message);
|
|
}
|
|
|
|
void _logTokenDetails(String jwt) {
|
|
try {
|
|
final parts = jwt.split('.');
|
|
if (parts.length != 3) return;
|
|
|
|
final decodedPayload = base64Url.decode(base64Url.normalize(parts[1]));
|
|
final payloadJson = utf8.decode(decodedPayload);
|
|
final data = json.decode(payloadJson) as Map<String, dynamic>;
|
|
|
|
final accessExpValue = data['exp'] as num?;
|
|
final accessExp = accessExpValue != null
|
|
? DateTime.fromMillisecondsSinceEpoch(accessExpValue.toInt() * 1000)
|
|
: 'N/A';
|
|
final refreshExp = data['rexp'] ?? 'N/A';
|
|
|
|
debugPrint("""
|
|
[Auth] Session Token Details ---
|
|
- Access Token Expires: $accessExp
|
|
- Refresh Token Expires: $refreshExp
|
|
""");
|
|
} catch (e) {
|
|
debugPrint("[Auth] Failed to decode or log token details: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _onLoginSuccess(
|
|
String token, {
|
|
String? provider,
|
|
String? redirectTo,
|
|
}) async {
|
|
try {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
// [Priority 1] Immediate External Redirection
|
|
if (redirectTo != null && redirectTo.isNotEmpty) {
|
|
try {
|
|
final providerName = provider ?? AuthTokenStore.getProvider();
|
|
AuthTokenStore.setToken(token, provider: providerName);
|
|
} catch (stErr) {
|
|
// ignore
|
|
}
|
|
|
|
webWindow.redirectTo(redirectTo); // Removed await as it's void
|
|
return;
|
|
}
|
|
|
|
// [Priority 2] OIDC Challenge Handling
|
|
final loginChallenge = _loginChallenge;
|
|
if (loginChallenge != null && loginChallenge.isNotEmpty) {
|
|
try {
|
|
// Save token first, it's needed for acceptance
|
|
final providerName = provider ?? AuthTokenStore.getProvider();
|
|
AuthTokenStore.setToken(token, provider: providerName);
|
|
|
|
final res = await AuthProxyService.acceptOidcLogin(
|
|
loginChallenge,
|
|
token: token,
|
|
);
|
|
|
|
// IMPORTANT: If backend returned a token during OIDC flow, save it to fix login state.
|
|
final jwt = res['sessionJwt'] ?? res['token'] ?? token;
|
|
if (jwt != null && jwt.isNotEmpty) {
|
|
await AuthNotifier.instance.onLoginSuccess(
|
|
jwt,
|
|
provider: res['provider'] as String? ?? providerName,
|
|
);
|
|
}
|
|
|
|
final nextRedirectTo = res['redirectTo'] as String?;
|
|
|
|
if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) {
|
|
loginChallengeLoopGuard.clear(loginChallenge);
|
|
// Give 50ms delay for localStorage to settle
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
webWindow.redirectTo(nextRedirectTo); // Removed await
|
|
return;
|
|
} else {}
|
|
} catch (e) {
|
|
_showError(tr('msg.userfront.login.oidc_failed'));
|
|
return;
|
|
}
|
|
}
|
|
|
|
_logTokenDetails(token);
|
|
|
|
final providerName = provider ?? AuthTokenStore.getProvider();
|
|
|
|
AuthTokenStore.setToken(token, provider: providerName);
|
|
AuthTokenStore.clearPendingProvider();
|
|
_dismissOverlays();
|
|
|
|
try {
|
|
await ref.read(profileProvider.notifier).loadProfile();
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
|
|
final uri = Uri.base;
|
|
final redirectParam =
|
|
uri.queryParameters['redirect_uri'] ??
|
|
uri.queryParameters['redirect_url'];
|
|
final hasRedirectParam =
|
|
redirectParam != null && redirectParam.isNotEmpty;
|
|
|
|
if (WebAuthIntegration.isPopup() || hasRedirectParam) {
|
|
WebAuthIntegration.sendLoginSuccess(token);
|
|
AuthNotifier.instance.notify();
|
|
return;
|
|
}
|
|
|
|
if (mounted) {
|
|
_goLocalizedHomeOnce();
|
|
}
|
|
} catch (globalErr) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
void _showUnregisteredDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(tr('ui.userfront.login.unregistered.title')),
|
|
content: Text(tr('msg.userfront.login.unregistered.body')),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(tr('ui.common.cancel')),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_resetLinkLoginState();
|
|
context.push('/signup');
|
|
},
|
|
child: Text(tr('ui.userfront.login.unregistered.action')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _capsLockWarningText(BuildContext context) {
|
|
const key = 'msg.userfront.login.password.caps_lock_on';
|
|
final languageCode = Localizations.localeOf(context).languageCode;
|
|
if (languageCode == 'ko') {
|
|
final translated = tr(key);
|
|
if (translated != key) {
|
|
return translated;
|
|
}
|
|
return 'Caps Lock이 켜져 있습니다.';
|
|
}
|
|
|
|
final translated = tr(key, fallback: 'Caps Lock is on.');
|
|
if (translated != key) {
|
|
return translated;
|
|
}
|
|
return 'Caps Lock is on.';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
final mutedColor = colorScheme.onSurfaceVariant;
|
|
final inputForegroundColor = colorScheme.brightness == Brightness.dark
|
|
? const Color(0xFFE2E8F0)
|
|
: const Color(0xFF334155);
|
|
final primaryColor = colorScheme.brightness == Brightness.dark
|
|
? const Color(0xFF93C5FD)
|
|
: const Color(0xFF1E3A8A);
|
|
final onPrimaryColor = colorScheme.brightness == Brightness.dark
|
|
? const Color(0xFF0F172A)
|
|
: Colors.white;
|
|
final inputDecorationTheme = theme.inputDecorationTheme.copyWith(
|
|
filled: false,
|
|
fillColor: Colors.transparent,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18),
|
|
isDense: true,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: colorScheme.outline),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: colorScheme.outline),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: primaryColor, width: 1.6),
|
|
),
|
|
labelStyle: TextStyle(color: inputForegroundColor),
|
|
floatingLabelStyle: TextStyle(color: primaryColor),
|
|
hintStyle: TextStyle(color: inputForegroundColor),
|
|
prefixIconColor: inputForegroundColor,
|
|
);
|
|
final localTheme = theme.copyWith(
|
|
inputDecorationTheme: inputDecorationTheme,
|
|
tabBarTheme: theme.tabBarTheme.copyWith(
|
|
dividerColor: colorScheme.outlineVariant,
|
|
indicatorColor: primaryColor,
|
|
labelColor: colorScheme.onSurface,
|
|
unselectedLabelColor: mutedColor,
|
|
labelStyle: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
unselectedLabelStyle: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
filledButtonTheme: FilledButtonThemeData(
|
|
style: FilledButton.styleFrom(
|
|
minimumSize: const Size.fromHeight(48),
|
|
backgroundColor: primaryColor,
|
|
foregroundColor: onPrimaryColor,
|
|
textStyle: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
),
|
|
textButtonTheme: TextButtonThemeData(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: primaryColor,
|
|
textStyle: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (_verificationOnly) {
|
|
return _buildVerificationOnlyScaffold();
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
|
body: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return Theme(
|
|
data: localTheme,
|
|
child: SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
|
child: Center(
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 480),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 28,
|
|
vertical: 40,
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
tr('ui.userfront.app_title'),
|
|
style: theme.textTheme.headlineMedium?.copyWith(
|
|
fontSize: 34,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
if (_drySendEnabled) ...[
|
|
const SizedBox(height: 20),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFFF3CD),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: const Color(0xFFFFC107),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.warning_amber_rounded,
|
|
color: Color(0xFF8A6D3B),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
tr('msg.userfront.login.dry_send'),
|
|
style: const TextStyle(
|
|
color: Color(0xFF8A6D3B),
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 52),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 34),
|
|
child: TabBar(
|
|
controller: _tabController,
|
|
indicatorSize: TabBarIndicatorSize.label,
|
|
tabs: [
|
|
Tab(text: tr('ui.userfront.login.tabs.password')),
|
|
Tab(text: tr('ui.userfront.login.tabs.link')),
|
|
Tab(text: tr('ui.userfront.login.tabs.qr')),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 28),
|
|
SizedBox(
|
|
height: 360,
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 20),
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
maxWidth: 356,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
TextField(
|
|
key: const ValueKey(
|
|
'password_login_id_input',
|
|
),
|
|
controller:
|
|
_passwordLoginIdController,
|
|
decoration: InputDecoration(
|
|
labelText:
|
|
_loginIdLabel ??
|
|
tr(
|
|
'ui.userfront.login.field.login_id',
|
|
),
|
|
prefixIcon: const Icon(
|
|
Icons.person_outline,
|
|
size: 22,
|
|
),
|
|
),
|
|
onSubmitted: (_) =>
|
|
_handlePasswordLogin(),
|
|
),
|
|
const SizedBox(height: 18),
|
|
TextField(
|
|
key: const ValueKey(
|
|
'password_login_password_input',
|
|
),
|
|
focusNode: _passwordFocusNode,
|
|
controller: _passwordController,
|
|
obscureText: true,
|
|
decoration: InputDecoration(
|
|
labelText: tr(
|
|
'ui.userfront.login.field.password',
|
|
),
|
|
prefixIcon: const Icon(
|
|
Icons.lock_outline,
|
|
size: 22,
|
|
),
|
|
),
|
|
onSubmitted: (_) =>
|
|
_handlePasswordLogin(),
|
|
),
|
|
if (_isPasswordCapsLockOn) ...[
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.keyboard_capslock_rounded,
|
|
size: 18,
|
|
color: Colors.orange,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
_capsLockWarningText(context),
|
|
style: const TextStyle(
|
|
color: Colors.orange,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 28),
|
|
FilledButton(
|
|
key: const ValueKey(
|
|
'password_login_submit_button',
|
|
),
|
|
onPressed: _handlePasswordLogin,
|
|
child: Text(
|
|
tr(
|
|
'ui.userfront.login.action.submit',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 20),
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
maxWidth: 356,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
if (_linkPendingRef == null) ...[
|
|
TextField(
|
|
controller: _linkIdController,
|
|
decoration: InputDecoration(
|
|
labelText: tr(
|
|
'ui.userfront.login.field.login_id',
|
|
),
|
|
hintText: '',
|
|
prefixIcon: const Icon(
|
|
Icons.person_outline,
|
|
size: 22,
|
|
),
|
|
),
|
|
onSubmitted: (_) =>
|
|
_handleLinkLogin(),
|
|
),
|
|
const SizedBox(height: 28),
|
|
FilledButton(
|
|
onPressed: _handleLinkLogin,
|
|
child: Text(
|
|
tr(
|
|
'ui.userfront.login.link.send',
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
tr(
|
|
'msg.userfront.login.link.helper',
|
|
),
|
|
style: TextStyle(
|
|
color: mutedColor,
|
|
fontSize: 12,
|
|
height: 1.5,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
if (_linkPendingRef != null) ...[
|
|
if (_linkExpired) ...[
|
|
Text(
|
|
tr(
|
|
'msg.userfront.login.link_timeout',
|
|
),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: mutedColor,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
FilledButton(
|
|
onPressed: () {
|
|
setState(_resetLinkLoginState);
|
|
},
|
|
child: Text(
|
|
tr('ui.common.refresh'),
|
|
),
|
|
),
|
|
] else ...[
|
|
Text(
|
|
tr(
|
|
'msg.userfront.login.link.short_code_help',
|
|
),
|
|
style: TextStyle(
|
|
color: mutedColor,
|
|
fontSize: 12,
|
|
height: 1.5,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextField(
|
|
controller:
|
|
_shortCodePrefixController,
|
|
textCapitalization:
|
|
TextCapitalization
|
|
.characters,
|
|
decoration: InputDecoration(
|
|
labelText: tr(
|
|
'ui.userfront.login.short_code.prefix',
|
|
),
|
|
hintText: 'AB',
|
|
hintStyle: TextStyle(
|
|
color: mutedColor,
|
|
),
|
|
counterText: '',
|
|
),
|
|
maxLength: 2,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
flex: 4,
|
|
child: TextField(
|
|
controller:
|
|
_shortCodeDigitsController,
|
|
keyboardType:
|
|
TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: tr(
|
|
'ui.userfront.login.short_code.digits',
|
|
),
|
|
hintText: '345678',
|
|
hintStyle: TextStyle(
|
|
color: mutedColor,
|
|
),
|
|
counterText: '',
|
|
suffixText:
|
|
_linkExpireSeconds > 0
|
|
? tr(
|
|
'ui.userfront.login.short_code.expire_time',
|
|
params: {
|
|
'time': _formatTime(
|
|
_linkExpireSeconds,
|
|
),
|
|
},
|
|
)
|
|
: null,
|
|
),
|
|
maxLength: 6,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final prefix =
|
|
_shortCodePrefixController
|
|
.text
|
|
.trim()
|
|
.toUpperCase();
|
|
final digits =
|
|
_shortCodeDigitsController
|
|
.text
|
|
.trim();
|
|
if (prefix.length != 2 ||
|
|
digits.length != 6) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.short_code.invalid',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
_verifyShortCode(
|
|
prefix + digits,
|
|
);
|
|
},
|
|
child: Text(
|
|
tr(
|
|
'ui.userfront.login.short_code.submit',
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
TextButton(
|
|
onPressed: () {
|
|
if (_linkResendSeconds > 0) {
|
|
_showInfo(
|
|
tr(
|
|
'msg.userfront.login.link.resend_wait',
|
|
params: {
|
|
'time': _formatTime(
|
|
_linkResendSeconds,
|
|
),
|
|
},
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
final loginId =
|
|
_lastLinkLoginId ??
|
|
_linkIdController.text
|
|
.trim();
|
|
if (loginId.isEmpty) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.link.missing_login_id',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
_startEnchantedFlow(
|
|
loginId,
|
|
isEmail:
|
|
_lastLinkIsEmail ||
|
|
loginId.contains('@'),
|
|
codeOnly: false,
|
|
);
|
|
},
|
|
child: Text(
|
|
_linkResendSeconds > 0
|
|
? tr(
|
|
'ui.userfront.login.link.resend_with_time',
|
|
params: {
|
|
'time': _formatTime(
|
|
_linkResendSeconds,
|
|
),
|
|
},
|
|
)
|
|
: tr('ui.common.resend'),
|
|
),
|
|
),
|
|
if (!_lastLinkIsEmail) ...[
|
|
const SizedBox(height: 4),
|
|
TextButton(
|
|
onPressed: () {
|
|
if (_linkResendSeconds > 0) {
|
|
_showInfo(
|
|
tr(
|
|
'msg.userfront.login.link.resend_wait',
|
|
params: {
|
|
'time': _formatTime(
|
|
_linkResendSeconds,
|
|
),
|
|
},
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
final loginId =
|
|
_lastLinkLoginId ??
|
|
_linkIdController.text
|
|
.trim();
|
|
if (loginId.isEmpty) {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.login.link.missing_phone',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
_startEnchantedFlow(
|
|
loginId,
|
|
isEmail: false,
|
|
codeOnly: true,
|
|
);
|
|
},
|
|
child: Text(
|
|
tr(
|
|
'ui.userfront.login.link.code_only',
|
|
params: {
|
|
'time': _formatTime(
|
|
_linkResendSeconds,
|
|
),
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (_isQrLoading)
|
|
const CircularProgressIndicator()
|
|
else if (_qrExpired)
|
|
Column(
|
|
children: [
|
|
Text(
|
|
tr('msg.userfront.login.qr_expired'),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: mutedColor,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
FilledButton(
|
|
onPressed: _startQrFlow,
|
|
child: Text(tr('ui.common.refresh')),
|
|
),
|
|
],
|
|
)
|
|
else if (_qrImageBase64 != null)
|
|
Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: colorScheme.outline,
|
|
),
|
|
borderRadius: BorderRadius.circular(
|
|
18,
|
|
),
|
|
),
|
|
child: QrImageView(
|
|
data: _qrImageBase64!,
|
|
version: QrVersions.auto,
|
|
size: 200.0,
|
|
backgroundColor: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
_qrRemainingSeconds > 0
|
|
? tr(
|
|
'ui.userfront.login.qr.remaining',
|
|
params: {
|
|
'time': _formatTime(
|
|
_qrRemainingSeconds,
|
|
),
|
|
},
|
|
)
|
|
: tr(
|
|
'ui.userfront.login.qr.expired',
|
|
),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: _qrRemainingSeconds > 30
|
|
? primaryColor
|
|
: Colors.red,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
tr(
|
|
'msg.userfront.login.qr.scan_hint',
|
|
),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: mutedColor,
|
|
fontSize: 12,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: _startQrFlow,
|
|
child: Text(
|
|
tr('ui.userfront.login.qr.refresh'),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Text(
|
|
tr('msg.userfront.login.qr.load_failed'),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Column(
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => context.push('/forgot-password'),
|
|
child: Text(
|
|
tr('ui.userfront.login.forgot_password'),
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
tr('msg.userfront.login.no_account'),
|
|
style: TextStyle(
|
|
color: mutedColor,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => context.push('/signup'),
|
|
child: Text(tr('ui.userfront.login.signup')),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
const Wrap(
|
|
alignment: WrapAlignment.center,
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: [ThemeToggleButton(), LanguageSelector()],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|