1
0
forked from baron/baron-sso

린트 적용

This commit is contained in:
2026-02-12 10:39:47 +09:00
parent 21b9594de5
commit 74884f6616
65 changed files with 26389 additions and 1583 deletions

View File

@@ -134,13 +134,16 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 40),
if (_message != null)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
_message!,
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
style: TextStyle(
color: _success ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
@@ -155,7 +158,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
backgroundColor: Colors.blue,
),
),
if (!isLoggedIn && !_success)
Padding(
padding: const EdgeInsets.only(top: 16),

View File

@@ -17,7 +17,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
bool _isLoading = true;
bool _isSubmitting = false;
String? _error;
// 사용자가 선택한 스코프 목록
final Set<String> _selectedScopes = {};
@@ -41,8 +41,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
Future<void> _fetchConsentInfo() async {
try {
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge);
final info = await AuthProxyService.getConsentInfo(
widget.consentChallenge,
);
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
if (info['redirectTo'] != null) {
webWindow.redirectTo(info['redirectTo']);
@@ -52,19 +54,20 @@ class _ConsentScreenState extends State<ConsentScreen> {
// 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용
if (info['scope_details'] != null) {
final details = info['scope_details'] as Map<String, dynamic>;
details.forEach((scope, detail) {
if (detail is Map<String, dynamic>) {
// 설명 업데이트
if (detail['description'] != null && detail['description'].toString().isNotEmpty) {
if (detail['description'] != null &&
detail['description'].toString().isNotEmpty) {
_scopeDescriptions[scope] = detail['description'].toString();
}
// 필수 여부 업데이트
if (detail['mandatory'] == true) {
_mandatoryScopes.add(scope);
} else {
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
// 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음.
// 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음)
if (scope != 'openid') {
@@ -76,7 +79,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
}
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
final requestedScopes = (info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
final requestedScopes =
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
_selectedScopes.addAll(requestedScopes);
setState(() {
@@ -102,7 +106,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
widget.consentChallenge,
grantScope: _selectedScopes.toList(),
);
if (result['redirectTo'] != null) {
webWindow.redirectTo(result['redirectTo']);
} else {
@@ -142,7 +146,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
if (confirmed == true) {
setState(() => _isSubmitting = true);
try {
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge);
final resp = await AuthProxyService.rejectConsent(
widget.consentChallenge,
);
final redirectTo = resp['redirectTo'];
if (redirectTo != null) {
webWindow.redirectTo(redirectTo);
@@ -152,9 +158,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
} catch (e) {
setState(() => _isSubmitting = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')));
}
}
}
@@ -164,13 +170,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
Widget build(BuildContext context) {
// 배경색을 약간 어둡게 처리하거나, 전체적인 테마 색상을 사용
return Scaffold(
backgroundColor: Colors.grey[100],
backgroundColor: Colors.grey[100],
body: Center(
child: _isLoading
? const CircularProgressIndicator()
: _error != null
? _buildErrorCard()
: _buildConsentCard(context),
? _buildErrorCard()
: _buildConsentCard(context),
),
);
}
@@ -196,7 +202,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
final clientLogo = _consentInfo?['client']?['logo_uri'];
final requestedScopes = (_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
final requestedScopes =
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
[];
return SingleChildScrollView(
child: Container(
@@ -204,7 +212,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
margin: const EdgeInsets.all(16),
child: Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
@@ -235,7 +245,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
),
child: Row(
children: [
if (clientLogo != null && clientLogo.toString().isNotEmpty)
if (clientLogo != null &&
clientLogo.toString().isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 16),
child: CircleAvatar(
@@ -286,7 +297,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
children: [
const Text(
'요청된 권한',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
'${requestedScopes.length}',
@@ -354,7 +368,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
)
: const Text(
'동의하고 계속하기',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),

View File

@@ -32,30 +32,36 @@ class ErrorScreen extends StatelessWidget {
final title = isProd
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
: (hasCode
? tr(
'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}',
params: {'code': normalizedCode},
)
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다'));
? tr(
'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}',
params: {'code': normalizedCode},
)
: tr(
'msg.userfront.error.title_generic',
fallback: '오류가 발생했습니다',
));
final detail = isProd
? (isWhitelisted
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback,
)
: tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
))
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback,
)
: tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
))
: ((description?.isNotEmpty == true)
? description!
: (hasCode
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.')
: tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
? description!
: (hasCode
? tr(
'msg.userfront.error.detail_generic',
fallback: '오류가 발생했습니다.',
)
: tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
return Scaffold(
backgroundColor: const Color(0xFFF7F8FA),
@@ -124,20 +130,29 @@ class ErrorScreen extends StatelessWidget {
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF111827),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'),
tr(
'ui.userfront.error.go_login',
fallback: '로그인으로 이동',
),
),
),
OutlinedButton(
onPressed: () => context.go('/'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF111827),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: const BorderSide(color: Color(0xFFCBD5F5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),

View File

@@ -17,7 +17,9 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
@override
void initState() {
super.initState();
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
_drySendEnabled =
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
}
Future<void> _handlePasswordReset() async {
@@ -44,7 +46,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
setState(() => _isLoading = true);
try {
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
await AuthProxyService.initiatePasswordReset(
loginId,
drySend: _drySendEnabled,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -107,16 +112,16 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
children: [
Text(
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
if (_drySendEnabled) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
@@ -124,7 +129,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
const Icon(
Icons.warning_amber_rounded,
color: Color(0xFF8A6D3B),
),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -132,7 +140,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
'msg.userfront.forgot.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
style: const TextStyle(
color: Color(0xFF8A6D3B),
fontSize: 12,
),
),
),
],
@@ -172,13 +183,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
tr(
'ui.userfront.forgot.submit',
fallback: '재설정 링크 전송',
),
tr('ui.userfront.forgot.submit', fallback: '재설정 링크 전송'),
),
),
],

View File

@@ -18,7 +18,12 @@ class LoginScreen extends ConsumerStatefulWidget {
final String? loginChallenge;
final String? redirectUrl;
const LoginScreen({super.key, this.verificationToken, this.loginChallenge, this.redirectUrl});
const LoginScreen({
super.key,
this.verificationToken,
this.loginChallenge,
this.redirectUrl,
});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
@@ -28,7 +33,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final TextEditingController _linkIdController = TextEditingController();
final TextEditingController _passwordLoginIdController = TextEditingController();
final TextEditingController _passwordLoginIdController =
TextEditingController();
final TextEditingController _passwordController = TextEditingController();
String? _redirectUrl;
String? _loginChallenge;
@@ -41,8 +47,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
int _qrRemainingSeconds = 0;
Timer? _qrCountdownTimer;
int _qrPollIntervalMs = 2000;
final TextEditingController _shortCodePrefixController = TextEditingController();
final TextEditingController _shortCodeDigitsController = TextEditingController();
final TextEditingController _shortCodePrefixController =
TextEditingController();
final TextEditingController _shortCodeDigitsController =
TextEditingController();
String? _linkPendingRef;
String? _lastLinkLoginId;
bool _lastLinkIsEmail = true;
@@ -75,12 +83,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
super.initState();
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
_tabController.addListener(_handleTabSelection);
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
_drySendEnabled =
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
_redirectUrl = widget.redirectUrl;
WidgetsBinding.instance.addPostFrameCallback((_) async {
final uri = Uri.base;
if (_redirectUrl == null) {
if (uri.queryParameters.containsKey('redirect_url')) {
_redirectUrl = uri.queryParameters['redirect_url'];
@@ -89,15 +99,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
_loginChallenge = widget.loginChallenge ?? uri.queryParameters['login_challenge'];
_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 hasShortCodePath =
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
final hasTokenParam = uri.queryParameters.containsKey('t');
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
final hasVerificationToken =
widget.verificationToken != null || hasTokenParam;
final hasLoginCode = loginIdParam != null && codeParam != null;
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
_verificationOnly =
hasVerificationToken || hasLoginCode || hasShortCodePath;
final notice = uri.queryParameters['notice'];
if (hasShortCodePath) {
@@ -150,9 +164,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
tr(
'msg.userfront.login.cookie_check_failed',
fallback: '로그인 확인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
params: {'error': e.toString().replaceFirst('Exception: ', '')},
),
);
}
@@ -171,8 +183,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final token = AuthTokenStore.getToken();
if (token != null && token.isNotEmpty) {
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint("[Auth] Cookie session with external integration. Notifying...");
if (WebAuthIntegration.isPopup() ||
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint(
"[Auth] Cookie session with external integration. Notifying...",
);
WebAuthIntegration.sendLoginSuccess(token);
return;
}
@@ -200,7 +215,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
try {
await AuthProxyService.checkCookieSession();
AuthTokenStore.setCookieMode(provider: AuthTokenStore.getProvider() ?? 'ory');
AuthTokenStore.setCookieMode(
provider: AuthTokenStore.getProvider() ?? 'ory',
);
await _acceptOidcLoginAndRedirect();
} catch (e) {
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
@@ -289,7 +306,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final parts = jwt.split('.');
if (parts.length != 3) return 'User';
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
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) {
@@ -360,65 +379,65 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _startQrPolling() {
_qrPollingTimer?.cancel();
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async {
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
timer.cancel();
return;
}
_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;
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;
}
} else {
_qrPollIntervalMs += 500;
timer.cancel();
_startQrPolling();
}
if (res['error'] == 'authorization_pending') {
return;
}
}
if (res['error'] == 'authorization_pending') {
return;
}
if (res['error'] == 'expired_token') {
timer.cancel();
_qrCountdownTimer?.cancel();
_showError(
tr(
'msg.userfront.login.qr_expired',
fallback: '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 {
if (res['error'] == 'expired_token') {
timer.cancel();
_qrCountdownTimer?.cancel();
_showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
tr('msg.userfront.login.qr_expired', fallback: '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(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
);
}
}
} catch (e) {
debugPrint("[QR] Polling error: $e");
}
} catch (e) {
debugPrint("[QR] Polling error: $e");
}
});
},
);
}
void _stopQrPolling() {
@@ -486,21 +505,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Duration redirectDelay = const Duration(seconds: 2),
}) {
if (!mounted) return;
final resolvedTitle = title ??
tr(
'ui.userfront.login.verification.title',
fallback: '승인 완료',
);
final resolvedPageTitle = pageTitle ??
tr(
'ui.userfront.login.verification.page_title',
fallback: '로그인 승인',
);
final resolvedActionLabel = actionLabel ??
tr(
'ui.userfront.login.verification.action_label',
fallback: '확인',
);
final resolvedTitle =
title ?? tr('ui.userfront.login.verification.title', fallback: '승인 완료');
final resolvedPageTitle =
pageTitle ??
tr('ui.userfront.login.verification.page_title', fallback: '로그인 승인');
final resolvedActionLabel =
actionLabel ??
tr('ui.userfront.login.verification.action_label', fallback: '확인');
setState(() {
_verificationApproved = true;
_verificationMessage = message;
@@ -524,11 +536,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72),
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),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 12),
Text(
@@ -544,7 +564,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 24),
FilledButton(
onPressed: () {
final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
final hasLocalSession =
AuthTokenStore.getToken() != null ||
AuthTokenStore.usesCookie();
final target = hasLocalSession ? '/' : '/signin';
if (mounted) {
setState(() {
@@ -586,10 +608,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
return;
}
@@ -602,18 +621,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
return;
}
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
return;
}
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
} catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
@@ -629,9 +642,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
Future<void> _verifyLoginCode(
String loginId,
String code, {
String? pendingRef,
}) async {
final sanitizedLoginId = loginId.replaceAll(' ', '+');
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
debugPrint(
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
);
final approvedMessage = tr(
'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
@@ -653,16 +672,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
final jwt = res['sessionJwt'] ?? res['token'];
final status = res['status']?.toString();
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
debugPrint(
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
);
final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin';
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
return;
}
@@ -676,18 +694,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
if (_verificationOnly) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
return;
}
_markVerificationApproved(
linkLoginMessage,
title: tr(
'ui.userfront.login.link.title',
fallback: '링크 로그인 완료',
),
title: tr('ui.userfront.login.link.title', fallback: '링크 로그인 완료'),
pageTitle: tr(
'ui.userfront.login.link.page_title',
fallback: '링크 로그인',
@@ -703,13 +715,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
} catch (e) {
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
debugPrint(
"[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e",
);
if (mounted) {
_showError(
tr(
@@ -747,10 +758,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
return;
}
@@ -764,10 +772,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
if (_verificationOnly) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
return;
}
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
@@ -775,10 +780,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
} catch (e) {
debugPrint("[Auth] Short code verification FAILED. Error: $e");
@@ -836,7 +838,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
try {
final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge);
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?;
@@ -860,9 +866,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
tr(
'msg.userfront.login.password.failed',
fallback: '로그인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
params: {'error': e.toString().replaceFirst('Exception: ', '')},
),
);
}
@@ -900,13 +904,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async {
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()),
builder: (context) =>
const Center(child: CircularProgressIndicator()),
);
}
@@ -921,7 +930,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final interval = initResponse['interval'];
final resendAfter = initResponse['resendAfter'];
final expiresIn = initResponse['expiresIn'];
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
debugPrint(
"[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider",
);
if (mounted) {
setState(() {
@@ -974,7 +985,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
Future<void> _pollForSession(String pendingRef, {Duration? initialInterval}) async {
Future<void> _pollForSession(
String pendingRef, {
Duration? initialInterval,
}) async {
int attempts = 0;
const maxAttempts = 60;
var pollInterval = initialInterval ?? const Duration(seconds: 2);
@@ -1047,10 +1061,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
Navigator.of(context).pop();
_showError(
tr(
'msg.userfront.login.link_timeout',
fallback: '로그인 요청 시간이 초과되었습니다.',
),
tr('msg.userfront.login.link_timeout', fallback: '로그인 요청 시간이 초과되었습니다.'),
);
}
}
@@ -1138,10 +1149,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint("[Auth] External integration detected (popup or redirect). Notifying...");
if (WebAuthIntegration.isPopup() ||
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint(
"[Auth] External integration detected (popup or redirect). Notifying...",
);
WebAuthIntegration.sendLoginSuccess(token);
return;
return;
}
debugPrint("[Auth] Login success. Navigating to root.");
@@ -1224,7 +1238,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_drySendEnabled) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
@@ -1232,13 +1249,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
const Icon(
Icons.warning_amber_rounded,
color: Color(0xFF8A6D3B),
),
const SizedBox(width: 8),
Expanded(
child: Text(
tr(
'msg.userfront.login.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
fallback:
'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(
color: Color(0xFF8A6D3B),
@@ -1294,7 +1315,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '이메일 또는 휴대폰 번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
prefixIcon: const Icon(
Icons.person_outline,
),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
@@ -1308,7 +1331,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '비밀번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
prefixIcon: const Icon(
Icons.lock_outline,
),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
@@ -1319,7 +1344,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
minimumSize: const Size.fromHeight(50),
),
child: Text(
tr('ui.userfront.login.action.submit', fallback: '로그인'),
tr(
'ui.userfront.login.action.submit',
fallback: '로그인',
),
),
),
],
@@ -1340,7 +1368,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
hintText: '',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
prefixIcon: const Icon(
Icons.person_outline,
),
),
onSubmitted: (_) => _handleLinkLogin(),
),
@@ -1363,7 +1393,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'msg.userfront.login.link.helper',
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
@@ -1371,9 +1404,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Text(
tr(
'msg.userfront.login.link.short_code_help',
fallback: '링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
fallback:
'링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
@@ -1382,16 +1419,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Expanded(
flex: 2,
child: TextField(
controller: _shortCodePrefixController,
textCapitalization: TextCapitalization.characters,
controller:
_shortCodePrefixController,
textCapitalization:
TextCapitalization.characters,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.short_code.prefix',
fallback: '영문 2자리',
),
border: const OutlineInputBorder(),
border:
const OutlineInputBorder(),
hintText: 'AB',
hintStyle: const TextStyle(color: Colors.grey),
hintStyle: const TextStyle(
color: Colors.grey,
),
),
maxLength: 2,
),
@@ -1400,22 +1442,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Expanded(
flex: 4,
child: TextField(
controller: _shortCodeDigitsController,
controller:
_shortCodeDigitsController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.short_code.digits',
fallback: '숫자 6자리',
),
border: const OutlineInputBorder(),
border:
const OutlineInputBorder(),
hintText: '345678',
hintStyle: const TextStyle(color: Colors.grey),
hintStyle: const TextStyle(
color: Colors.grey,
),
suffixText: _linkExpireSeconds > 0
? tr(
'ui.userfront.login.short_code.expire_time',
fallback: '유효시간 {{time}}',
params: {
'time': _formatTime(_linkExpireSeconds),
'time': _formatTime(
_linkExpireSeconds,
),
},
)
: null,
@@ -1428,13 +1476,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12),
FilledButton(
onPressed: () {
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
final digits = _shortCodeDigitsController.text.trim();
if (prefix.length != 2 || digits.length != 6) {
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',
fallback: '문자 2개와 숫자 6자리를 입력해 주세요.',
fallback:
'문자 2개와 숫자 6자리를 입력해 주세요.',
),
);
return;
@@ -1458,27 +1513,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
fallback:
'재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
),
);
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
final loginId =
_lastLinkLoginId ??
_linkIdController.text.trim();
if (loginId.isEmpty) {
_showError(
tr(
'msg.userfront.login.link.missing_login_id',
fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.',
fallback:
'이메일 또는 휴대폰 번호를 입력해 주세요.',
),
);
return;
}
_startEnchantedFlow(
loginId,
isEmail: _lastLinkIsEmail || loginId.contains('@'),
isEmail:
_lastLinkIsEmail ||
loginId.contains('@'),
codeOnly: false,
);
},
@@ -1488,7 +1551,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.link.resend_with_time',
fallback: '재발송 ({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
)
: tr(
@@ -1505,15 +1570,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
fallback:
'재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
),
);
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
final loginId =
_lastLinkLoginId ??
_linkIdController.text.trim();
if (loginId.isEmpty) {
_showError(
tr(
@@ -1534,7 +1604,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.link.code_only',
fallback: '코드만 받기({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
),
),
@@ -1553,13 +1625,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const CircularProgressIndicator()
else if (_qrImageBase64 != null)
Column(
crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
borderRadius: BorderRadius.circular(
12,
),
),
child: QrImageView(
data: _qrImageBase64!,
@@ -1570,20 +1647,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12),
Text(
_qrRemainingSeconds > 0
? tr(
'ui.userfront.login.qr.remaining',
fallback: '남은 시간: {{time}}',
params: {
'time': _formatTime(_qrRemainingSeconds),
},
)
: tr(
'ui.userfront.login.qr.expired',
fallback: 'QR 코드 만료됨',
),
? tr(
'ui.userfront.login.qr.remaining',
fallback: '남은 시간: {{time}}',
params: {
'time': _formatTime(
_qrRemainingSeconds,
),
},
)
: tr(
'ui.userfront.login.qr.expired',
fallback: 'QR 코드 만료됨',
),
textAlign: TextAlign.center,
style: TextStyle(
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
color: _qrRemainingSeconds > 30
? Colors.blue
: Colors.red,
fontWeight: FontWeight.bold,
),
),
@@ -1594,7 +1675,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '모바일 앱으로 스캔하세요',
),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 12),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
TextButton(
onPressed: _startQrFlow,
@@ -1640,7 +1724,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'msg.userfront.login.no_account',
fallback: '계정이 없으신가요?',
),
style: const TextStyle(color: Colors.grey, fontSize: 14),
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
TextButton(
onPressed: () => context.push('/signup'),

View File

@@ -14,23 +14,27 @@ class LoginSuccessScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green),
const Icon(
Icons.check_circle_outline,
size: 80,
color: Colors.green,
),
const SizedBox(height: 24),
Text(
tr('ui.userfront.login_success.title', fallback: '로그인 완료'),
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'),
tr(
'msg.userfront.login_success.subtitle',
fallback: '성공적으로 로그인되었습니다.',
),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 16),
),
const SizedBox(height: 48),
// 이 버튼이 QR 카메라를 켜는 버튼입니다.
FilledButton.icon(
onPressed: () {
@@ -38,12 +42,18 @@ class LoginSuccessScreen extends StatelessWidget {
},
icon: const Icon(Icons.camera_alt, size: 28),
label: Text(
tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'),
tr(
'ui.userfront.login_success.qr',
fallback: 'QR 인증 (카메라 켜기)',
),
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
backgroundColor: Colors.blue.shade700,
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),

View File

@@ -87,7 +87,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
Future<void> _onDetect(BarcodeCapture capture) async {
if (_isScanned) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
if (barcode.rawValue != null) {
@@ -119,7 +119,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
_log.info('QR Code detected raw: $qrData, ref: $pendingRef');
final approveRef = qrData;
final storedToken = AuthTokenStore.getToken();
final sessionToken = storedToken;
var usesCookie = AuthTokenStore.usesCookie();
@@ -140,7 +140,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
token: sessionToken,
withCredentials: usesCookie,
);
if (mounted) {
setState(() {
_isSuccess = true;
@@ -226,7 +226,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
const SizedBox(height: 16),
Text(
title,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 12),
Text(
@@ -268,7 +272,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
controller: controller,
onDetect: _onDetect,
errorBuilder: (context, error) {
final isPermissionDenied = error.errorCode ==
final isPermissionDenied =
error.errorCode ==
MobileScannerErrorCode.permissionDenied;
return Center(
child: Column(
@@ -295,7 +300,10 @@ class _QRScanScreenState extends State<QRScanScreen> {
: _requestCameraPermission,
child: Text(
_isRequestingCamera
? tr('ui.common.requesting', fallback: '요청 중...')
? tr(
'ui.common.requesting',
fallback: '요청 중...',
)
: tr(
'ui.userfront.qr.request_permission',
fallback: '카메라 권한 요청하기',

View File

@@ -13,7 +13,8 @@ class ResetPasswordScreen extends StatefulWidget {
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController();
final TextEditingController _confirmPasswordController =
TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
String? _loginId;
@@ -31,13 +32,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
// 2. Fallback to URI query parameter if not available via router
if (_loginId == null || _loginId!.isEmpty) {
final uri = Uri.base;
_loginId = uri.queryParameters['loginId'];
}
final uri = Uri.base;
_loginId = uri.queryParameters['loginId'];
}
// 토큰도 함께 읽어놓는다.
final uri = Uri.base;
_token = uri.queryParameters['token'];
// 토큰도 함께 읽어놓는다.
final uri = Uri.base;
_token = uri.queryParameters['token'];
_loadPolicy();
}
@@ -66,7 +67,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Future<void> _handlePasswordReset() async {
if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
if ((_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)) {
_showError(
tr(
'msg.userfront.reset.invalid_link',
@@ -163,9 +165,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
);
}
if (requiresNumber) {
parts.add(
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
);
parts.add(tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'));
}
if (requiresSymbol) {
parts.add(
@@ -180,16 +180,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
),
title: Text(tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정')),
centerTitle: true,
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)
child:
(_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)
? _buildInvalidTokenView()
: Form(
key: _formKey,
@@ -227,7 +227,9 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
_isPasswordObscured
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
@@ -244,7 +246,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
fallback: '비밀번호를 입력해주세요.',
);
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minLength =
(_policy?['minLength'] as int?) ?? 12;
if (val.length < minLength) {
return tr(
'msg.userfront.reset.error.min_length',
@@ -262,7 +265,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (hasNumber) typeCount++;
if (hasSymbol) typeCount++;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
final minTypes =
(_policy?['minCharacterTypes'] as int?) ?? 0;
if (minTypes > 0 && typeCount < minTypes) {
return tr(
'msg.userfront.reset.error.min_types',
@@ -290,7 +294,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
);
}
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
if ((_policy?['nonAlphanumeric'] ?? true) &&
!hasSymbol) {
return tr(
'msg.userfront.reset.error.symbol',
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
@@ -312,11 +317,14 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
_isConfirmPasswordObscured
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_isConfirmPasswordObscured = !_isConfirmPasswordObscured;
_isConfirmPasswordObscured =
!_isConfirmPasswordObscured;
});
},
),
@@ -369,8 +377,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
const Icon(Icons.error_outline, color: Colors.red, size: 60),
const SizedBox(height: 16),
Text(
tr('msg.userfront.reset.invalid_title',
fallback: '유효하지 않은 링크입니다.'),
tr('msg.userfront.reset.invalid_title', fallback: '유효하지 않은 링크입니다.'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),

View File

@@ -164,30 +164,39 @@ class _SignupScreenState extends State<SignupScreen> {
final email = _emailController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.',
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.',
),
);
return;
}
setState(() { _isLoading = true; _emailError = null; });
setState(() {
_isLoading = true;
_emailError = null;
});
try {
final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.',
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.',
),
);
return;
}
await AuthProxyService.sendSignupCode(email, 'email');
_startTimer('email');
} catch (e) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
),
);
} finally {
setState(() => _isLoading = false);
}
@@ -197,7 +206,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _emailCodeController.text.trim();
if (code.length != 6) return;
try {
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code);
final success = await AuthProxyService.verifySignupCode(
_emailController.text.trim(),
'email',
code,
);
if (success) {
setState(() {
_isEmailVerified = true;
@@ -206,33 +219,42 @@ class _SignupScreenState extends State<SignupScreen> {
_emailError = null;
});
} else {
setState(() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
),
);
}
} catch (e) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
);
}
}
Future<void> _sendPhoneCode() async {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
setState(() { _isLoading = true; _phoneError = null; });
setState(() {
_isLoading = true;
_phoneError = null;
});
try {
await AuthProxyService.sendSignupCode(phone, 'phone');
_startTimer('phone');
} catch (e) {
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
),
);
} finally {
setState(() => _isLoading = false);
}
@@ -242,7 +264,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _phoneCodeController.text.trim();
if (code.length != 6) return;
try {
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code);
final success = await AuthProxyService.verifySignupCode(
_phoneController.text.trim(),
'phone',
code,
);
if (success) {
setState(() {
_isPhoneVerified = true;
@@ -251,26 +277,32 @@ class _SignupScreenState extends State<SignupScreen> {
_phoneError = null;
});
} else {
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
),
);
}
} catch (e) {
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
);
}
}
Future<void> _handleSignup() async {
if (_passwordController.text != _confirmPasswordController.text) {
setState(() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
));
setState(
() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
),
);
return;
}
if (!_formKey.currentState!.validate()) return;
@@ -288,7 +320,9 @@ class _SignupScreenState extends State<SignupScreen> {
phone: _phoneController.text.trim(),
affiliationType: _affiliationType,
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(),
department: _deptController.text.trim().isEmpty
? (_affiliationType == 'GENERAL' ? 'External' : '')
: _deptController.text.trim(),
termsAccepted: true,
);
if (mounted) _showSuccessDialog();
@@ -394,11 +428,28 @@ class _SignupScreenState extends State<SignupScreen> {
children: [
CircleAvatar(
radius: 12,
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]),
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)),
backgroundColor: isDone
? Colors.green
: (isCurrent ? Colors.black : Colors.grey[300]),
child: isDone
? const Icon(Icons.check, size: 14, color: Colors.white)
: Text(
'$step',
style: TextStyle(
color: isCurrent ? Colors.white : Colors.black54,
fontSize: 10,
),
),
),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)),
Text(
label,
style: TextStyle(
fontSize: 9,
color: isCurrent ? Colors.black : Colors.grey,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
),
),
],
);
}
@@ -438,10 +489,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
child: CheckboxListTile(
title: Text(
tr(
'ui.userfront.signup.agreement.all',
fallback: '모두 동의합니다',
),
tr('ui.userfront.signup.agreement.all', fallback: '모두 동의합니다'),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
value: _termsAccepted && _privacyAccepted,
@@ -488,8 +536,10 @@ class _SignupScreenState extends State<SignupScreen> {
return Column(
children: [
CheckboxListTile(
title: Text(title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
title: Text(
title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
value: value,
onChanged: onChanged,
controlAffinity: ListTileControlAffinity.leading,
@@ -508,7 +558,11 @@ class _SignupScreenState extends State<SignupScreen> {
child: SingleChildScrollView(
child: Text(
content,
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
height: 1.5,
),
),
),
),
@@ -517,8 +571,8 @@ class _SignupScreenState extends State<SignupScreen> {
}
static String get _tosText => tr(
'msg.userfront.signup.tos_full',
fallback: """
'msg.userfront.signup.tos_full',
fallback: """
바론 소프트웨어 이용약관
제1장 총칙
@@ -589,11 +643,11 @@ class _SignupScreenState extends State<SignupScreen> {
부칙
본 약관은 2024년 10월 1일부터 시행됩니다.
""",
);
);
static String get _privacyText => tr(
'msg.userfront.signup.privacy_full',
fallback: """
'msg.userfront.signup.privacy_full',
fallback: """
개인정보 수집 및 이용 동의
바론서비스 개인정보처리방침
@@ -702,7 +756,7 @@ class _SignupScreenState extends State<SignupScreen> {
제8조 (기타)
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
""",
);
);
Widget _buildStepAuth() {
return Column(
@@ -719,7 +773,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 가족사 이메일 안내 문구
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
@@ -730,7 +787,11 @@ class _SignupScreenState extends State<SignupScreen> {
'msg.userfront.signup.auth.affiliate_notice',
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
),
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
),
],
@@ -753,7 +814,7 @@ class _SignupScreenState extends State<SignupScreen> {
'ui.userfront.signup.auth.email.label',
fallback: '이메일 주소',
),
border: const OutlineInputBorder(),
border: const OutlineInputBorder(),
errorText: _emailError,
hintText: 'example@hanmaceng.co.kr',
),
@@ -764,8 +825,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox(
height: 55,
child: ElevatedButton(
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
onPressed: (_isEmailVerified || _isLoading)
? null
: _sendEmailCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text(
_emailSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
@@ -791,8 +858,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyEmailCode();
},
),
],
if (_isEmailVerified)
@@ -837,8 +909,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox(
height: 55,
child: ElevatedButton(
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
onPressed: (_isPhoneVerified || _isLoading)
? null
: _sendPhoneCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text(
_phoneSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
@@ -864,8 +942,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyPhoneCode();
},
),
],
if (_isPhoneVerified)
@@ -903,10 +986,7 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _nameController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.name',
fallback: '이름',
),
labelText: tr('ui.userfront.signup.profile.name', fallback: '이름'),
border: const OutlineInputBorder(),
),
),
@@ -936,19 +1016,13 @@ class _SignupScreenState extends State<SignupScreen> {
DropdownMenuItem(
value: 'GENERAL',
child: Text(
tr(
'domain.affiliation.general',
fallback: '일반 사용자',
),
tr('domain.affiliation.general', fallback: '일반 사용자'),
),
),
DropdownMenuItem(
value: 'AFFILIATE',
child: Text(
tr(
'domain.affiliation.affiliate',
fallback: '가족사 임직원',
),
tr('domain.affiliation.affiliate', fallback: '가족사 임직원'),
),
),
],
@@ -985,39 +1059,27 @@ class _SignupScreenState extends State<SignupScreen> {
items: [
DropdownMenuItem(
value: 'HANMAC',
child: Text(
tr('domain.company.hanmac', fallback: '한맥'),
),
child: Text(tr('domain.company.hanmac', fallback: '한맥')),
),
DropdownMenuItem(
value: 'SAMAN',
child: Text(
tr('domain.company.saman', fallback: '삼안'),
),
child: Text(tr('domain.company.saman', fallback: '삼안')),
),
DropdownMenuItem(
value: 'PTC',
child: Text(
tr('domain.company.ptc', fallback: 'PTC'),
),
child: Text(tr('domain.company.ptc', fallback: 'PTC')),
),
DropdownMenuItem(
value: 'JANGHEON',
child: Text(
tr('domain.company.jangheon', fallback: '장헌'),
),
child: Text(tr('domain.company.jangheon', fallback: '장헌')),
),
DropdownMenuItem(
value: 'BARON',
child: Text(
tr('domain.company.baron', fallback: '바론'),
),
child: Text(tr('domain.company.baron', fallback: '바론')),
),
DropdownMenuItem(
value: 'HALLA',
child: Text(
tr('domain.company.halla', fallback: '한라'),
),
child: Text(tr('domain.company.halla', fallback: '한라')),
),
],
onChanged: _isAffiliateEmail
@@ -1038,7 +1100,7 @@ class _SignupScreenState extends State<SignupScreen> {
'ui.userfront.signup.profile.department_optional',
fallback: '소속 정보 (선택)',
),
border: const OutlineInputBorder()
border: const OutlineInputBorder(),
),
),
],
@@ -1076,36 +1138,16 @@ class _SignupScreenState extends State<SignupScreen> {
);
}
if (requiresUpper) {
parts.add(
tr(
'msg.userfront.signup.policy.uppercase',
fallback: '대문자',
),
);
parts.add(tr('msg.userfront.signup.policy.uppercase', fallback: '대문자'));
}
if (requiresLower) {
parts.add(
tr(
'msg.userfront.signup.policy.lowercase',
fallback: '소문자',
),
);
parts.add(tr('msg.userfront.signup.policy.lowercase', fallback: '소문자'));
}
if (requiresNumber) {
parts.add(
tr(
'msg.userfront.signup.policy.number',
fallback: '숫자',
),
);
parts.add(tr('msg.userfront.signup.policy.number', fallback: '숫자'));
}
if (requiresSymbol) {
parts.add(
tr(
'msg.userfront.signup.policy.symbol',
fallback: '특수문자',
),
);
parts.add(tr('msg.userfront.signup.policy.symbol', fallback: '특수문자'));
}
return tr(
@@ -1117,7 +1159,7 @@ class _SignupScreenState extends State<SignupScreen> {
Widget _buildStepPassword() {
String p = _passwordController.text;
// Default Policy Fallback
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -1152,7 +1194,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 비밀번호 정책 안내 박스
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.security, size: 18, color: Colors.blue),
@@ -1160,7 +1205,11 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded(
child: Text(
_buildPolicyDescription(),
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500),
style: TextStyle(
fontSize: 12,
color: Colors.blue[800],
fontWeight: FontWeight.w500,
),
),
),
],
@@ -1219,10 +1268,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
if (requiresNumber)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.number',
fallback: '숫자',
),
tr('msg.userfront.signup.password.rule.number', fallback: '숫자'),
hasDigit,
),
if (requiresSymbol)
@@ -1253,7 +1299,7 @@ class _SignupScreenState extends State<SignupScreen> {
labelText: tr(
'ui.userfront.signup.password.confirm_label',
fallback: '비밀번호 확인',
),
),
border: const OutlineInputBorder(),
errorText: _confirmPasswordError,
),
@@ -1266,9 +1312,19 @@ class _SignupScreenState extends State<SignupScreen> {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey),
Icon(
isValid ? Icons.check_circle : Icons.circle_outlined,
size: 14,
color: isValid ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)),
Text(
label,
style: TextStyle(
fontSize: 11,
color: isValid ? Colors.green : Colors.grey,
),
),
],
);
}
@@ -1276,8 +1332,10 @@ class _SignupScreenState extends State<SignupScreen> {
@override
Widget build(BuildContext context) {
bool canGoNext = false;
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true;
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true;
if (_currentStep == 1 && _termsAccepted && _privacyAccepted)
canGoNext = true;
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified)
canGoNext = true;
if (_currentStep == 3) {
final nameOk = _nameController.text.trim().isNotEmpty;
if (_affiliationType == 'GENERAL') {
@@ -1313,11 +1371,13 @@ class _SignupScreenState extends State<SignupScreen> {
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: _currentStep == 1
? _buildStepAgreement()
: (_currentStep == 2
? _buildStepAuth()
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())),
child: _currentStep == 1
? _buildStepAgreement()
: (_currentStep == 2
? _buildStepAuth()
: (_currentStep == 3
? _buildStepInfo()
: _buildStepPassword())),
),
),
),
@@ -1329,7 +1389,10 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _currentStep--),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(55),
side: const BorderSide(color: Colors.black),
),
child: Text(
tr('ui.common.prev', fallback: '이전'),
style: const TextStyle(color: Colors.black),
@@ -1340,20 +1403,35 @@ class _SignupScreenState extends State<SignupScreen> {
],
Expanded(
child: FilledButton(
onPressed: _currentStep < 4
? (canGoNext ? () => setState(() => _currentStep++) : null)
: (_isLoading ? null : _handleSignup),
onPressed: _currentStep < 4
? (canGoNext
? () => setState(() => _currentStep++)
: null)
: (_isLoading ? null : _handleSignup),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(55),
backgroundColor: Colors.black,
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(
_currentStep < 4
? tr('ui.userfront.signup.next_step', fallback: '다음 단계')
: tr('ui.userfront.signup.complete', fallback: '가입 완료'),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
_currentStep < 4
? tr(
'ui.userfront.signup.next_step',
fallback: '다음 단계',
)
: tr(
'ui.userfront.signup.complete',
fallback: '가입 완료',
),
),
),
),
],