forked from baron/baron-sso
1336 lines
49 KiB
Dart
1336 lines
49 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.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/web_auth_integration.dart';
|
|
import '../../../core/services/auth_proxy_service.dart';
|
|
import '../../../core/services/auth_token_store.dart';
|
|
import '../../../core/notifiers/auth_notifier.dart';
|
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
|
import '../../../core/services/web_window.dart';
|
|
|
|
class LoginScreen extends ConsumerStatefulWidget {
|
|
final String? verificationToken;
|
|
final String? loginChallenge;
|
|
|
|
const LoginScreen({super.key, this.verificationToken, this.loginChallenge});
|
|
|
|
@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();
|
|
String? _redirectUrl;
|
|
String? _loginChallenge;
|
|
|
|
// QR Login Variables
|
|
String? _qrImageBase64;
|
|
String? _qrPendingRef;
|
|
bool _isQrLoading = 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 _verificationOnly = false;
|
|
bool _verificationApproved = false;
|
|
String _verificationMessage = '';
|
|
String _verificationTitle = '승인 완료';
|
|
String _verificationPageTitle = '로그인 승인';
|
|
String _verificationActionLabel = '확인';
|
|
String _verificationActionPath = '/';
|
|
Timer? _verificationRedirectTimer;
|
|
bool _noticeHandled = false;
|
|
bool _drySendEnabled = false;
|
|
bool _oidcAutoAcceptTried = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
|
_tabController.addListener(_handleTabSelection);
|
|
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
final uri = Uri.base;
|
|
_loginChallenge = widget.loginChallenge ?? uri.queryParameters['login_challenge'];
|
|
final loginIdParam = uri.queryParameters['loginId'];
|
|
final codeParam = uri.queryParameters['code'];
|
|
final pendingRefParam = uri.queryParameters['pendingRef'];
|
|
final hasShortCodePath = uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
|
|
final hasTokenParam = uri.queryParameters.containsKey('t');
|
|
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
|
|
final hasLoginCode = loginIdParam != null && codeParam != null;
|
|
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
|
|
final notice = uri.queryParameters['notice'];
|
|
|
|
if (hasShortCodePath) {
|
|
final shortCode = uri.pathSegments[1];
|
|
_verifyShortCode(shortCode);
|
|
}
|
|
if (hasLoginCode) {
|
|
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
|
} else if (hasVerificationToken) {
|
|
_verifyToken(widget.verificationToken ?? uri.queryParameters['t']!);
|
|
}
|
|
|
|
if (!_noticeHandled && notice == 'qr_login_required') {
|
|
_noticeHandled = true;
|
|
_showInfo('로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다');
|
|
}
|
|
|
|
if (!_verificationOnly) {
|
|
await _attemptOidcAutoAccept();
|
|
if (!mounted) return;
|
|
await _tryCookieSession();
|
|
}
|
|
|
|
if (uri.queryParameters.containsKey('redirect_url')) {
|
|
_redirectUrl = uri.queryParameters['redirect_url'];
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _tryCookieSession({bool silent = true}) async {
|
|
if (AuthTokenStore.getToken() != null &&
|
|
(_loginChallenge == null || _loginChallenge!.isEmpty)) {
|
|
return;
|
|
}
|
|
final pendingProvider = AuthTokenStore.getPendingProvider();
|
|
final provider = pendingProvider ?? AuthTokenStore.getProvider() ?? 'ory';
|
|
|
|
try {
|
|
await AuthProxyService.checkCookieSession();
|
|
AuthTokenStore.setCookieMode(provider: provider);
|
|
AuthTokenStore.clearPendingProvider();
|
|
if (mounted) {
|
|
await ref.read(profileProvider.notifier).loadProfile();
|
|
await _onCookieLoginSuccess(provider);
|
|
}
|
|
} catch (e) {
|
|
if (!silent) {
|
|
_showError("로그인 확인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _onCookieLoginSuccess(String provider) async {
|
|
debugPrint("[Auth] Cookie-based login success. Provider: $provider");
|
|
AuthNotifier.instance.notify();
|
|
if (_loginChallenge != null && _loginChallenge!.isNotEmpty) {
|
|
final accepted = await _acceptOidcLoginAndRedirect();
|
|
if (accepted) {
|
|
return;
|
|
}
|
|
}
|
|
if (mounted) {
|
|
context.go('/');
|
|
}
|
|
}
|
|
|
|
Future<void> _attemptOidcAutoAccept() async {
|
|
if (_oidcAutoAcceptTried) return;
|
|
_oidcAutoAcceptTried = true;
|
|
if (_loginChallenge == null || _loginChallenge!.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final token = AuthTokenStore.getToken();
|
|
if (token != null && token.isNotEmpty) {
|
|
final accepted = await _acceptOidcLoginAndRedirect(token: token);
|
|
if (accepted) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await AuthProxyService.checkCookieSession();
|
|
AuthTokenStore.setCookieMode(provider: AuthTokenStore.getProvider() ?? 'ory');
|
|
await _acceptOidcLoginAndRedirect();
|
|
} catch (e) {
|
|
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
|
|
}
|
|
}
|
|
|
|
Future<bool> _acceptOidcLoginAndRedirect({String? token}) async {
|
|
if (_loginChallenge == null || _loginChallenge!.isEmpty) {
|
|
return false;
|
|
}
|
|
try {
|
|
final res = await AuthProxyService.acceptOidcLogin(
|
|
_loginChallenge!,
|
|
token: token,
|
|
);
|
|
final redirectTo = res['redirectTo'] as String?;
|
|
if (redirectTo != null && redirectTo.isNotEmpty) {
|
|
debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo");
|
|
webWindow.redirectTo(redirectTo);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] OIDC login auto-accept failed: $e");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void _resetLinkLoginState() {
|
|
_linkPendingRef = null;
|
|
_lastLinkLoginId = null;
|
|
_lastLinkIsEmail = true;
|
|
_linkResendTimer?.cancel();
|
|
_linkResendTimer = null;
|
|
_linkResendSeconds = 0;
|
|
_linkExpireTimer?.cancel();
|
|
_linkExpireTimer = null;
|
|
_linkExpireSeconds = 0;
|
|
_shortCodePrefixController.clear();
|
|
_shortCodeDigitsController.clear();
|
|
}
|
|
|
|
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(_resetLinkLoginState);
|
|
context.go('/signin');
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
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("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(Duration(milliseconds: _qrPollIntervalMs), (timer) async {
|
|
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
|
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();
|
|
_showError("QR 세션이 만료되었습니다.");
|
|
return;
|
|
}
|
|
|
|
if (res['status'] == 'ok') {
|
|
timer.cancel();
|
|
_qrCountdownTimer?.cancel();
|
|
final token = res['sessionJwt'] ?? res['token'];
|
|
if (token is String && token.isNotEmpty) {
|
|
_completeLoginFromToken(token);
|
|
} else {
|
|
_showError("로그인 토큰을 확인할 수 없습니다.");
|
|
}
|
|
}
|
|
} 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;
|
|
}
|
|
|
|
void _markVerificationApproved(
|
|
String message, {
|
|
String title = '승인 완료',
|
|
String pageTitle = '로그인 승인',
|
|
String actionLabel = '확인',
|
|
String actionPath = '/',
|
|
bool autoRedirect = false,
|
|
Duration redirectDelay = const Duration(seconds: 2),
|
|
}) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_verificationApproved = true;
|
|
_verificationMessage = message;
|
|
_verificationTitle = title;
|
|
_verificationPageTitle = pageTitle;
|
|
_verificationActionLabel = actionLabel;
|
|
_verificationActionPath = actionPath;
|
|
});
|
|
_verificationRedirectTimer?.cancel();
|
|
if (autoRedirect) {
|
|
_verificationRedirectTimer = Timer(redirectDelay, () {
|
|
if (!mounted) return;
|
|
context.go(actionPath);
|
|
});
|
|
}
|
|
}
|
|
|
|
Widget _buildVerificationResultView() {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_verificationTitle,
|
|
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
_verificationMessage.isEmpty ? '로그인 승인에 성공했습니다.' : _verificationMessage,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Colors.black54),
|
|
),
|
|
const SizedBox(height: 24),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
|
|
final target = hasLocalSession ? '/' : '/signin';
|
|
if (mounted) {
|
|
setState(() {
|
|
_verificationOnly = false;
|
|
_verificationApproved = false;
|
|
});
|
|
}
|
|
context.go(target);
|
|
},
|
|
child: Text(_verificationActionLabel),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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'];
|
|
final status = res['status']?.toString();
|
|
final hasLocalSession = await _hasValidLocalSession();
|
|
final actionPath = hasLocalSession ? '/' : '/signin';
|
|
|
|
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
|
if (mounted) {
|
|
_markVerificationApproved(
|
|
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
|
|
actionPath: actionPath,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (jwt is String && jwt.isNotEmpty) {
|
|
if (hasLocalSession) {
|
|
_markVerificationApproved(
|
|
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
|
|
actionPath: actionPath,
|
|
);
|
|
return;
|
|
}
|
|
_markVerificationApproved(
|
|
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
|
|
actionPath: actionPath,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (mounted) {
|
|
_markVerificationApproved(
|
|
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
|
|
actionPath: actionPath,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
|
if (mounted) {
|
|
_showError("Verification failed: $e");
|
|
}
|
|
}
|
|
}
|
|
|
|
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['token'];
|
|
final status = res['status']?.toString();
|
|
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
|
|
final hasLocalSession = await _hasValidLocalSession();
|
|
final actionPath = hasLocalSession ? '/' : '/signin';
|
|
|
|
if (jwt == null && status == 'approved') {
|
|
if (mounted) {
|
|
_markVerificationApproved(
|
|
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
|
|
actionPath: actionPath,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (jwt is String && jwt.isNotEmpty) {
|
|
if (hasLocalSession) {
|
|
_markVerificationApproved(
|
|
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
|
|
actionPath: actionPath,
|
|
);
|
|
return;
|
|
}
|
|
if (_verificationOnly) {
|
|
_markVerificationApproved(
|
|
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
|
|
actionPath: actionPath,
|
|
);
|
|
return;
|
|
}
|
|
_markVerificationApproved("링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
|
|
title: '링크 로그인 완료',
|
|
pageTitle: '링크 로그인',
|
|
actionLabel: '로그인 화면으로 이동',
|
|
actionPath: '/signin',
|
|
autoRedirect: true,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_verificationOnly && mounted) {
|
|
_markVerificationApproved(
|
|
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
|
|
actionPath: actionPath,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
|
|
if (mounted) {
|
|
_showError("Verification failed: $e");
|
|
}
|
|
}
|
|
}
|
|
|
|
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['token'];
|
|
final status = res['status']?.toString();
|
|
debugPrint("[Auth] Short code verification successful");
|
|
final hasLocalSession = await _hasValidLocalSession();
|
|
final actionPath = hasLocalSession ? '/' : '/signin';
|
|
|
|
if (jwt == null && status == 'approved') {
|
|
if (mounted) {
|
|
_markVerificationApproved(
|
|
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
|
|
actionPath: actionPath,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (jwt is String && jwt.isNotEmpty) {
|
|
if (hasLocalSession) {
|
|
_markVerificationApproved(
|
|
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
|
|
actionPath: actionPath,
|
|
);
|
|
return;
|
|
}
|
|
if (_verificationOnly) {
|
|
_markVerificationApproved(
|
|
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
|
|
actionPath: actionPath,
|
|
);
|
|
return;
|
|
}
|
|
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
|
return;
|
|
}
|
|
|
|
if (_verificationOnly && mounted) {
|
|
_markVerificationApproved(
|
|
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
|
|
actionPath: actionPath,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
|
if (mounted) {
|
|
_showError("Verification failed: $e");
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stopQrPolling();
|
|
_verificationRedirectTimer?.cancel();
|
|
_tabController.dispose();
|
|
_linkIdController.dispose();
|
|
_passwordLoginIdController.dispose();
|
|
_passwordController.dispose();
|
|
_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("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
String loginId = input;
|
|
if (!input.contains('@')) {
|
|
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
|
|
if (loginId.startsWith('010')) {
|
|
loginId = '+82${loginId.substring(1)}';
|
|
}
|
|
}
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => const Center(child: CircularProgressIndicator()),
|
|
);
|
|
|
|
try {
|
|
final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge);
|
|
final jwt = res['sessionJwt'];
|
|
final provider = res['provider'] as String?;
|
|
final redirectTo = res['redirectTo'] as String?;
|
|
|
|
if (mounted) Navigator.of(context).pop();
|
|
|
|
if (redirectTo != null && redirectTo.isNotEmpty) {
|
|
webWindow.redirectTo(redirectTo);
|
|
return;
|
|
}
|
|
|
|
if (jwt != null) {
|
|
_onLoginSuccess(jwt, provider: provider);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) Navigator.of(context).pop();
|
|
if (e.toString().contains("User not registered")) {
|
|
_showUnregisteredDialog();
|
|
} else {
|
|
_showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
|
|
}
|
|
}
|
|
}
|
|
|
|
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("오류: $e");
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async {
|
|
try {
|
|
if (mounted) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => const Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
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;
|
|
});
|
|
Navigator.of(context).pop();
|
|
|
|
_showInfo(isEmail
|
|
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
|
: "입력하신 번호로 로그인 링크를 보냈습니다.");
|
|
|
|
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 && Navigator.canPop(context)) Navigator.of(context).pop();
|
|
if (mounted) {
|
|
setState(_resetLinkLoginState);
|
|
}
|
|
if (e.toString().contains("User not registered")) {
|
|
_showUnregisteredDialog();
|
|
} else {
|
|
_showError("전송 실패: $e");
|
|
}
|
|
}
|
|
}
|
|
|
|
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("Login timed out.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (result['status'] == 'ok') {
|
|
final token = result['sessionJwt'] ?? 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("로그인 토큰을 확인할 수 없습니다.");
|
|
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("Login timed out.");
|
|
}
|
|
}
|
|
|
|
void _showError(String message) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
|
);
|
|
try {
|
|
AuthProxyService.logError(message);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
void _showInfo(String message) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(message), backgroundColor: Colors.green),
|
|
);
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
void _onLoginSuccess(String token, {String? provider}) async {
|
|
if (!mounted) return;
|
|
|
|
_logTokenDetails(token);
|
|
|
|
final providerName = provider ?? AuthTokenStore.getProvider();
|
|
|
|
AuthTokenStore.setToken(token, provider: providerName);
|
|
AuthTokenStore.clearPendingProvider();
|
|
|
|
try {
|
|
await ref.read(profileProvider.notifier).loadProfile();
|
|
} catch (e) {
|
|
debugPrint("[Auth] Failed to pre-fetch profile: $e");
|
|
}
|
|
|
|
if (_loginChallenge != null && _loginChallenge!.isNotEmpty) {
|
|
try {
|
|
final res = await AuthProxyService.acceptOidcLogin(
|
|
_loginChallenge!,
|
|
token: token,
|
|
);
|
|
final redirectTo = res['redirectTo'] as String?;
|
|
if (redirectTo != null && redirectTo.isNotEmpty) {
|
|
debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo");
|
|
webWindow.redirectTo(redirectTo);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
_showError("OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (WebAuthIntegration.isPopup()) {
|
|
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
|
|
WebAuthIntegration.sendLoginSuccess(token);
|
|
} else {
|
|
if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
|
|
debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl");
|
|
final target = "$_redirectUrl?token=$token";
|
|
launchUrlString(target, webOnlyWindowName: '_self');
|
|
return;
|
|
}
|
|
}
|
|
|
|
debugPrint("[Auth] Login success. Navigating to root.");
|
|
AuthNotifier.instance.notify();
|
|
if (mounted) {
|
|
context.go('/');
|
|
}
|
|
}
|
|
|
|
void _showUnregisteredDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text("미등록 회원"),
|
|
content: const Text("가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요."),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text("취소"),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_resetLinkLoginState();
|
|
context.push('/signup');
|
|
},
|
|
child: const Text("회원가입 하기"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_verificationOnly && _verificationApproved) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(_verificationPageTitle),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => context.go('/'),
|
|
),
|
|
),
|
|
body: _buildVerificationResultView(),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
body: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
|
child: Center(
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 400),
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
"Baron 로그인",
|
|
style: TextStyle(
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
if (_drySendEnabled) ...[
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFFF3CD),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: const Color(0xFFFFC107)),
|
|
),
|
|
child: Row(
|
|
children: const [
|
|
Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
"drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.",
|
|
style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 40),
|
|
|
|
TabBar(
|
|
controller: _tabController,
|
|
tabs: const [
|
|
Tab(text: "비밀번호"),
|
|
Tab(text: "로그인 링크"),
|
|
Tab(text: "QR 코드"),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
SizedBox(
|
|
height: 350,
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 16.0),
|
|
child: Column(
|
|
children: [
|
|
TextField(
|
|
controller: _passwordLoginIdController,
|
|
decoration: const InputDecoration(
|
|
labelText: "이메일 또는 휴대폰 번호",
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.person_outline),
|
|
),
|
|
onSubmitted: (_) => _handlePasswordLogin(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _passwordController,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: "비밀번호",
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.lock_outline),
|
|
),
|
|
onSubmitted: (_) => _handlePasswordLogin(),
|
|
),
|
|
const SizedBox(height: 24),
|
|
FilledButton(
|
|
onPressed: _handlePasswordLogin,
|
|
style: FilledButton.styleFrom(
|
|
minimumSize: const Size.fromHeight(50),
|
|
),
|
|
child: const Text("로그인"),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 16.0),
|
|
child: Column(
|
|
children: [
|
|
if (_linkPendingRef == null) ...[
|
|
TextField(
|
|
controller: _linkIdController,
|
|
decoration: const InputDecoration(
|
|
labelText: "이메일 또는 휴대폰 번호",
|
|
hintText: "",
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.person_outline),
|
|
),
|
|
onSubmitted: (_) => _handleLinkLogin(),
|
|
),
|
|
const SizedBox(height: 24),
|
|
FilledButton(
|
|
onPressed: _handleLinkLogin,
|
|
style: FilledButton.styleFrom(
|
|
minimumSize: const Size.fromHeight(50),
|
|
),
|
|
child: const Text("로그인 링크 전송"),
|
|
),
|
|
const SizedBox(height: 24),
|
|
const Text(
|
|
"입력하신 정보로 로그인 링크를 전송합니다.",
|
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
if (_linkPendingRef != null) ...[
|
|
const Text(
|
|
"링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.",
|
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextField(
|
|
controller: _shortCodePrefixController,
|
|
textCapitalization: TextCapitalization.characters,
|
|
decoration: const InputDecoration(
|
|
labelText: "영문 2자리",
|
|
border: OutlineInputBorder(),
|
|
hintText: "AB",
|
|
hintStyle: TextStyle(color: Colors.grey),
|
|
),
|
|
maxLength: 2,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
flex: 4,
|
|
child: TextField(
|
|
controller: _shortCodeDigitsController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: "숫자 6자리",
|
|
border: const OutlineInputBorder(),
|
|
hintText: "345678",
|
|
hintStyle: const TextStyle(color: Colors.grey),
|
|
suffixText: _linkExpireSeconds > 0
|
|
? "유효시간 ${_formatTime(_linkExpireSeconds)}"
|
|
: null,
|
|
),
|
|
maxLength: 6,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
|
|
final digits = _shortCodeDigitsController.text.trim();
|
|
if (prefix.length != 2 || digits.length != 6) {
|
|
_showError("문자 2개와 숫자 6자리를 입력해 주세요.");
|
|
return;
|
|
}
|
|
_verifyShortCode(prefix + digits);
|
|
},
|
|
style: FilledButton.styleFrom(
|
|
minimumSize: const Size.fromHeight(45),
|
|
),
|
|
child: const Text("코드로 로그인"),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextButton(
|
|
onPressed: () {
|
|
if (_linkResendSeconds > 0) {
|
|
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다.");
|
|
return;
|
|
}
|
|
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
|
if (loginId.isEmpty) {
|
|
_showError("이메일 또는 휴대폰 번호를 입력해 주세요.");
|
|
return;
|
|
}
|
|
_startEnchantedFlow(
|
|
loginId,
|
|
isEmail: _lastLinkIsEmail || loginId.contains('@'),
|
|
codeOnly: false,
|
|
);
|
|
},
|
|
child: Text(
|
|
_linkResendSeconds > 0
|
|
? "재발송 (${_formatTime(_linkResendSeconds)})"
|
|
: "재발송",
|
|
),
|
|
),
|
|
if (!_lastLinkIsEmail) ...[
|
|
const SizedBox(height: 4),
|
|
TextButton(
|
|
onPressed: () {
|
|
if (_linkResendSeconds > 0) {
|
|
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다.");
|
|
return;
|
|
}
|
|
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
|
if (loginId.isEmpty) {
|
|
_showError("휴대폰 번호를 입력해 주세요.");
|
|
return;
|
|
}
|
|
_startEnchantedFlow(
|
|
loginId,
|
|
isEmail: false,
|
|
codeOnly: true,
|
|
);
|
|
},
|
|
child: Text("코드만 받기(${_formatTime(_linkResendSeconds)})"),
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
),
|
|
|
|
Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (_isQrLoading)
|
|
const CircularProgressIndicator()
|
|
else if (_qrImageBase64 != null)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
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
|
|
? "남은 시간: ${_formatTime(_qrRemainingSeconds)}"
|
|
: "QR 코드 만료됨",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
"모바일 앱으로 스캔하세요",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
TextButton(
|
|
onPressed: _startQrFlow,
|
|
child: const Text("QR 코드 새로고침")
|
|
),
|
|
],
|
|
)
|
|
else
|
|
const Text("QR 코드를 불러오지 못했습니다.", textAlign: TextAlign.center),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Column(
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => context.push('/forgot-password'),
|
|
child: const Text("비밀번호를 잊으셨나요?"),
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Text("계정이 없으신가요?", style: TextStyle(color: Colors.grey, fontSize: 14)),
|
|
TextButton(
|
|
onPressed: () => context.push('/signup'),
|
|
child: const Text("회원가입"),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|