From 8df95c8a13f6206fe1b4ccb9b87968163a45da3d Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Tue, 10 Feb 2026 19:13:00 +0900 Subject: [PATCH] =?UTF-8?q?i18n=20=EB=8C=80=EB=8C=80=EC=A0=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/Dockerfile | 2 +- .../lib/core/services/audit_service.dart | 9 +- .../lib/core/services/auth_proxy_service.dart | 190 +- .../core/services/auth_token_store_web.dart | 2 + .../services/web_auth_integration_stub.dart | 4 +- .../services/web_auth_integration_web.dart | 11 +- .../lib/core/services/web_window_web.dart | 2 + .../presentation/user_management_screen.dart | 3 +- .../auth/presentation/consent_screen.dart | 12 +- .../auth/presentation/error_screen.dart | 52 +- .../presentation/forgot_password_screen.dart | 70 +- .../auth/presentation/login_screen.dart | 542 ++++-- .../presentation/login_success_screen.dart | 21 +- .../auth/presentation/qr_scan_screen.dart | 49 +- .../presentation/reset_password_screen.dart | 162 +- .../auth/presentation/signup_screen.dart | 586 +++++- .../dashboard/domain/dashboard_providers.dart | 20 +- .../presentation/dashboard_screen.dart | 610 +++++-- .../data/repositories/profile_repository.dart | 69 +- .../presentation/pages/profile_page.dart | 333 +++- userfront/lib/i18n.dart | 40 + userfront/lib/i18n_data.dart | 1612 +++++++++++++++++ userfront/lib/main.dart | 10 +- userfront/pubspec.lock | 2 +- userfront/pubspec.yaml | 2 + userfront/test/dashboard_providers_test.dart | 19 +- userfront/test/error_screen_test.dart | 70 +- 27 files changed, 3910 insertions(+), 594 deletions(-) create mode 100644 userfront/lib/i18n.dart create mode 100644 userfront/lib/i18n_data.dart diff --git a/userfront/Dockerfile b/userfront/Dockerfile index 47052a28..aab98a00 100644 --- a/userfront/Dockerfile +++ b/userfront/Dockerfile @@ -7,7 +7,7 @@ COPY . . # Get dependencies and build for web RUN flutter pub get RUN touch .env -RUN flutter build web --release --no-tree-shake-icons +RUN flutter build web --release --no-tree-shake-icons --wasm # Stage 2: Serve with Nginx FROM nginx:alpine diff --git a/userfront/lib/core/services/audit_service.dart b/userfront/lib/core/services/audit_service.dart index 5c37ed76..ab39f906 100644 --- a/userfront/lib/core/services/audit_service.dart +++ b/userfront/lib/core/services/audit_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -33,12 +34,14 @@ class AuditService { ); if (response.statusCode >= 200 && response.statusCode < 300) { - print("Audit log sent successfully"); + debugPrint('Audit log sent successfully'); } else { - print("Failed to send audit log: ${response.statusCode} ${response.body}"); + debugPrint( + 'Failed to send audit log: ${response.statusCode} ${response.body}', + ); } } catch (e) { - print("Error sending audit log: $e"); + debugPrint('Error sending audit log: $e'); } } } diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 50658953..c5431ed2 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:userfront/i18n.dart'; import 'http_client.dart'; import 'web_window.dart'; import 'auth_token_store.dart'; @@ -30,13 +31,26 @@ class AuthProxyService { return drySend == true; } + static Exception _error(String key, String fallback, {String? detail}) { + return Exception( + tr( + key, + fallback: fallback, + params: detail != null ? {'error': detail} : null, + ), + ); + } + static Future> fetchPasswordPolicy() async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy'); final response = await http.get(url); if (response.statusCode == 200) { return jsonDecode(response.body); } else { - throw Exception('Failed to fetch password policy'); + throw _error( + 'err.userfront.auth_proxy.password_policy_fetch', + '비밀번호 정책을 불러오지 못했습니다.', + ); } } @@ -52,7 +66,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); } - throw Exception('Failed to load profile: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.profile_load', + '프로필을 불러오지 못했습니다: {{error}}', + detail: response.body, + ); } finally { client.close(); } @@ -107,7 +125,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); } else { - throw Exception('Failed to init login: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.login_init', + '로그인 초기화에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -128,7 +150,11 @@ class AuthProxyService { if (response.statusCode == 400) { return jsonDecode(response.body); } - throw Exception('Polling failed: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.login_poll', + '로그인 상태 확인에 실패했습니다: {{error}}', + detail: response.body, + ); } static Future> verifyMagicLink(String token, {bool verifyOnly = false}) async { @@ -146,7 +172,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); } else { - throw Exception('Verification failed: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.verify_failed', + '검증에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -176,7 +206,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); } else { - throw Exception('Verification failed: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.verify_failed', + '검증에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -198,7 +232,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); } else { - throw Exception('Verification failed: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.verify_failed', + '검증에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -225,7 +263,13 @@ class AuthProxyService { return data; } else { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? 'Failed to login'); + throw Exception( + errorBody['error'] ?? + tr( + 'err.userfront.auth_proxy.login_failed', + fallback: '로그인에 실패했습니다.', + ), + ); } } static Future> getConsentInfo(String consentChallenge) async { @@ -239,7 +283,13 @@ class AuthProxyService { return jsonDecode(response.body); } else { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? 'Failed to get consent info'); + throw Exception( + errorBody['error'] ?? + tr( + 'err.userfront.auth_proxy.consent_fetch', + fallback: '동의 정보를 가져오지 못했습니다.', + ), + ); } } @@ -262,7 +312,13 @@ class AuthProxyService { return jsonDecode(response.body); } else { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? 'Failed to accept consent'); + throw Exception( + errorBody['error'] ?? + tr( + 'err.userfront.auth_proxy.consent_accept', + fallback: '동의 처리에 실패했습니다.', + ), + ); } } @@ -282,7 +338,13 @@ class AuthProxyService { return jsonDecode(response.body); } else { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? 'Failed to reject consent'); + throw Exception( + errorBody['error'] ?? + tr( + 'err.userfront.auth_proxy.consent_reject', + fallback: '동의 거부에 실패했습니다.', + ), + ); } } @@ -311,7 +373,13 @@ class AuthProxyService { return jsonDecode(response.body); } else { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? 'Failed to accept OIDC login'); + throw Exception( + errorBody['error'] ?? + tr( + 'err.userfront.auth_proxy.oidc_accept', + fallback: 'OIDC 로그인 승인에 실패했습니다.', + ), + ); } } finally { client.close(); @@ -334,7 +402,13 @@ class AuthProxyService { return jsonDecode(response.body); } else { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? 'Failed to initiate password reset'); + throw Exception( + errorBody['error'] ?? + tr( + 'err.userfront.auth_proxy.password_reset_init', + fallback: '비밀번호 재설정을 시작하지 못했습니다.', + ), + ); } } @@ -361,7 +435,13 @@ class AuthProxyService { return jsonDecode(response.body); } else { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? 'Failed to complete password reset'); + throw Exception( + errorBody['error'] ?? + tr( + 'err.userfront.auth_proxy.password_reset_complete', + fallback: '비밀번호 재설정에 실패했습니다.', + ), + ); } } @@ -377,7 +457,11 @@ class AuthProxyService { ); if (response.statusCode != 200) { - throw Exception('Failed to send SMS: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.sms_send', + 'SMS 전송에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -396,7 +480,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); } else { - throw Exception('Failed to verify code: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.code_verify', + '인증 코드 확인에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -410,7 +498,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); } else { - throw Exception('Failed to init QR login: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.qr_init', + 'QR 로그인을 시작하지 못했습니다: {{error}}', + detail: response.body, + ); } } @@ -428,7 +520,11 @@ class AuthProxyService { if (response.statusCode == 400) { return jsonDecode(response.body); } - throw Exception('QR Polling failed: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.qr_poll', + 'QR 상태 확인에 실패했습니다: {{error}}', + detail: response.body, + ); } static Future approveQrLogin( @@ -462,7 +558,11 @@ class AuthProxyService { )); if (response.statusCode != 200) { - throw Exception('QR Approval failed: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.qr_approve', + 'QR 승인에 실패했습니다: {{error}}', + detail: response.body, + ); } } finally { client?.close(); @@ -509,7 +609,11 @@ class AuthProxyService { ); if (response.statusCode != 200) { - throw Exception('Failed to create user: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.user_create', + '사용자 생성에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -531,7 +635,11 @@ class AuthProxyService { final data = jsonDecode(response.body); return data['users'] ?? []; } else { - throw Exception('Failed to list users: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.user_list', + '사용자 목록 조회에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -548,7 +656,11 @@ class AuthProxyService { ); if (response.statusCode != 200) { - throw Exception('Failed to delete user: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.user_delete', + '사용자 삭제에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -566,7 +678,11 @@ class AuthProxyService { ); if (response.statusCode != 200) { - throw Exception('Failed to update status: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.user_status_update', + '상태 업데이트에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -595,7 +711,11 @@ class AuthProxyService { ); if (response.statusCode != 200) { - throw Exception('Failed to update user: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.user_update', + '사용자 수정에 실패했습니다: {{error}}', + detail: response.body, + ); } } @@ -622,7 +742,10 @@ class AuthProxyService { final data = jsonDecode(response.body); return data['items'] ?? []; } else { - throw Exception('연동된 앱 목록을 불러오지 못했습니다.'); + throw _error( + 'err.userfront.auth_proxy.linked_apps_load', + '연동된 앱 목록을 불러오지 못했습니다.', + ); } } finally { client.close(); @@ -650,7 +773,13 @@ class AuthProxyService { if (response.statusCode != 200) { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? '연동 해지에 실패했습니다.'); + throw Exception( + errorBody['error'] ?? + tr( + 'err.userfront.auth_proxy.linked_app_revoke', + fallback: '연동 해지에 실패했습니다.', + ), + ); } } finally { client.close(); @@ -688,7 +817,6 @@ class AuthProxyService { } static int _clientLogFailureCount = 0; - static DateTime? _clientLogLastFailureAt; static DateTime? _clientLogOpenUntil; static bool _canSendClientLog() { @@ -702,7 +830,6 @@ class AuthProxyService { static void _recordClientLogFailure() { _clientLogFailureCount += 1; - _clientLogLastFailureAt = DateTime.now(); if (_clientLogFailureCount >= 3) { _clientLogOpenUntil = DateTime.now().add(const Duration(minutes: 1)); _clientLogFailureCount = 0; @@ -711,7 +838,6 @@ class AuthProxyService { static void _recordClientLogSuccess() { _clientLogFailureCount = 0; - _clientLogLastFailureAt = null; _clientLogOpenUntil = null; } @@ -743,7 +869,11 @@ class AuthProxyService { ); if (response.statusCode != 200) { - throw Exception('Failed to send code: ${response.body}'); + throw _error( + 'err.userfront.auth_proxy.phone_code_send', + '인증 코드 전송에 실패했습니다: {{error}}', + detail: response.body, + ); } } diff --git a/userfront/lib/core/services/auth_token_store_web.dart b/userfront/lib/core/services/auth_token_store_web.dart index dc7c6851..8fb4f26b 100644 --- a/userfront/lib/core/services/auth_token_store_web.dart +++ b/userfront/lib/core/services/auth_token_store_web.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use + import 'dart:html' as html; class AuthTokenStore { diff --git a/userfront/lib/core/services/web_auth_integration_stub.dart b/userfront/lib/core/services/web_auth_integration_stub.dart index 61b26fa7..58ef5014 100644 --- a/userfront/lib/core/services/web_auth_integration_stub.dart +++ b/userfront/lib/core/services/web_auth_integration_stub.dart @@ -1,6 +1,8 @@ +import 'package:flutter/foundation.dart'; + void implSendLoginSuccess(String token) { // No-op on non-web platforms - print("Not on web: Login Success with token: $token"); + debugPrint('Not on web: Login Success with token: $token'); } bool implIsPopup() { diff --git a/userfront/lib/core/services/web_auth_integration_web.dart b/userfront/lib/core/services/web_auth_integration_web.dart index 2f0efbcc..7e227de3 100644 --- a/userfront/lib/core/services/web_auth_integration_web.dart +++ b/userfront/lib/core/services/web_auth_integration_web.dart @@ -1,5 +1,8 @@ -import 'dart:html' as html; +// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use + import 'dart:async'; +import 'dart:html' as html; +import 'package:flutter/foundation.dart'; void implSendLoginSuccess(String token) { final message = {'type': 'LOGIN_SUCCESS', 'token': token}; @@ -7,9 +10,9 @@ void implSendLoginSuccess(String token) { if (html.window.opener != null) { try { html.window.opener!.postMessage(message, '*'); - print("Sent login success message to opener"); + debugPrint('Sent login success message to opener'); } catch (e) { - print("Failed to postMessage: $e"); + debugPrint('Failed to postMessage: $e'); } // Close the popup after a short delay to ensure message sending @@ -18,7 +21,7 @@ void implSendLoginSuccess(String token) { }); } else { // Should not happen given isPopup check, but as fallback: - print("No opener found during popup flow."); + debugPrint('No opener found during popup flow.'); } } diff --git a/userfront/lib/core/services/web_window_web.dart b/userfront/lib/core/services/web_window_web.dart index 6a9c7079..1f8f0c67 100644 --- a/userfront/lib/core/services/web_window_web.dart +++ b/userfront/lib/core/services/web_window_web.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use + import 'dart:html' as html; class WebWindow { diff --git a/userfront/lib/features/admin/presentation/user_management_screen.dart b/userfront/lib/features/admin/presentation/user_management_screen.dart index 94ea9c57..499378be 100644 --- a/userfront/lib/features/admin/presentation/user_management_screen.dart +++ b/userfront/lib/features/admin/presentation/user_management_screen.dart @@ -345,10 +345,9 @@ class _UserManagementScreenState extends State with Single ? const Center(child: Text("No users found.")) : ListView.separated( itemCount: _users.length, - separatorBuilder: (_, __) => const Divider(), + separatorBuilder: (context, index) => const Divider(), itemBuilder: (context, index) { final user = _users[index]; - final userObj = user['user'] ?? {}; // 응답 구조가 케이스마다 다를 수 있음 // 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다. final loginIDs = (user['loginIds'] as List?) ?? []; diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 7e5c7a2c..3699996d 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -13,12 +13,6 @@ class ConsentScreen extends StatefulWidget { } class _ConsentScreenState extends State { - static const _ink = Color(0xFF1A1F2C); - static const _surface = Colors.white; - static const _border = Color(0xFFE5E7EB); - static const _subtle = Color(0xFFF7F8FA); - static const _accent = Color(0xFF2563EB); - Map? _consentInfo; bool _isLoading = true; bool _isSubmitting = false; @@ -28,7 +22,7 @@ class _ConsentScreenState extends State { final Set _selectedScopes = {}; // 권한별 설명 매핑 (동적으로 업데이트됨) - Map _scopeDescriptions = { + final Map _scopeDescriptions = { 'openid': 'OpenID 인증 정보 (로그인 상태 확인)', 'profile': '기본 프로필 정보 (이름, 사용자 식별자)', 'email': '이메일 주소 (계정 식별 및 알림 용도)', @@ -37,7 +31,7 @@ class _ConsentScreenState extends State { }; // 필수 권한 목록 (동적으로 업데이트됨) - Set _mandatoryScopes = {'openid'}; + final Set _mandatoryScopes = {'openid'}; @override void initState() { @@ -333,7 +327,7 @@ class _ConsentScreenState extends State { contentPadding: EdgeInsets.zero, activeColor: Theme.of(context).primaryColor, ); - }).toList(), + }), const Divider(), const SizedBox(height: 32), diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index e9419f75..c3d8113b 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/error_whitelist.dart'; import '../../../core/services/auth_proxy_service.dart'; +import 'package:userfront/i18n.dart'; class ErrorScreen extends StatelessWidget { final String? errorId; @@ -23,19 +24,38 @@ class ErrorScreen extends StatelessWidget { final isProd = isProdOverride ?? AuthProxyService.isProdEnv; final normalizedCode = (errorCode ?? '').trim(); final hasCode = normalizedCode.isNotEmpty; - final whitelistMessage = errorWhitelistMessages[normalizedCode]; - final isWhitelisted = whitelistMessage != null; + final whitelistFallback = errorWhitelistMessages[normalizedCode]; + final isWhitelisted = whitelistFallback != null; final errorType = isProd ? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error') : (hasCode ? normalizedCode : 'unknown_error'); final title = isProd - ? '인증 과정에서 오류가 발생했습니다' - : (hasCode ? '오류: $normalizedCode' : '오류가 발생했습니다'); + ? 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: '오류가 발생했습니다')); final detail = isProd - ? (isWhitelisted ? whitelistMessage! : '에러가 계속되면 관리자에게 문의해주세요') + ? (isWhitelisted + ? tr( + 'msg.userfront.error.whitelist.$normalizedCode', + fallback: whitelistFallback, + ) + : tr( + 'msg.userfront.error.detail_contact', + fallback: '에러가 계속되면 관리자에게 문의해주세요', + )) : ((description?.isNotEmpty == true) ? description! - : (hasCode ? '오류가 발생했습니다.' : '요청을 처리하는 중 문제가 발생했습니다.')); + : (hasCode + ? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.') + : tr( + 'msg.userfront.error.detail_request', + fallback: '요청을 처리하는 중 문제가 발생했습니다.', + ))); return Scaffold( backgroundColor: const Color(0xFFF7F8FA), @@ -72,7 +92,11 @@ class ErrorScreen extends StatelessWidget { ), const SizedBox(height: 12), Text( - '오류 종류: $errorType', + tr( + 'msg.userfront.error.type', + fallback: '오류 종류: {{type}}', + params: {'type': errorType}, + ), style: theme.textTheme.bodySmall?.copyWith( color: const Color(0xFF6B7280), ), @@ -80,7 +104,11 @@ class ErrorScreen extends StatelessWidget { if (errorId != null && errorId!.isNotEmpty) ...[ const SizedBox(height: 12), Text( - '오류 ID: $errorId', + tr( + 'msg.userfront.error.id', + fallback: '오류 ID: {{id}}', + params: {'id': errorId!}, + ), style: theme.textTheme.bodySmall?.copyWith( color: const Color(0xFF6B7280), ), @@ -101,7 +129,9 @@ class ErrorScreen extends StatelessWidget { borderRadius: BorderRadius.circular(10), ), ), - child: const Text('로그인으로 이동'), + child: Text( + tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'), + ), ), OutlinedButton( onPressed: () => context.go('/'), @@ -113,7 +143,9 @@ class ErrorScreen extends StatelessWidget { borderRadius: BorderRadius.circular(10), ), ), - child: const Text('홈으로 이동'), + child: Text( + tr('ui.userfront.error.go_home', fallback: '홈으로 이동'), + ), ), ], ), diff --git a/userfront/lib/features/auth/presentation/forgot_password_screen.dart b/userfront/lib/features/auth/presentation/forgot_password_screen.dart index fdb83611..59ad5fd7 100644 --- a/userfront/lib/features/auth/presentation/forgot_password_screen.dart +++ b/userfront/lib/features/auth/presentation/forgot_password_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../core/services/auth_proxy_service.dart'; +import 'package:userfront/i18n.dart'; class ForgotPasswordScreen extends StatefulWidget { const ForgotPasswordScreen({super.key}); @@ -22,7 +23,12 @@ class _ForgotPasswordScreenState extends State { Future _handlePasswordReset() async { final input = _loginIdController.text.trim(); if (input.isEmpty) { - _showError("이메일 또는 휴대폰 번호를 입력해주세요."); + _showError( + tr( + 'msg.userfront.forgot.input_required', + fallback: '이메일 또는 휴대폰 번호를 입력해주세요.', + ), + ); return; } @@ -41,8 +47,13 @@ class _ForgotPasswordScreenState extends State { await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."), + SnackBar( + content: Text( + tr( + 'msg.userfront.forgot.sent', + fallback: '비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요.', + ), + ), backgroundColor: Colors.green, ), ); @@ -50,7 +61,13 @@ class _ForgotPasswordScreenState extends State { } } catch (e) { if (mounted) { - _showError("전송에 실패했습니다: ${e.toString()}"); + _showError( + tr( + 'msg.userfront.forgot.error', + fallback: '전송에 실패했습니다: {{error}}', + params: {'error': e.toString()}, + ), + ); } } finally { if (mounted) { @@ -77,7 +94,7 @@ class _ForgotPasswordScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("비밀번호 재설정"), + title: Text(tr('ui.userfront.forgot.title', fallback: '비밀번호 재설정')), centerTitle: true, ), body: Center( @@ -89,7 +106,7 @@ class _ForgotPasswordScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - "비밀번호를 잊으셨나요?", + tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'), style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, @@ -106,13 +123,16 @@ class _ForgotPasswordScreenState extends State { border: Border.all(color: const Color(0xFFFFC107)), ), child: Row( - children: const [ - Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), - SizedBox(width: 8), + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), + const SizedBox(width: 8), Expanded( child: Text( - "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.", - style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12), + tr( + 'msg.userfront.forgot.dry_send', + fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.', + ), + style: const TextStyle(color: Color(0xFF8A6D3B), fontSize: 12), ), ), ], @@ -120,18 +140,25 @@ class _ForgotPasswordScreenState extends State { ), ], const SizedBox(height: 16), - const Text( - "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.", + Text( + tr( + 'msg.userfront.forgot.description', + fallback: + '계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.', + ), textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), + style: const TextStyle(color: Colors.grey), ), const SizedBox(height: 40), TextField( controller: _loginIdController, - decoration: const InputDecoration( - labelText: "이메일 또는 휴대폰 번호", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.person_outline), + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.forgot.input_label', + fallback: '이메일 또는 휴대폰 번호', + ), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.person_outline), ), onSubmitted: (_) => _handlePasswordReset(), ), @@ -147,7 +174,12 @@ class _ForgotPasswordScreenState extends State { width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) - : const Text("재설정 링크 전송"), + : Text( + tr( + 'ui.userfront.forgot.submit', + fallback: '재설정 링크 전송', + ), + ), ), ], ), diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 4976f54b..459b9363 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -5,6 +5,7 @@ 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 'package:userfront/i18n.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; @@ -51,10 +52,18 @@ class _LoginScreenState extends ConsumerState bool _verificationOnly = false; bool _verificationApproved = false; String _verificationMessage = ''; - String _verificationTitle = '승인 완료'; - String _verificationPageTitle = '로그인 승인'; - String _verificationActionLabel = '확인'; - String _verificationActionPath = '/'; + String _verificationTitle = tr( + 'ui.userfront.login.verification.title', + fallback: '승인 완료', + ); + String _verificationPageTitle = tr( + 'ui.userfront.login.verification.page_title', + fallback: '로그인 승인', + ); + String _verificationActionLabel = tr( + 'ui.userfront.login.verification.action_label', + fallback: '확인', + ); Timer? _verificationRedirectTimer; bool _noticeHandled = false; bool _drySendEnabled = false; @@ -92,7 +101,12 @@ class _LoginScreenState extends ConsumerState if (!_noticeHandled && notice == 'qr_login_required') { _noticeHandled = true; - _showInfo('로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다'); + _showInfo( + tr( + 'msg.userfront.login.qr_login_required', + fallback: '로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다', + ), + ); } if (!_verificationOnly) { @@ -125,7 +139,15 @@ class _LoginScreenState extends ConsumerState } } catch (e) { if (!silent) { - _showError("로그인 확인 실패: ${e.toString().replaceFirst("Exception: ", "")}"); + _showError( + tr( + 'msg.userfront.login.cookie_check_failed', + fallback: '로그인 확인 실패: {{error}}', + params: { + 'error': e.toString().replaceFirst('Exception: ', ''), + }, + ), + ); } } } @@ -294,7 +316,13 @@ class _LoginScreenState extends ConsumerState _startCountdown(); } } catch (e) { - _showError("Failed to init QR: $e"); + _showError( + tr( + 'msg.userfront.login.qr_init_failed', + fallback: 'QR 초기화에 실패했습니다: {{error}}', + params: {'error': e.toString()}, + ), + ); if (mounted) setState(() => _isQrLoading = false); } } @@ -346,7 +374,12 @@ class _LoginScreenState extends ConsumerState if (res['error'] == 'expired_token') { timer.cancel(); _qrCountdownTimer?.cancel(); - _showError("QR 세션이 만료되었습니다."); + _showError( + tr( + 'msg.userfront.login.qr_expired', + fallback: 'QR 세션이 만료되었습니다.', + ), + ); return; } @@ -357,7 +390,12 @@ class _LoginScreenState extends ConsumerState if (token is String && token.isNotEmpty) { _completeLoginFromToken(token); } else { - _showError("로그인 토큰을 확인할 수 없습니다."); + _showError( + tr( + 'msg.userfront.login.token_missing', + fallback: '로그인 토큰을 확인할 수 없습니다.', + ), + ); } } } catch (e) { @@ -423,21 +461,35 @@ class _LoginScreenState extends ConsumerState void _markVerificationApproved( String message, { - String title = '승인 완료', - String pageTitle = '로그인 승인', - String actionLabel = '확인', + String? title, + String? pageTitle, + String? actionLabel, String actionPath = '/', bool autoRedirect = false, 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: '확인', + ); setState(() { _verificationApproved = true; _verificationMessage = message; - _verificationTitle = title; - _verificationPageTitle = pageTitle; - _verificationActionLabel = actionLabel; - _verificationActionPath = actionPath; + _verificationTitle = resolvedTitle; + _verificationPageTitle = resolvedPageTitle; + _verificationActionLabel = resolvedActionLabel; }); _verificationRedirectTimer?.cancel(); if (autoRedirect) { @@ -463,7 +515,12 @@ class _LoginScreenState extends ConsumerState ), const SizedBox(height: 12), Text( - _verificationMessage.isEmpty ? '로그인 승인에 성공했습니다.' : _verificationMessage, + _verificationMessage.isEmpty + ? tr( + 'msg.userfront.login.verification.success', + fallback: '로그인 승인에 성공했습니다.', + ) + : _verificationMessage, textAlign: TextAlign.center, style: const TextStyle(color: Colors.black54), ), @@ -490,6 +547,14 @@ class _LoginScreenState extends ConsumerState Future _verifyToken(String token) async { debugPrint("[Auth] Starting verification for token: $token"); + final approvedMessage = tr( + 'msg.userfront.login.verification.approved', + fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.', + ); + final localSessionMessage = tr( + 'msg.userfront.login.verification.approved_local', + fallback: '승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다', + ); try { // Use Backend to verify the token (Backend-Driven Flow) final res = await AuthProxyService.verifyMagicLink( @@ -505,7 +570,7 @@ class _LoginScreenState extends ConsumerState if (status == 'approved' || (jwt == null && _verificationOnly)) { if (mounted) { _markVerificationApproved( - "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + approvedMessage, actionPath: actionPath, ); } @@ -515,13 +580,13 @@ class _LoginScreenState extends ConsumerState if (jwt is String && jwt.isNotEmpty) { if (hasLocalSession) { _markVerificationApproved( - "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + localSessionMessage, actionPath: actionPath, ); return; } _markVerificationApproved( - "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + approvedMessage, actionPath: actionPath, ); return; @@ -529,14 +594,20 @@ class _LoginScreenState extends ConsumerState if (mounted) { _markVerificationApproved( - "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + approvedMessage, actionPath: actionPath, ); } } catch (e) { debugPrint("[Auth] Verification FAILED for token: $token. Error: $e"); if (mounted) { - _showError("Verification failed: $e"); + _showError( + tr( + 'msg.userfront.login.verification_failed', + fallback: '승인 처리에 실패했습니다: {{error}}', + params: {'error': e.toString()}, + ), + ); } } } @@ -544,6 +615,18 @@ class _LoginScreenState extends ConsumerState Future _verifyLoginCode(String loginId, String code, {String? pendingRef}) async { final sanitizedLoginId = loginId.replaceAll(' ', '+'); debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId"); + final approvedMessage = tr( + 'msg.userfront.login.verification.approved', + fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.', + ); + final localSessionMessage = tr( + 'msg.userfront.login.verification.approved_local', + fallback: '승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다', + ); + final linkLoginMessage = tr( + 'msg.userfront.login.link.approved', + fallback: '링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.', + ); try { final res = await AuthProxyService.verifyLoginCode( sanitizedLoginId, @@ -560,7 +643,7 @@ class _LoginScreenState extends ConsumerState if (jwt == null && status == 'approved') { if (mounted) { _markVerificationApproved( - "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + approvedMessage, actionPath: actionPath, ); } @@ -570,22 +653,32 @@ class _LoginScreenState extends ConsumerState if (jwt is String && jwt.isNotEmpty) { if (hasLocalSession) { _markVerificationApproved( - "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + localSessionMessage, actionPath: actionPath, ); return; } if (_verificationOnly) { _markVerificationApproved( - "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + approvedMessage, actionPath: actionPath, ); return; } - _markVerificationApproved("링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.", - title: '링크 로그인 완료', - pageTitle: '링크 로그인', - actionLabel: '로그인 화면으로 이동', + _markVerificationApproved( + linkLoginMessage, + title: tr( + 'ui.userfront.login.link.title', + fallback: '링크 로그인 완료', + ), + pageTitle: tr( + 'ui.userfront.login.link.page_title', + fallback: '링크 로그인', + ), + actionLabel: tr( + 'ui.userfront.login.link.action_label', + fallback: '로그인 화면으로 이동', + ), actionPath: '/signin', autoRedirect: true, ); @@ -594,14 +687,20 @@ class _LoginScreenState extends ConsumerState if (_verificationOnly && mounted) { _markVerificationApproved( - "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + approvedMessage, actionPath: actionPath, ); } } catch (e) { debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e"); if (mounted) { - _showError("Verification failed: $e"); + _showError( + tr( + 'msg.userfront.login.verification_failed', + fallback: '승인 처리에 실패했습니다: {{error}}', + params: {'error': e.toString()}, + ), + ); } } } @@ -610,6 +709,14 @@ class _LoginScreenState extends ConsumerState final sanitized = shortCode.trim().toUpperCase(); if (sanitized.isEmpty) return; debugPrint("[Auth] Starting short code verification for code: $sanitized"); + final approvedMessage = tr( + 'msg.userfront.login.verification.approved', + fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.', + ); + final localSessionMessage = tr( + 'msg.userfront.login.verification.approved_local', + fallback: '승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다', + ); try { final res = await AuthProxyService.verifyLoginShortCode( sanitized, @@ -624,7 +731,7 @@ class _LoginScreenState extends ConsumerState if (jwt == null && status == 'approved') { if (mounted) { _markVerificationApproved( - "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + approvedMessage, actionPath: actionPath, ); } @@ -634,14 +741,14 @@ class _LoginScreenState extends ConsumerState if (jwt is String && jwt.isNotEmpty) { if (hasLocalSession) { _markVerificationApproved( - "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + localSessionMessage, actionPath: actionPath, ); return; } if (_verificationOnly) { _markVerificationApproved( - "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + approvedMessage, actionPath: actionPath, ); return; @@ -652,14 +759,20 @@ class _LoginScreenState extends ConsumerState if (_verificationOnly && mounted) { _markVerificationApproved( - "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + approvedMessage, actionPath: actionPath, ); } } catch (e) { debugPrint("[Auth] Short code verification FAILED. Error: $e"); if (mounted) { - _showError("Verification failed: $e"); + _showError( + tr( + 'msg.userfront.login.verification_failed', + fallback: '승인 처리에 실패했습니다: {{error}}', + params: {'error': e.toString()}, + ), + ); } } } @@ -682,7 +795,12 @@ class _LoginScreenState extends ConsumerState final input = _passwordLoginIdController.text.trim(); final password = _passwordController.text.trim(); if (input.isEmpty || password.isEmpty) { - _showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요."); + _showError( + tr( + 'msg.userfront.login.password.missing_credentials', + fallback: '이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.', + ), + ); return; } @@ -721,7 +839,15 @@ class _LoginScreenState extends ConsumerState if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { - _showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}"); + _showError( + tr( + 'msg.userfront.login.password.failed', + fallback: '로그인 실패: {{error}}', + params: { + 'error': e.toString().replaceFirst('Exception: ', ''), + }, + ), + ); } } } @@ -746,7 +872,13 @@ class _LoginScreenState extends ConsumerState if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { - _showError("오류: $e"); + _showError( + tr( + 'msg.userfront.login.link_failed', + fallback: '오류: {{error}}', + params: {'error': e.toString()}, + ), + ); } } } @@ -782,9 +914,17 @@ class _LoginScreenState extends ConsumerState }); Navigator.of(context).pop(); - _showInfo(isEmail - ? "입력하신 이메일로 로그인 링크를 보냈습니다." - : "입력하신 번호로 로그인 링크를 보냈습니다."); + _showInfo( + isEmail + ? tr( + 'msg.userfront.login.link_sent_email', + fallback: '입력하신 이메일로 로그인 링크를 보냈습니다.', + ) + : tr( + 'msg.userfront.login.link_sent_phone', + fallback: '입력하신 번호로 로그인 링크를 보냈습니다.', + ), + ); final initialInterval = (interval is int && interval > 0) ? Duration(seconds: interval) @@ -806,7 +946,13 @@ class _LoginScreenState extends ConsumerState if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { - _showError("전송 실패: $e"); + _showError( + tr( + 'msg.userfront.login.link_send_failed', + fallback: '전송 실패: {{error}}', + params: {'error': e.toString()}, + ), + ); } } } @@ -842,7 +988,12 @@ class _LoginScreenState extends ConsumerState if (result['error'] == 'expired_token') { if (mounted) { Navigator.of(context).pop(); - _showError("Login timed out."); + _showError( + tr( + 'msg.userfront.login.link_timeout', + fallback: '로그인 요청 시간이 초과되었습니다.', + ), + ); } return; } @@ -862,7 +1013,12 @@ class _LoginScreenState extends ConsumerState if (mounted && Navigator.canPop(context)) { Navigator.of(context).pop(); } - _showError("로그인 토큰을 확인할 수 없습니다."); + _showError( + tr( + 'msg.userfront.login.token_missing', + fallback: '로그인 토큰을 확인할 수 없습니다.', + ), + ); return; } } catch (e) { @@ -873,7 +1029,12 @@ class _LoginScreenState extends ConsumerState if (mounted) { debugPrint("[Auth] Polling timed out for ref: $pendingRef"); Navigator.of(context).pop(); - _showError("Login timed out."); + _showError( + tr( + 'msg.userfront.login.link_timeout', + fallback: '로그인 요청 시간이 초과되었습니다.', + ), + ); } } @@ -950,7 +1111,12 @@ class _LoginScreenState extends ConsumerState return; } } catch (e) { - _showError("OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."); + _showError( + tr( + 'msg.userfront.login.oidc_failed', + fallback: 'OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요.', + ), + ); return; } } @@ -978,12 +1144,19 @@ class _LoginScreenState extends ConsumerState showDialog( context: context, builder: (context) => AlertDialog( - title: const Text("미등록 회원"), - content: const Text("가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요."), + title: Text( + tr('ui.userfront.login.unregistered.title', fallback: '미등록 회원'), + ), + content: Text( + tr( + 'msg.userfront.login.unregistered.body', + fallback: '가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요.', + ), + ), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text("취소"), + child: Text(tr('ui.common.cancel', fallback: '취소')), ), FilledButton( onPressed: () { @@ -991,7 +1164,9 @@ class _LoginScreenState extends ConsumerState _resetLinkLoginState(); context.push('/signup'); }, - child: const Text("회원가입 하기"), + child: Text( + tr('ui.userfront.login.unregistered.action', fallback: '회원가입 하기'), + ), ), ], ), @@ -1028,8 +1203,8 @@ class _LoginScreenState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - "Baron 로그인", - style: TextStyle( + tr('ui.userfront.app_title', fallback: 'Baron 로그인'), + style: const TextStyle( fontSize: 32, fontWeight: FontWeight.bold, ), @@ -1045,13 +1220,19 @@ class _LoginScreenState extends ConsumerState border: Border.all(color: const Color(0xFFFFC107)), ), child: Row( - children: const [ - Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), - SizedBox(width: 8), + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), + const SizedBox(width: 8), Expanded( child: Text( - "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.", - style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12), + tr( + 'msg.userfront.login.dry_send', + fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.', + ), + style: const TextStyle( + color: Color(0xFF8A6D3B), + fontSize: 12, + ), ), ), ], @@ -1062,10 +1243,25 @@ class _LoginScreenState extends ConsumerState TabBar( controller: _tabController, - tabs: const [ - Tab(text: "비밀번호"), - Tab(text: "로그인 링크"), - Tab(text: "QR 코드"), + tabs: [ + Tab( + text: tr( + 'ui.userfront.login.tabs.password', + fallback: '비밀번호', + ), + ), + Tab( + text: tr( + 'ui.userfront.login.tabs.link', + fallback: '로그인 링크', + ), + ), + Tab( + text: tr( + 'ui.userfront.login.tabs.qr', + fallback: 'QR 코드', + ), + ), ], ), const SizedBox(height: 24), @@ -1081,10 +1277,13 @@ class _LoginScreenState extends ConsumerState children: [ TextField( controller: _passwordLoginIdController, - decoration: const InputDecoration( - labelText: "이메일 또는 휴대폰 번호", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.person_outline), + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.login_id', + fallback: '이메일 또는 휴대폰 번호', + ), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.person_outline), ), onSubmitted: (_) => _handlePasswordLogin(), ), @@ -1092,10 +1291,13 @@ class _LoginScreenState extends ConsumerState TextField( controller: _passwordController, obscureText: true, - decoration: const InputDecoration( - labelText: "비밀번호", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.lock_outline), + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.password', + fallback: '비밀번호', + ), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.lock_outline), ), onSubmitted: (_) => _handlePasswordLogin(), ), @@ -1105,7 +1307,9 @@ class _LoginScreenState extends ConsumerState style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(50), ), - child: const Text("로그인"), + child: Text( + tr('ui.userfront.login.action.submit', fallback: '로그인'), + ), ), ], ), @@ -1118,11 +1322,14 @@ class _LoginScreenState extends ConsumerState if (_linkPendingRef == null) ...[ TextField( controller: _linkIdController, - decoration: const InputDecoration( - labelText: "이메일 또는 휴대폰 번호", - hintText: "", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.person_outline), + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.login_id', + fallback: '이메일 또는 휴대폰 번호', + ), + hintText: '', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.person_outline), ), onSubmitted: (_) => _handleLinkLogin(), ), @@ -1132,19 +1339,30 @@ class _LoginScreenState extends ConsumerState style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(50), ), - child: const Text("로그인 링크 전송"), + child: Text( + tr( + 'ui.userfront.login.link.send', + fallback: '로그인 링크 전송', + ), + ), ), const SizedBox(height: 24), - const Text( - "입력하신 정보로 로그인 링크를 전송합니다.", - style: TextStyle(color: Colors.grey, fontSize: 12), + Text( + tr( + 'msg.userfront.login.link.helper', + fallback: '입력하신 정보로 로그인 링크를 전송합니다.', + ), + style: const TextStyle(color: Colors.grey, fontSize: 12), textAlign: TextAlign.center, ), ], if (_linkPendingRef != null) ...[ - const Text( - "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.", - style: TextStyle(color: Colors.grey, fontSize: 12), + Text( + tr( + 'msg.userfront.login.link.short_code_help', + fallback: '링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.', + ), + style: const TextStyle(color: Colors.grey, fontSize: 12), textAlign: TextAlign.center, ), const SizedBox(height: 12), @@ -1155,11 +1373,14 @@ class _LoginScreenState extends ConsumerState child: TextField( controller: _shortCodePrefixController, textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration( - labelText: "영문 2자리", - border: OutlineInputBorder(), - hintText: "AB", - hintStyle: TextStyle(color: Colors.grey), + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.short_code.prefix', + fallback: '영문 2자리', + ), + border: const OutlineInputBorder(), + hintText: 'AB', + hintStyle: const TextStyle(color: Colors.grey), ), maxLength: 2, ), @@ -1171,12 +1392,21 @@ class _LoginScreenState extends ConsumerState controller: _shortCodeDigitsController, keyboardType: TextInputType.number, decoration: InputDecoration( - labelText: "숫자 6자리", + labelText: tr( + 'ui.userfront.login.short_code.digits', + fallback: '숫자 6자리', + ), border: const OutlineInputBorder(), - hintText: "345678", + hintText: '345678', hintStyle: const TextStyle(color: Colors.grey), suffixText: _linkExpireSeconds > 0 - ? "유효시간 ${_formatTime(_linkExpireSeconds)}" + ? tr( + 'ui.userfront.login.short_code.expire_time', + fallback: '유효시간 {{time}}', + params: { + 'time': _formatTime(_linkExpireSeconds), + }, + ) : null, ), maxLength: 6, @@ -1190,7 +1420,12 @@ class _LoginScreenState extends ConsumerState final prefix = _shortCodePrefixController.text.trim().toUpperCase(); final digits = _shortCodeDigitsController.text.trim(); if (prefix.length != 2 || digits.length != 6) { - _showError("문자 2개와 숫자 6자리를 입력해 주세요."); + _showError( + tr( + 'msg.userfront.login.short_code.invalid', + fallback: '문자 2개와 숫자 6자리를 입력해 주세요.', + ), + ); return; } _verifyShortCode(prefix + digits); @@ -1198,18 +1433,36 @@ class _LoginScreenState extends ConsumerState style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(45), ), - child: const Text("코드로 로그인"), + child: Text( + tr( + 'ui.userfront.login.short_code.submit', + fallback: '코드로 로그인', + ), + ), ), const SizedBox(height: 12), TextButton( onPressed: () { if (_linkResendSeconds > 0) { - _showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다."); + _showInfo( + tr( + 'msg.userfront.login.link.resend_wait', + fallback: '재발송은 {{time}} 후 가능합니다.', + params: { + 'time': _formatTime(_linkResendSeconds), + }, + ), + ); return; } final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); if (loginId.isEmpty) { - _showError("이메일 또는 휴대폰 번호를 입력해 주세요."); + _showError( + tr( + 'msg.userfront.login.link.missing_login_id', + fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.', + ), + ); return; } _startEnchantedFlow( @@ -1220,8 +1473,17 @@ class _LoginScreenState extends ConsumerState }, child: Text( _linkResendSeconds > 0 - ? "재발송 (${_formatTime(_linkResendSeconds)})" - : "재발송", + ? tr( + 'ui.userfront.login.link.resend_with_time', + fallback: '재발송 ({{time}})', + params: { + 'time': _formatTime(_linkResendSeconds), + }, + ) + : tr( + 'ui.common.resend', + fallback: '재발송', + ), ), ), if (!_lastLinkIsEmail) ...[ @@ -1229,12 +1491,25 @@ class _LoginScreenState extends ConsumerState TextButton( onPressed: () { if (_linkResendSeconds > 0) { - _showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다."); + _showInfo( + tr( + 'msg.userfront.login.link.resend_wait', + fallback: '재발송은 {{time}} 후 가능합니다.', + params: { + 'time': _formatTime(_linkResendSeconds), + }, + ), + ); return; } final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); if (loginId.isEmpty) { - _showError("휴대폰 번호를 입력해 주세요."); + _showError( + tr( + 'msg.userfront.login.link.missing_phone', + fallback: '휴대폰 번호를 입력해 주세요.', + ), + ); return; } _startEnchantedFlow( @@ -1243,7 +1518,15 @@ class _LoginScreenState extends ConsumerState codeOnly: true, ); }, - child: Text("코드만 받기(${_formatTime(_linkResendSeconds)})"), + child: Text( + tr( + 'ui.userfront.login.link.code_only', + fallback: '코드만 받기({{time}})', + params: { + 'time': _formatTime(_linkResendSeconds), + }, + ), + ), ), ], ], @@ -1276,8 +1559,17 @@ class _LoginScreenState extends ConsumerState const SizedBox(height: 12), Text( _qrRemainingSeconds > 0 - ? "남은 시간: ${_formatTime(_qrRemainingSeconds)}" - : "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, @@ -1285,19 +1577,33 @@ class _LoginScreenState extends ConsumerState ), ), const SizedBox(height: 8), - const Text( - "모바일 앱으로 스캔하세요", + Text( + tr( + 'msg.userfront.login.qr.scan_hint', + fallback: '모바일 앱으로 스캔하세요', + ), textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey, fontSize: 12), + style: const TextStyle(color: Colors.grey, fontSize: 12), ), TextButton( onPressed: _startQrFlow, - child: const Text("QR 코드 새로고침") + child: Text( + tr( + 'ui.userfront.login.qr.refresh', + fallback: 'QR 코드 새로고침', + ), + ), ), ], ) else - const Text("QR 코드를 불러오지 못했습니다.", textAlign: TextAlign.center), + Text( + tr( + 'msg.userfront.login.qr.load_failed', + fallback: 'QR 코드를 불러오지 못했습니다.', + ), + textAlign: TextAlign.center, + ), ], ), ], @@ -1308,15 +1614,31 @@ class _LoginScreenState extends ConsumerState children: [ TextButton( onPressed: () => context.push('/forgot-password'), - child: const Text("비밀번호를 잊으셨나요?"), + child: Text( + tr( + 'ui.userfront.login.forgot_password', + fallback: '비밀번호를 잊으셨나요?', + ), + ), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text("계정이 없으신가요?", style: TextStyle(color: Colors.grey, fontSize: 14)), + Text( + tr( + 'msg.userfront.login.no_account', + fallback: '계정이 없으신가요?', + ), + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), TextButton( onPressed: () => context.push('/signup'), - child: const Text("회원가입"), + child: Text( + tr( + 'ui.userfront.login.signup', + fallback: '회원가입', + ), + ), ), ], ), diff --git a/userfront/lib/features/auth/presentation/login_success_screen.dart b/userfront/lib/features/auth/presentation/login_success_screen.dart index eeb5a2ee..a492f9b4 100644 --- a/userfront/lib/features/auth/presentation/login_success_screen.dart +++ b/userfront/lib/features/auth/presentation/login_success_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:userfront/i18n.dart'; class LoginSuccessScreen extends StatelessWidget { const LoginSuccessScreen({super.key}); @@ -16,17 +17,17 @@ class LoginSuccessScreen extends StatelessWidget { 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, ), ), const SizedBox(height: 16), - const Text( - "성공적으로 로그인되었습니다.", + Text( + tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'), textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey, fontSize: 16), + style: const TextStyle(color: Colors.grey, fontSize: 16), ), const SizedBox(height: 48), @@ -36,7 +37,9 @@ class LoginSuccessScreen extends StatelessWidget { context.push('/scan'); }, icon: const Icon(Icons.camera_alt, size: 28), - label: const Text("QR 인증 (카메라 켜기)"), + label: Text( + tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'), + ), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게 backgroundColor: Colors.blue.shade700, @@ -51,7 +54,13 @@ class LoginSuccessScreen extends StatelessWidget { onPressed: () { context.go('/'); }, - child: const Text("나중에 하기 (대시보드로 이동)", style: TextStyle(color: Colors.grey)), + child: Text( + tr( + 'ui.userfront.login_success.later', + fallback: '나중에 하기 (대시보드로 이동)', + ), + style: const TextStyle(color: Colors.grey), + ), ), ], ), diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen.dart b/userfront/lib/features/auth/presentation/qr_scan_screen.dart index 7c4a2a4c..3aa5b6b6 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; +import 'package:userfront/i18n.dart'; class QRScanScreen extends StatefulWidget { const QRScanScreen({super.key}); @@ -143,7 +144,10 @@ class _QRScanScreenState extends State { if (mounted) { setState(() { _isSuccess = true; - _resultMessage = 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.'; + _resultMessage = tr( + 'msg.userfront.qr.approve_success', + fallback: 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.', + ); _isProcessing = false; }); } @@ -152,7 +156,11 @@ class _QRScanScreenState extends State { if (mounted) { setState(() { _isSuccess = false; - _resultMessage = 'QR 승인 실패: $e'; + _resultMessage = tr( + 'msg.userfront.qr.approve_error', + fallback: 'QR 승인 실패: {{error}}', + params: {'error': '$e'}, + ); _isProcessing = false; }); } @@ -181,8 +189,13 @@ class _QRScanScreenState extends State { _log.warning('Camera permission request failed: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.'), + SnackBar( + content: Text( + tr( + 'msg.userfront.qr.permission_error', + fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.', + ), + ), backgroundColor: Colors.red, ), ); @@ -198,7 +211,9 @@ class _QRScanScreenState extends State { final success = _isSuccess == true; final icon = success ? Icons.check_circle_outline : Icons.error_outline; final color = success ? Colors.green : Colors.red; - final title = success ? '승인 완료' : '승인 실패'; + final title = success + ? tr('ui.userfront.qr.result_success', fallback: '승인 완료') + : tr('ui.userfront.qr.result_failure', fallback: '승인 실패'); final message = _resultMessage ?? ''; return Center( @@ -223,12 +238,12 @@ class _QRScanScreenState extends State { if (!success) FilledButton( onPressed: _resetScan, - child: const Text('다시 스캔'), + child: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')), ), if (success) FilledButton( onPressed: () => context.pop(), - child: const Text('닫기'), + child: Text(tr('ui.common.close', fallback: '닫기')), ), ], ), @@ -240,7 +255,7 @@ class _QRScanScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Scan QR Code'), + title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.pop(), @@ -263,8 +278,15 @@ class _QRScanScreenState extends State { const SizedBox(height: 10), Text( isPermissionDenied - ? '카메라 권한이 필요합니다.' - : '카메라 오류: ${error.errorCode}', + ? tr( + 'msg.userfront.qr.permission_required', + fallback: '카메라 권한이 필요합니다.', + ) + : tr( + 'msg.userfront.qr.camera_error', + fallback: '카메라 오류: {{error}}', + params: {'error': '${error.errorCode}'}, + ), ), const SizedBox(height: 12), FilledButton( @@ -273,8 +295,11 @@ class _QRScanScreenState extends State { : _requestCameraPermission, child: Text( _isRequestingCamera - ? '요청 중...' - : '카메라 권한 요청하기', + ? tr('ui.common.requesting', fallback: '요청 중...') + : tr( + 'ui.userfront.qr.request_permission', + fallback: '카메라 권한 요청하기', + ), ), ), ], diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index cd19ca5d..fa78d16a 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../core/services/auth_proxy_service.dart'; +import 'package:userfront/i18n.dart'; class ResetPasswordScreen extends StatefulWidget { final String? loginId; // Now receiving loginId @@ -66,7 +67,12 @@ class _ResetPasswordScreenState extends State { Future _handlePasswordReset() async { if (_formKey.currentState?.validate() != true) return; if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) { - _showError("유효하지 않은 재설정 링크입니다. (loginId/token 누락)"); + _showError( + tr( + 'msg.userfront.reset.invalid_link', + fallback: '유효하지 않은 재설정 링크입니다. (loginId/token 누락)', + ), + ); return; } @@ -81,8 +87,13 @@ class _ResetPasswordScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."), + SnackBar( + content: Text( + tr( + 'msg.userfront.reset.success', + fallback: '비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요.', + ), + ), backgroundColor: Colors.green, ), ); @@ -90,7 +101,13 @@ class _ResetPasswordScreenState extends State { } } catch (e) { if (mounted) { - _showError("비밀번호 변경에 실패했습니다: ${e.toString()}"); + _showError( + tr( + 'msg.userfront.reset.error.generic', + fallback: '비밀번호 변경에 실패했습니다: {{error}}', + params: {'error': e.toString()}, + ), + ); } } finally { if (mounted) { @@ -107,7 +124,10 @@ class _ResetPasswordScreenState extends State { String _buildPolicyDescription() { if (_isPolicyLoading) { - return "비밀번호 정책을 불러오는 중입니다..."; + return tr( + 'msg.userfront.reset.policy_loading', + fallback: '비밀번호 정책을 불러오는 중입니다...', + ); } final minLength = (_policy?['minLength'] as int?) ?? 12; final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; @@ -116,14 +136,42 @@ class _ResetPasswordScreenState extends State { final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; - final parts = ["최소 ${minLength}자 이상"]; + final parts = [ + tr( + 'msg.userfront.reset.policy.min_length', + fallback: '최소 {{count}}자 이상', + params: {'count': '$minLength'}, + ), + ]; if (minTypes > 0) { - parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상"); + parts.add( + tr( + 'msg.userfront.reset.policy.min_types', + fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상', + params: {'count': '$minTypes'}, + ), + ); + } + if (requiresLower) { + parts.add( + tr('msg.userfront.reset.policy.lowercase', fallback: '소문자 1개 이상'), + ); + } + if (requiresUpper) { + parts.add( + tr('msg.userfront.reset.policy.uppercase', fallback: '대문자 1개 이상'), + ); + } + if (requiresNumber) { + parts.add( + tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'), + ); + } + if (requiresSymbol) { + parts.add( + tr('msg.userfront.reset.policy.symbol', fallback: '특수문자 1개 이상'), + ); } - if (requiresLower) parts.add("소문자 1개 이상"); - if (requiresUpper) parts.add("대문자 1개 이상"); - if (requiresNumber) parts.add("숫자 1개 이상"); - if (requiresSymbol) parts.add("특수문자 1개 이상"); return parts.join(", "); } @@ -132,7 +180,9 @@ class _ResetPasswordScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("새 비밀번호 설정"), + title: Text( + tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'), + ), centerTitle: true, ), body: Center( @@ -148,7 +198,10 @@ class _ResetPasswordScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - "새로운 비밀번호 설정", + tr( + 'ui.userfront.reset.subtitle', + fallback: '새로운 비밀번호 설정', + ), style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, @@ -166,7 +219,10 @@ class _ResetPasswordScreenState extends State { controller: _passwordController, obscureText: _isPasswordObscured, decoration: InputDecoration( - labelText: "새 비밀번호", + labelText: tr( + 'ui.userfront.reset.new_password', + fallback: '새 비밀번호', + ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.lock_outline), suffixIcon: IconButton( @@ -183,11 +239,18 @@ class _ResetPasswordScreenState extends State { validator: (value) { final val = value ?? ""; if (val.isEmpty) { - return '비밀번호를 입력해주세요.'; + return tr( + 'msg.userfront.reset.error.empty_password', + fallback: '비밀번호를 입력해주세요.', + ); } final minLength = (_policy?['minLength'] as int?) ?? 12; if (val.length < minLength) { - return '비밀번호는 최소 $minLength자 이상이어야 합니다.'; + return tr( + 'msg.userfront.reset.error.min_length', + fallback: '비밀번호는 최소 {{count}}자 이상이어야 합니다.', + params: {'count': '$minLength'}, + ); } final hasLower = RegExp(r'[a-z]').hasMatch(val); final hasUpper = RegExp(r'[A-Z]').hasMatch(val); @@ -201,20 +264,37 @@ class _ResetPasswordScreenState extends State { final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; if (minTypes > 0 && typeCount < minTypes) { - return '비밀번호는 영문 대/소문자/숫자/특수문자 중 $minTypes가지 이상 포함해야 합니다.'; + return tr( + 'msg.userfront.reset.error.min_types', + fallback: + '비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.', + params: {'count': '$minTypes'}, + ); } if ((_policy?['lowercase'] ?? true) && !hasLower) { - return '최소 1개 이상의 소문자를 포함해야 합니다.'; + return tr( + 'msg.userfront.reset.error.lowercase', + fallback: '최소 1개 이상의 소문자를 포함해야 합니다.', + ); } if ((_policy?['uppercase'] ?? false) && !hasUpper) { - return '최소 1개 이상의 대문자를 포함해야 합니다.'; + return tr( + 'msg.userfront.reset.error.uppercase', + fallback: '최소 1개 이상의 대문자를 포함해야 합니다.', + ); } if ((_policy?['number'] ?? true) && !hasNumber) { - return '최소 1개 이상의 숫자를 포함해야 합니다.'; + return tr( + 'msg.userfront.reset.error.number', + fallback: '최소 1개 이상의 숫자를 포함해야 합니다.', + ); } if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) { - return '최소 1개 이상의 특수문자를 포함해야 합니다.'; + return tr( + 'msg.userfront.reset.error.symbol', + fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.', + ); } return null; }, @@ -224,7 +304,10 @@ class _ResetPasswordScreenState extends State { controller: _confirmPasswordController, obscureText: _isConfirmPasswordObscured, decoration: InputDecoration( - labelText: "새 비밀번호 확인", + labelText: tr( + 'ui.userfront.reset.confirm_password', + fallback: '새 비밀번호 확인', + ), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.lock_outline), suffixIcon: IconButton( @@ -240,7 +323,10 @@ class _ResetPasswordScreenState extends State { ), validator: (value) { if (value != _passwordController.text) { - return '비밀번호가 일치하지 않습니다.'; + return tr( + 'msg.userfront.reset.error.mismatch', + fallback: '비밀번호가 일치하지 않습니다.', + ); } return null; }, @@ -255,9 +341,17 @@ class _ResetPasswordScreenState extends State { ? const SizedBox( height: 20, width: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), ) - : const Text("비밀번호 변경"), + : Text( + tr( + 'ui.userfront.reset.submit', + fallback: '비밀번호 변경', + ), + ), ), ], ), @@ -268,20 +362,24 @@ class _ResetPasswordScreenState extends State { } Widget _buildInvalidTokenView() { - return const Center( + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, color: Colors.red, size: 60), - SizedBox(height: 16), + const Icon(Icons.error_outline, color: Colors.red, size: 60), + const SizedBox(height: 16), Text( - "유효하지 않은 링크입니다.", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + tr('msg.userfront.reset.invalid_title', + fallback: '유효하지 않은 링크입니다.'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), - SizedBox(height: 8), + const SizedBox(height: 8), Text( - "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.", + tr( + 'msg.userfront.reset.invalid_body', + fallback: '비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.', + ), textAlign: TextAlign.center, ), ], diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 219f694e..5b5c85c7 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; +import 'package:userfront/i18n.dart'; import '../../../core/services/auth_proxy_service.dart'; class SignupScreen extends StatefulWidget { @@ -130,8 +131,11 @@ class _SignupScreenState extends State { _emailTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) return; setState(() { - if (_emailSeconds > 0) _emailSeconds--; - else timer.cancel(); + if (_emailSeconds > 0) { + _emailSeconds--; + } else { + timer.cancel(); + } }); }); } else { @@ -140,8 +144,11 @@ class _SignupScreenState extends State { _phoneTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) return; setState(() { - if (_phoneSeconds > 0) _phoneSeconds--; - else timer.cancel(); + if (_phoneSeconds > 0) { + _phoneSeconds--; + } else { + timer.cancel(); + } }); }); } @@ -157,20 +164,30 @@ class _SignupScreenState extends State { final email = _emailController.text.trim(); final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); if (!emailRegex.hasMatch(email)) { - setState(() => _emailError = '유효한 이메일 형식이 아닙니다.'); + setState(() => _emailError = tr( + 'msg.userfront.signup.email.invalid', + fallback: '유효한 이메일 형식이 아닙니다.', + )); return; } setState(() { _isLoading = true; _emailError = null; }); try { final available = await AuthProxyService.checkEmailAvailability(email); if (!available) { - setState(() => _emailError = '이미 가입된 이메일입니다.'); + setState(() => _emailError = tr( + 'msg.userfront.signup.email.duplicate', + fallback: '이미 가입된 이메일입니다.', + )); return; } await AuthProxyService.sendSignupCode(email, 'email'); _startTimer('email'); } catch (e) { - setState(() => _emailError = '발송 실패: $e'); + setState(() => _emailError = tr( + 'msg.userfront.signup.email.send_failed', + fallback: '발송 실패: {{error}}', + params: {'error': e.toString()}, + )); } finally { setState(() => _isLoading = false); } @@ -189,10 +206,17 @@ class _SignupScreenState extends State { _emailError = null; }); } else { - setState(() => _emailError = '인증코드가 일치하지 않습니다.'); + setState(() => _emailError = tr( + 'msg.userfront.signup.email.code_mismatch', + fallback: '인증코드가 일치하지 않습니다.', + )); } } catch (e) { - setState(() => _emailError = '인증 실패: $e'); + setState(() => _emailError = tr( + 'msg.userfront.signup.email.verify_failed', + fallback: '인증 실패: {{error}}', + params: {'error': e.toString()}, + )); } } @@ -204,7 +228,11 @@ class _SignupScreenState extends State { await AuthProxyService.sendSignupCode(phone, 'phone'); _startTimer('phone'); } catch (e) { - setState(() => _phoneError = '발송 실패: $e'); + setState(() => _phoneError = tr( + 'msg.userfront.signup.phone.send_failed', + fallback: '발송 실패: {{error}}', + params: {'error': e.toString()}, + )); } finally { setState(() => _isLoading = false); } @@ -223,16 +251,26 @@ class _SignupScreenState extends State { _phoneError = null; }); } else { - setState(() => _phoneError = '인증코드가 일치하지 않습니다.'); + setState(() => _phoneError = tr( + 'msg.userfront.signup.phone.code_mismatch', + fallback: '인증코드가 일치하지 않습니다.', + )); } } catch (e) { - setState(() => _phoneError = '인증 실패: $e'); + setState(() => _phoneError = tr( + 'msg.userfront.signup.phone.verify_failed', + fallback: '인증 실패: {{error}}', + params: {'error': e.toString()}, + )); } } Future _handleSignup() async { if (_passwordController.text != _confirmPasswordController.text) { - setState(() => _confirmPasswordError = '비밀번호가 일치하지 않습니다.'); + setState(() => _confirmPasswordError = tr( + 'msg.userfront.signup.password.mismatch', + fallback: '비밀번호가 일치하지 않습니다.', + )); return; } if (!_formKey.currentState!.validate()) return; @@ -257,12 +295,38 @@ class _SignupScreenState extends State { } catch (e) { String eStr = e.toString().toLowerCase(); setState(() { - if (eStr.contains('uppercase')) _passwordError = '대문자가 최소 1개 이상 포함되어야 합니다.'; - else if (eStr.contains('lowercase')) _passwordError = '소문자가 최소 1개 이상 포함되어야 합니다.'; - else if (eStr.contains('digit') || eStr.contains('number')) _passwordError = '숫자가 최소 1개 이상 포함되어야 합니다.'; - else if (eStr.contains('symbol') || eStr.contains('special')) _passwordError = '특수문자가 최소 1개 이상 포함되어야 합니다.'; - else if (eStr.contains('length') || eStr.contains('12 characters')) _passwordError = '비밀번호는 최소 12자 이상이어야 합니다.'; - else _passwordError = '가입 실패: $e'; + if (eStr.contains('uppercase')) { + _passwordError = tr( + 'msg.userfront.signup.password.uppercase_required', + fallback: '대문자가 최소 1개 이상 포함되어야 합니다.', + ); + } else if (eStr.contains('lowercase')) { + _passwordError = tr( + 'msg.userfront.signup.password.lowercase_required', + fallback: '소문자가 최소 1개 이상 포함되어야 합니다.', + ); + } else if (eStr.contains('digit') || eStr.contains('number')) { + _passwordError = tr( + 'msg.userfront.signup.password.number_required', + fallback: '숫자가 최소 1개 이상 포함되어야 합니다.', + ); + } else if (eStr.contains('symbol') || eStr.contains('special')) { + _passwordError = tr( + 'msg.userfront.signup.password.symbol_required', + fallback: '특수문자가 최소 1개 이상 포함되어야 합니다.', + ); + } else if (eStr.contains('length') || eStr.contains('12 characters')) { + _passwordError = tr( + 'msg.userfront.signup.password.length_required', + fallback: '비밀번호는 최소 12자 이상이어야 합니다.', + ); + } else { + _passwordError = tr( + 'msg.userfront.signup.failed', + fallback: '가입 실패: {{error}}', + params: {'error': e.toString()}, + ); + } }); } finally { setState(() => _isLoading = false); @@ -274,9 +338,20 @@ class _SignupScreenState extends State { context: context, barrierDismissible: false, builder: (context) => AlertDialog( - title: const Text('회원가입 완료'), - content: const Text('성공적으로 가입되었습니다.'), - actions: [TextButton(onPressed: () => context.go('/signin'), child: const Text('로그인하기'))], + title: Text( + tr('msg.userfront.signup.success.title', fallback: '회원가입 완료'), + ), + content: Text( + tr('msg.userfront.signup.success.body', fallback: '성공적으로 가입되었습니다.'), + ), + actions: [ + TextButton( + onPressed: () => context.go('/signin'), + child: Text( + tr('ui.userfront.signup.success.action', fallback: '로그인하기'), + ), + ), + ], ), ); } @@ -288,13 +363,25 @@ class _SignupScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 20), child: Row( children: [ - _stepCircle(1, '약관동의'), + _stepCircle( + 1, + tr('ui.userfront.signup.steps.agreement', fallback: '약관동의'), + ), _stepLine(1), - _stepCircle(2, '본인인증'), + _stepCircle( + 2, + tr('ui.userfront.signup.steps.verify', fallback: '본인인증'), + ), _stepLine(2), - _stepCircle(3, '정보입력'), + _stepCircle( + 3, + tr('ui.userfront.signup.steps.profile', fallback: '정보입력'), + ), _stepLine(3), - _stepCircle(4, '비밀번호'), + _stepCircle( + 4, + tr('ui.userfront.signup.steps.password', fallback: '비밀번호'), + ), ], ), ); @@ -330,9 +417,17 @@ class _SignupScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('서비스 이용을 위해\n약관에 동의해주세요', - style: TextStyle( - fontSize: 20, fontWeight: FontWeight.bold, height: 1.3)), + Text( + tr( + 'msg.userfront.signup.agreement.title', + fallback: '서비스 이용을 위해\n약관에 동의해주세요', + ), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + height: 1.3, + ), + ), const SizedBox(height: 24), // 모두 동의 버튼 Container( @@ -342,8 +437,13 @@ class _SignupScreenState extends State { border: Border.all(color: Colors.grey[200]!), ), child: CheckboxListTile( - title: const Text('모두 동의합니다', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), + title: Text( + tr( + 'ui.userfront.signup.agreement.all', + fallback: '모두 동의합니다', + ), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ), value: _termsAccepted && _privacyAccepted, onChanged: (val) { setState(() { @@ -357,14 +457,20 @@ class _SignupScreenState extends State { ), const SizedBox(height: 16), _agreementSection( - title: '바론 소프트웨어 이용약관 (필수)', + title: tr( + 'ui.userfront.signup.agreement.tos_title', + fallback: '바론 소프트웨어 이용약관 (필수)', + ), content: _tosText, value: _termsAccepted, onChanged: (val) => setState(() => _termsAccepted = val!), ), const SizedBox(height: 16), _agreementSection( - title: '개인정보 수집 및 이용 동의 (필수)', + title: tr( + 'ui.userfront.signup.agreement.privacy_title', + fallback: '개인정보 수집 및 이용 동의 (필수)', + ), content: _privacyText, value: _privacyAccepted, onChanged: (val) => setState(() => _privacyAccepted = val!), @@ -410,7 +516,9 @@ class _SignupScreenState extends State { ); } - static const String _tosText = """ + static String get _tosText => tr( + 'msg.userfront.signup.tos_full', + fallback: """ 바론 소프트웨어 이용약관 제1장 총칙 @@ -480,9 +588,12 @@ class _SignupScreenState extends State { 본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다. 부칙 본 약관은 2024년 10월 1일부터 시행됩니다. -"""; +""", + ); - static const String _privacyText = """ + static String get _privacyText => tr( + 'msg.userfront.signup.privacy_full', + fallback: """ 개인정보 수집 및 이용 동의 바론서비스 개인정보처리방침 @@ -590,33 +701,46 @@ class _SignupScreenState extends State { 회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다. 제8조 (기타) 본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다. -"""; +""", + ); Widget _buildStepAuth() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('본인 확인을 위해\n인증을 진행해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + tr( + 'msg.userfront.signup.auth.title', + fallback: '본인 확인을 위해\n인증을 진행해주세요', + ), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), const SizedBox(height: 16), // 가족사 이메일 안내 문구 Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)), - child: const Row( + child: Row( children: [ - Icon(Icons.info_outline, size: 16, color: Colors.blue), - SizedBox(width: 8), + const Icon(Icons.info_outline, size: 16, color: Colors.blue), + const SizedBox(width: 8), Expanded( child: Text( - '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.', - style: TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500), + tr( + 'msg.userfront.signup.auth.affiliate_notice', + fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.', + ), + style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500), ), ), ], ), ), const SizedBox(height: 24), - Text('이메일 인증', style: const TextStyle(fontWeight: FontWeight.bold)), + Text( + tr('ui.userfront.signup.auth.email.title', fallback: '이메일 인증'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), Row( children: [ @@ -625,7 +749,10 @@ class _SignupScreenState extends State { controller: _emailController, onChanged: _checkEmailAffiliation, // 도메인 실시간 체크 decoration: InputDecoration( - labelText: '이메일 주소', + labelText: tr( + 'ui.userfront.signup.auth.email.label', + fallback: '이메일 주소', + ), border: const OutlineInputBorder(), errorText: _emailError, hintText: 'example@hanmaceng.co.kr', @@ -639,7 +766,14 @@ class _SignupScreenState extends State { child: ElevatedButton( onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode, style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), - child: Text(_emailSeconds > 0 ? '재발송' : '인증요청'), + child: Text( + _emailSeconds > 0 + ? tr('ui.common.resend', fallback: '재발송') + : tr( + 'ui.userfront.signup.auth.request_code', + fallback: '인증요청', + ), + ), ), ), ], @@ -649,7 +783,10 @@ class _SignupScreenState extends State { TextFormField( controller: _emailCodeController, decoration: InputDecoration( - labelText: '인증코드 6자리', + labelText: tr( + 'ui.userfront.signup.auth.code_label', + fallback: '인증코드 6자리', + ), suffixText: _formatTime(_emailSeconds), border: const OutlineInputBorder(), ), @@ -658,19 +795,40 @@ class _SignupScreenState extends State { onChanged: (val) { if(val.length == 6) _verifyEmailCode(); }, ), ], - if (_isEmailVerified) const Padding( - padding: EdgeInsets.only(top: 8), - child: Text('✅ 이메일 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)), - ), + if (_isEmailVerified) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + tr( + 'msg.userfront.signup.email.verified', + fallback: '✅ 이메일 인증 완료', + ), + style: const TextStyle( + color: Colors.green, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), const SizedBox(height: 24), - Text('휴대폰 인증', style: const TextStyle(fontWeight: FontWeight.bold)), + Text( + tr('ui.userfront.signup.phone.title', fallback: '휴대폰 인증'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextFormField( controller: _phoneController, - decoration: InputDecoration(labelText: '휴대폰 번호 (-없이)', border: const OutlineInputBorder(), errorText: _phoneError), + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.phone.label', + fallback: '휴대폰 번호 (-없이)', + ), + border: const OutlineInputBorder(), + errorText: _phoneError, + ), readOnly: _isPhoneVerified, keyboardType: TextInputType.phone, ), @@ -681,7 +839,14 @@ class _SignupScreenState extends State { child: ElevatedButton( onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode, style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), - child: Text(_phoneSeconds > 0 ? '재발송' : '인증요청'), + child: Text( + _phoneSeconds > 0 + ? tr('ui.common.resend', fallback: '재발송') + : tr( + 'ui.userfront.signup.auth.request_code', + fallback: '인증요청', + ), + ), ), ), ], @@ -691,7 +856,10 @@ class _SignupScreenState extends State { TextFormField( controller: _phoneCodeController, decoration: InputDecoration( - labelText: '인증코드 6자리', + labelText: tr( + 'ui.userfront.signup.auth.code_label', + fallback: '인증코드 6자리', + ), suffixText: _formatTime(_phoneSeconds), border: const OutlineInputBorder(), ), @@ -700,10 +868,21 @@ class _SignupScreenState extends State { onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); }, ), ], - if (_isPhoneVerified) const Padding( - padding: EdgeInsets.only(top: 8), - child: Text('✅ 휴대폰 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)), - ), + if (_isPhoneVerified) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + tr( + 'msg.userfront.signup.phone.verified', + fallback: '✅ 휴대폰 인증 완료', + ), + style: const TextStyle( + color: Colors.green, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), ], ); } @@ -712,12 +891,24 @@ class _SignupScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('회원님의\n소속 정보를 알려주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + tr( + 'msg.userfront.signup.profile.title', + fallback: '회원님의\n소속 정보를 알려주세요', + ), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), const SizedBox(height: 24), TextFormField( controller: _nameController, onChanged: (_) => setState(() {}), - decoration: const InputDecoration(labelText: '이름', border: OutlineInputBorder()), + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.name', + fallback: '이름', + ), + border: const OutlineInputBorder(), + ), ), const SizedBox(height: 16), // 소속 유형 선택 (가족사 메일일 경우 비활성화) @@ -726,17 +917,51 @@ class _SignupScreenState extends State { child: Opacity( opacity: _isAffiliateEmail ? 0.7 : 1.0, child: DropdownButtonFormField( - value: _affiliationType, + key: ValueKey(_affiliationType), + initialValue: _affiliationType, decoration: InputDecoration( - labelText: '소속 유형', + labelText: tr( + 'ui.userfront.signup.profile.affiliation_type', + fallback: '소속 유형', + ), border: const OutlineInputBorder(), - helperText: _isAffiliateEmail ? '가족사 이메일 사용 시 자동으로 선택됩니다.' : null, + helperText: _isAffiliateEmail + ? tr( + 'msg.userfront.signup.profile.affiliate_hint', + fallback: '가족사 이메일 사용 시 자동으로 선택됩니다.', + ) + : null, ), - items: const [ - DropdownMenuItem(value: 'GENERAL', child: Text('일반 사용자')), - DropdownMenuItem(value: 'AFFILIATE', child: Text('가족사 임직원')), + items: [ + DropdownMenuItem( + value: 'GENERAL', + child: Text( + tr( + 'domain.affiliation.general', + fallback: '일반 사용자', + ), + ), + ), + DropdownMenuItem( + value: 'AFFILIATE', + child: Text( + tr( + 'domain.affiliation.affiliate', + fallback: '가족사 임직원', + ), + ), + ), ], - onChanged: _isAffiliateEmail ? null : (val) => setState(() { _affiliationType = val!; }), + onChanged: _isAffiliateEmail + ? null + : (val) { + if (val == null) { + return; + } + setState(() { + _affiliationType = val; + }); + }, ), ), ), @@ -748,17 +973,56 @@ class _SignupScreenState extends State { child: Opacity( opacity: _isAffiliateEmail ? 0.7 : 1.0, child: DropdownButtonFormField( - value: _companyCode, - decoration: const InputDecoration(labelText: '가족사 선택', border: OutlineInputBorder()), - items: const [ - DropdownMenuItem(value: 'HANMAC', child: Text('한맥')), - DropdownMenuItem(value: 'SAMAN', child: Text('삼안')), - DropdownMenuItem(value: 'PTC', child: Text('PTC')), - DropdownMenuItem(value: 'JANGHEON', child: Text('장헌')), - DropdownMenuItem(value: 'BARON', child: Text('바론')), - DropdownMenuItem(value: 'HALLA', child: Text('한라')), + key: ValueKey(_companyCode ?? 'none'), + initialValue: _companyCode, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.company', + fallback: '가족사 선택', + ), + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 'HANMAC', + child: Text( + tr('domain.company.hanmac', fallback: '한맥'), + ), + ), + DropdownMenuItem( + value: 'SAMAN', + child: Text( + tr('domain.company.saman', fallback: '삼안'), + ), + ), + DropdownMenuItem( + value: 'PTC', + child: Text( + tr('domain.company.ptc', fallback: 'PTC'), + ), + ), + DropdownMenuItem( + value: 'JANGHEON', + child: Text( + tr('domain.company.jangheon', fallback: '장헌'), + ), + ), + DropdownMenuItem( + value: 'BARON', + child: Text( + tr('domain.company.baron', fallback: '바론'), + ), + ), + DropdownMenuItem( + value: 'HALLA', + child: Text( + tr('domain.company.halla', fallback: '한라'), + ), + ), ], - onChanged: _isAffiliateEmail ? null : (val) => setState(() => _companyCode = val), + onChanged: _isAffiliateEmail + ? null + : (val) => setState(() => _companyCode = val), ), ), ), @@ -768,7 +1032,12 @@ class _SignupScreenState extends State { controller: _deptController, onChanged: (_) => setState(() {}), decoration: InputDecoration( - labelText: _affiliationType == 'AFFILIATE' ? '부서명' : '소속 정보 (선택)', + labelText: _affiliationType == 'AFFILIATE' + ? tr('ui.userfront.signup.profile.department', fallback: '부서명') + : tr( + 'ui.userfront.signup.profile.department_optional', + fallback: '소속 정보 (선택)', + ), border: const OutlineInputBorder() ), ), @@ -778,7 +1047,10 @@ class _SignupScreenState extends State { String _buildPolicyDescription() { if (_isPolicyLoading) { - return "비밀번호 정책을 불러오는 중입니다..."; + return tr( + 'msg.userfront.signup.policy.loading', + fallback: '비밀번호 정책을 불러오는 중입니다...', + ); } final minLength = (_policy?['minLength'] as int?) ?? 12; final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; @@ -787,16 +1059,60 @@ class _SignupScreenState extends State { final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; - final parts = ["최소 $minLength자 이상"]; + final parts = [ + tr( + 'msg.userfront.signup.policy.min_length', + fallback: '최소 {{count}}자 이상', + params: {'count': minLength.toString()}, + ), + ]; if (minTypes > 0) { - parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상"); + parts.add( + tr( + 'msg.userfront.signup.policy.min_types', + fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상', + params: {'count': minTypes.toString()}, + ), + ); + } + if (requiresUpper) { + parts.add( + tr( + 'msg.userfront.signup.policy.uppercase', + fallback: '대문자', + ), + ); + } + if (requiresLower) { + parts.add( + tr( + 'msg.userfront.signup.policy.lowercase', + fallback: '소문자', + ), + ); + } + if (requiresNumber) { + parts.add( + tr( + 'msg.userfront.signup.policy.number', + fallback: '숫자', + ), + ); + } + if (requiresSymbol) { + parts.add( + tr( + 'msg.userfront.signup.policy.symbol', + fallback: '특수문자', + ), + ); } - if (requiresUpper) parts.add("대문자"); - if (requiresLower) parts.add("소문자"); - if (requiresNumber) parts.add("숫자"); - if (requiresSymbol) parts.add("특수문자"); - return "보안 정책: ${parts.join(', ')}"; + return tr( + 'msg.userfront.signup.policy.summary', + fallback: '보안 정책: {{rules}}', + params: {'rules': parts.join(', ')}, + ); } Widget _buildStepPassword() { @@ -825,7 +1141,13 @@ class _SignupScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('마지막으로\n비밀번호를 설정해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + tr( + 'msg.userfront.signup.password.title', + fallback: '마지막으로\n비밀번호를 설정해주세요', + ), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), const SizedBox(height: 16), // 비밀번호 정책 안내 박스 Container( @@ -850,7 +1172,10 @@ class _SignupScreenState extends State { obscureText: true, onChanged: (_) => setState(() {}), decoration: InputDecoration( - labelText: '비밀번호', + labelText: tr( + 'ui.userfront.signup.password.label', + fallback: '비밀번호', + ), border: const OutlineInputBorder(), errorText: _passwordError, ), @@ -859,12 +1184,55 @@ class _SignupScreenState extends State { Wrap( spacing: 10, children: [ - _cryptoCheck('$minLength자 이상', hasLength), - if (minTypes > 0) _cryptoCheck('문자 유형 ${minTypes}가지 이상', hasTypeCount), - if (requiresUpper) _cryptoCheck('대문자', hasUpper), - if (requiresLower) _cryptoCheck('소문자', hasLower), - if (requiresNumber) _cryptoCheck('숫자', hasDigit), - if (requiresSymbol) _cryptoCheck('특수문자', hasSpecial), + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.min_length', + fallback: '{{count}}자 이상', + params: {'count': minLength.toString()}, + ), + hasLength, + ), + if (minTypes > 0) + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.min_types', + fallback: '문자 유형 {{count}}가지 이상', + params: {'count': minTypes.toString()}, + ), + hasTypeCount, + ), + if (requiresUpper) + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.uppercase', + fallback: '대문자', + ), + hasUpper, + ), + if (requiresLower) + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.lowercase', + fallback: '소문자', + ), + hasLower, + ), + if (requiresNumber) + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.number', + fallback: '숫자', + ), + hasDigit, + ), + if (requiresSymbol) + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.symbol', + fallback: '특수문자', + ), + hasSpecial, + ), ], ), const SizedBox(height: 16), @@ -873,11 +1241,19 @@ class _SignupScreenState extends State { obscureText: true, onChanged: (val) { setState(() { - _confirmPasswordError = (val != _passwordController.text) ? '비밀번호가 일치하지 않습니다.' : null; + _confirmPasswordError = (val != _passwordController.text) + ? tr( + 'msg.userfront.signup.password.mismatch', + fallback: '비밀번호가 일치하지 않습니다.', + ) + : null; }); }, decoration: InputDecoration( - labelText: '비밀번호 확인', + labelText: tr( + 'ui.userfront.signup.password.confirm_label', + fallback: '비밀번호 확인', + ), border: const OutlineInputBorder(), errorText: _confirmPasswordError, ), @@ -917,7 +1293,10 @@ class _SignupScreenState extends State { return Scaffold( backgroundColor: Colors.white, appBar: AppBar( - title: Text('회원가입', style: TextStyle(fontWeight: FontWeight.bold)), + title: Text( + tr('ui.userfront.signup.title', fallback: '회원가입'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), elevation: 0, backgroundColor: Colors.white, foregroundColor: Colors.black, @@ -951,7 +1330,10 @@ class _SignupScreenState extends State { child: OutlinedButton( onPressed: () => setState(() => _currentStep--), style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)), - child: const Text('이전', style: TextStyle(color: Colors.black)), + child: Text( + tr('ui.common.prev', fallback: '이전'), + style: const TextStyle(color: Colors.black), + ), ), ), const SizedBox(width: 12), @@ -967,7 +1349,11 @@ class _SignupScreenState extends State { ), child: _isLoading ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : Text(_currentStep < 4 ? '다음 단계' : '가입 완료'), + : Text( + _currentStep < 4 + ? tr('ui.userfront.signup.next_step', fallback: '다음 단계') + : tr('ui.userfront.signup.complete', fallback: '가입 완료'), + ), ), ), ], diff --git a/userfront/lib/features/dashboard/domain/dashboard_providers.dart b/userfront/lib/features/dashboard/domain/dashboard_providers.dart index 61ad25f5..6c090a49 100644 --- a/userfront/lib/features/dashboard/domain/dashboard_providers.dart +++ b/userfront/lib/features/dashboard/domain/dashboard_providers.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; +import 'package:userfront/i18n.dart'; import 'models.dart'; String _envOrDefault(String key, String fallback) { @@ -17,19 +17,6 @@ String _envOrDefault(String key, String fallback) { String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); -Future> _fetchLinkedRps() async { - final items = await AuthProxyService.fetchLinkedRps(); - final result = []; - for (final item in items) { - if (item is Map) { - result.add(LinkedRp.fromJson(Map.from(item))); - } - } - return result; -} - - - Future _fetchAuthTimelinePage({String? cursor}) async { final queryParameters = { 'limit': '20', @@ -192,7 +179,10 @@ class AuthTimelineNotifier extends Notifier { state = state.copyWith( isLoading: false, isLoadingMore: false, - error: '접속이력을 불러오지 못했습니다.', + error: tr( + 'msg.userfront.dashboard.timeline.load_error', + fallback: '접속이력을 불러오지 못했습니다.', + ), ); } } diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 6b8c42fd..b7db69d3 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -9,11 +9,11 @@ import '../domain/providers/linked_rps_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; -import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../domain/dashboard_providers.dart'; import '../domain/models.dart' hide LinkedRp; +import 'package:userfront/i18n.dart'; class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @@ -34,7 +34,6 @@ class _DashboardScreenState extends ConsumerState { String? _auditNextCursor; bool _auditLoading = false; bool _auditLoadingMore = false; - String? _auditError; bool _isRevoking = false; bool _showAllActivities = false; @@ -63,17 +62,27 @@ class _DashboardScreenState extends ConsumerState { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('연동 해지'), - content: Text('$appName 앱과의 연동을 해지하시겠습니까?\n해지하면 다음 로그인 시 다시 동의가 필요합니다.'), + title: Text( + tr('ui.userfront.dashboard.revoke.title', fallback: '연동 해지'), + ), + content: Text( + tr( + 'msg.userfront.dashboard.revoke.confirm', + fallback: + '{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.', + params: {'app': appName}, + ), + ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text('취소'), + child: Text(tr('ui.common.cancel', fallback: '취소')), ), TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('해지하기'), + child: + Text(tr('ui.userfront.dashboard.revoke.confirm_button', fallback: '해지하기')), ), ], ), @@ -86,7 +95,15 @@ class _DashboardScreenState extends ConsumerState { await ref.read(linkedRpsProvider.notifier).revokeRp(clientId); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$appName 연동이 해지되었습니다.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.dashboard.revoke.success', + fallback: '{{app}} 연동이 해지되었습니다.', + params: {'app': appName}, + ), + ), + ), ); setState(() { _revokedClientIds.add(clientId); @@ -96,7 +113,15 @@ class _DashboardScreenState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('해지 실패: $e')), + SnackBar( + content: Text( + tr( + 'msg.userfront.dashboard.revoke.error', + fallback: '해지 실패: {{error}}', + params: {'error': '$e'}, + ), + ), + ), ); } } finally { @@ -132,10 +157,18 @@ class _DashboardScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('권한 (Scopes)', style: TextStyle(fontWeight: FontWeight.bold)), + Text( + tr('ui.userfront.dashboard.scopes.title', + fallback: '권한 (Scopes)'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), if (item.scopes.isEmpty) - const Text('요청된 권한이 없습니다.', style: TextStyle(color: Colors.grey)) + Text( + tr('msg.userfront.dashboard.scopes.empty', + fallback: '요청된 권한이 없습니다.'), + style: const TextStyle(color: Colors.grey), + ) else Wrap( spacing: 8, @@ -147,16 +180,43 @@ class _DashboardScreenState extends ConsumerState { )).toList(), ), const SizedBox(height: 24), - const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)), + Text( + tr('ui.userfront.dashboard.status_history', + fallback: '상태 이력'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('최근 인증: ${item.lastAuthAt}'), - const SizedBox(height: 4), Text( - '현재 상태: ${item.status}', - style: TextStyle(color: item.status == '활성' ? Colors.green : Colors.grey), + tr( + 'msg.userfront.dashboard.last_auth', + fallback: '최근 인증: {{value}}', + params: {'value': item.lastAuthAt}, + ), + ), + const SizedBox(height: 4), + Builder( + builder: (context) { + final statusLabel = item.status == 'active' + ? tr('ui.common.status.active', + fallback: '활성') + : tr('ui.userfront.dashboard.status.revoked', + fallback: '해지됨'); + return Text( + tr( + 'msg.userfront.dashboard.current_status', + fallback: '현재 상태: {{status}}', + params: {'status': statusLabel}, + ), + style: TextStyle( + color: item.status == 'active' + ? Colors.green + : Colors.grey, + ), + ); + }, ), ], ), @@ -166,7 +226,7 @@ class _DashboardScreenState extends ConsumerState { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('닫기'), + child: Text(tr('ui.common.close', fallback: '닫기')), ), ], ); @@ -182,7 +242,8 @@ class _DashboardScreenState extends ConsumerState { children: [ ListTile( leading: const Icon(Icons.home_outlined), - title: const Text('대시보드'), + title: + Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')), selected: true, onTap: () { if (closeOnTap) { @@ -193,7 +254,8 @@ class _DashboardScreenState extends ConsumerState { ), ListTile( leading: const Icon(Icons.person_outline), - title: const Text('내 정보'), + title: + Text(tr('ui.userfront.nav.profile', fallback: '내 정보')), onTap: () { if (closeOnTap) { Navigator.of(context).pop(); @@ -203,7 +265,8 @@ class _DashboardScreenState extends ConsumerState { ), ListTile( leading: const Icon(Icons.qr_code_scanner), - title: const Text('QR 스캔'), + title: + Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')), onTap: () { if (closeOnTap) { Navigator.of(context).pop(); @@ -214,7 +277,8 @@ class _DashboardScreenState extends ConsumerState { const Divider(), ListTile( leading: const Icon(Icons.logout), - title: const Text('로그아웃'), + title: + Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')), onTap: () async { if (closeOnTap) { Navigator.of(context).pop(); @@ -303,7 +367,6 @@ class _DashboardScreenState extends ConsumerState { setState(() { _auditLogs.clear(); _auditNextCursor = null; - _auditError = null; _auditLoading = true; }); } else { @@ -317,12 +380,9 @@ class _DashboardScreenState extends ConsumerState { setState(() { _auditLogs.addAll(page.items); _auditNextCursor = page.nextCursor; - _auditError = null; }); } catch (_) { - setState(() { - _auditError = '접속이력을 불러오지 못했습니다.'; - }); + // 에러는 상위 UI에서 재시도 UX로 처리합니다. } finally { setState(() { _auditLoading = false; @@ -368,40 +428,40 @@ class _DashboardScreenState extends ConsumerState { String _authMethodLabel() { if (AuthTokenStore.usesCookie()) { - return 'Ory 세션'; + return tr('ui.userfront.auth_method.ory', fallback: 'Ory 세션'); } final provider = AuthTokenStore.getProvider(); if (provider == null || provider.isEmpty) { - return '세션'; + return tr('ui.userfront.auth_method.session', fallback: '세션'); } final lower = provider.toLowerCase(); if (lower.contains('ory')) { - return 'Ory 세션'; + return tr('ui.userfront.auth_method.ory', fallback: 'Ory 세션'); } return provider; } String _deviceLabelFromUserAgent(String userAgent) { if (userAgent.isEmpty) { - return '-'; + return tr('ui.common.hyphen', fallback: '-'); } final ua = userAgent.toLowerCase(); if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) { - return 'Mobile(iOS)'; + return tr('ui.userfront.device.ios', fallback: 'Mobile(iOS)'); } if (ua.contains('android')) { - return 'Mobile(Android)'; + return tr('ui.userfront.device.android', fallback: 'Mobile(Android)'); } if (ua.contains('windows')) { - return 'Desktop(Windows)'; + return tr('ui.userfront.device.windows', fallback: 'Desktop(Windows)'); } if (ua.contains('mac os x') || ua.contains('macintosh')) { - return 'Desktop(macOS)'; + return tr('ui.userfront.device.macos', fallback: 'Desktop(macOS)'); } if (ua.contains('linux')) { - return 'Desktop(Linux)'; + return tr('ui.userfront.device.linux', fallback: 'Desktop(Linux)'); } - return 'Unknown'; + return tr('ui.common.unknown', fallback: 'Unknown'); } Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) { @@ -415,8 +475,16 @@ class _DashboardScreenState extends ConsumerState { } final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent); final tooltip = [ - '승인 기기: $deviceLabel', - '승인 IP: ${approvedIp.isEmpty ? '-' : approvedIp}', + tr( + 'msg.userfront.dashboard.approved_device', + fallback: '승인 기기: {{device}}', + params: {'device': deviceLabel}, + ), + tr( + 'msg.userfront.dashboard.approved_ip', + fallback: '승인 IP: {{ip}}', + params: {'ip': approvedIp.isEmpty ? '-' : approvedIp}, + ), ].join('\n'); return Tooltip( message: tooltip, @@ -432,10 +500,26 @@ class _DashboardScreenState extends ConsumerState { final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false) ? log.detailMap['approved_session_id'].toString() : log.sessionId; - final tooltipLabel = isOidc ? '승인한 Userfront 세션 ID' : '승인한 세션 ID'; + final tooltipLabel = isOidc + ? tr( + 'ui.userfront.dashboard.approved_session.userfront', + fallback: '승인한 Userfront 세션 ID', + ) + : tr( + 'ui.userfront.dashboard.approved_session.default', + fallback: '승인한 세션 ID', + ); final tooltip = approvedSessionId.isEmpty - ? '$tooltipLabel 없음' - : '$tooltipLabel: $approvedSessionId\n클릭하면 복사됩니다.'; + ? tr( + 'msg.userfront.dashboard.approved_session.none', + fallback: '{{label}} 없음', + params: {'label': tooltipLabel}, + ) + : tr( + 'msg.userfront.dashboard.approved_session.copy_click', + fallback: '{{label}}: {{id}}\\n클릭하면 복사됩니다.', + params: {'label': tooltipLabel, 'id': approvedSessionId}, + ); return InkWell( onTap: approvedSessionId.isEmpty ? null @@ -443,14 +527,21 @@ class _DashboardScreenState extends ConsumerState { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('세션 ID가 복사되었습니다.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.dashboard.session_id_copied', + fallback: '세션 ID가 복사되었습니다.', + ), + ), + ), ); } }, child: Tooltip( message: tooltip, child: Text( - isOidc ? authMethod : 'QR', + isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'), style: TextStyle( color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, decoration: @@ -468,17 +559,35 @@ class _DashboardScreenState extends ConsumerState { final approvedIp = log.detailMap['approved_ip']?.toString() ?? ''; final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty; if (!authMethod.startsWith('링크') || !hasApproverMeta) { - return _selectableText('인증수단: $authMethod'); + return _selectableText( + tr( + 'msg.userfront.dashboard.auth_method', + fallback: '인증수단: {{method}}', + params: {'method': authMethod}, + ), + ); } final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent); final tooltip = [ - '승인 기기: $deviceLabel', - '승인 IP: ${approvedIp.isEmpty ? '-' : approvedIp}', + tr( + 'msg.userfront.dashboard.approved_device', + fallback: '승인 기기: {{device}}', + params: {'device': deviceLabel}, + ), + tr( + 'msg.userfront.dashboard.approved_ip', + fallback: '승인 IP: {{ip}}', + params: {'ip': approvedIp.isEmpty ? '-' : approvedIp}, + ), ].join('\n'); return Tooltip( message: tooltip, child: _selectableText( - '인증수단: $authMethod', + tr( + 'msg.userfront.dashboard.auth_method', + fallback: '인증수단: {{method}}', + params: {'method': authMethod}, + ), style: const TextStyle( color: Colors.blueAccent, decoration: TextDecoration.underline, @@ -489,7 +598,15 @@ class _DashboardScreenState extends ConsumerState { final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false) ? log.detailMap['approved_session_id'].toString() : log.sessionId; - final tooltipLabel = isOidc ? '승인한 Userfront 세션 ID' : '승인한 세션 ID'; + final tooltipLabel = isOidc + ? tr( + 'ui.userfront.dashboard.approved_session.userfront', + fallback: '승인한 Userfront 세션 ID', + ) + : tr( + 'ui.userfront.dashboard.approved_session.default', + fallback: '승인한 세션 ID', + ); return InkWell( onTap: approvedSessionId.isEmpty ? null @@ -497,16 +614,37 @@ class _DashboardScreenState extends ConsumerState { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('세션 ID가 복사되었습니다.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.dashboard.session_id_copied', + fallback: '세션 ID가 복사되었습니다.', + ), + ), + ), ); } }, child: Tooltip( message: approvedSessionId.isEmpty - ? '$tooltipLabel 없음' - : '$tooltipLabel: $approvedSessionId\n탭하면 복사됩니다.', + ? tr( + 'msg.userfront.dashboard.approved_session.none', + fallback: '{{label}} 없음', + params: {'label': tooltipLabel}, + ) + : tr( + 'msg.userfront.dashboard.approved_session.copy_tap', + fallback: '{{label}}: {{id}}\\n탭하면 복사됩니다.', + params: {'label': tooltipLabel, 'id': approvedSessionId}, + ), child: Text( - '인증수단: ${isOidc ? authMethod : 'QR'}', + tr( + 'msg.userfront.dashboard.auth_method', + fallback: '인증수단: {{method}}', + params: { + 'method': isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'), + }, + ), style: TextStyle( color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, decoration: approvedSessionId.isEmpty @@ -528,7 +666,16 @@ class _DashboardScreenState extends ConsumerState { Widget _buildAppCell(AuditLogEntry log, {TextStyle? style}) { final label = _appLabelForLog(log); final clientId = log.clientId; - final tooltip = clientId.isEmpty ? 'Client ID 없음' : 'Client ID: $clientId'; + final tooltip = clientId.isEmpty + ? tr( + 'msg.userfront.dashboard.client_id_missing', + fallback: 'Client ID 없음', + ) + : tr( + 'msg.userfront.dashboard.client_id', + fallback: 'Client ID: {{id}}', + params: {'id': clientId}, + ); final baseStyle = style ?? const TextStyle(); final emphasisStyle = clientId.isEmpty ? baseStyle @@ -544,18 +691,21 @@ class _DashboardScreenState extends ConsumerState { String _appLabelForPath(String path) { if (path.startsWith('/api/v1/auth')) { - return 'Baron 로그인'; + return tr('ui.userfront.app_label.baron', fallback: 'Baron 로그인'); } if (path.startsWith('/api/v1/user')) { - return 'Baron 로그인'; + return tr('ui.userfront.app_label.baron', fallback: 'Baron 로그인'); } if (path.startsWith('/api/v1/dev')) { - return 'Dev Console'; + return tr('ui.userfront.app_label.dev_console', fallback: 'Dev Console'); } if (path.startsWith('/api/v1/admin')) { - return 'Admin Console'; + return tr( + 'ui.userfront.app_label.admin_console', + fallback: 'Admin Console', + ); } - return 'Baron 로그인'; + return tr('ui.userfront.app_label.baron', fallback: 'Baron 로그인'); } @override @@ -567,15 +717,17 @@ class _DashboardScreenState extends ConsumerState { final userName = profile?.name ?? profile?.email ?? profile?.phone ?? - 'User'; - final department = profile?.department.isNotEmpty == true ? profile!.department : '소속 정보 없음'; + tr('ui.userfront.profile.user_fallback', fallback: 'User'); + final department = profile?.department.isNotEmpty == true + ? profile!.department + : tr('ui.userfront.profile.department_empty', fallback: '소속 정보 없음'); final sessionIssuedAt = _getJwtIssuedAt(); return Scaffold( backgroundColor: _subtle, appBar: AppBar( title: Text( - 'Baron 로그인', + tr('ui.userfront.app_title', fallback: 'Baron 로그인'), style: TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, @@ -584,17 +736,17 @@ class _DashboardScreenState extends ConsumerState { actions: [ IconButton( icon: const Icon(Icons.person_outline), - tooltip: '내 정보', + tooltip: tr('ui.userfront.nav.profile', fallback: '내 정보'), onPressed: () => context.push('/profile'), ), IconButton( icon: const Icon(Icons.qr_code_scanner), - tooltip: 'QR 스캔', + tooltip: tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'), onPressed: _onScanQR, ), IconButton( icon: const Icon(Icons.logout), - tooltip: '로그아웃', + tooltip: tr('ui.userfront.nav.logout', fallback: '로그아웃'), onPressed: _logout, ), ], @@ -626,11 +778,23 @@ class _DashboardScreenState extends ConsumerState { _buildHeaderCard(userName, department, sessionIssuedAt), const SizedBox(height: 28), ], - _buildSectionTitle('나의 App 현황', '현재 연결된 앱과 최근 인증 상태입니다.'), + _buildSectionTitle( + tr('ui.userfront.sections.apps', fallback: '나의 App 현황'), + tr( + 'msg.userfront.sections.apps_subtitle', + fallback: '현재 연결된 앱과 최근 인증 상태입니다.', + ), + ), const SizedBox(height: 12), _buildActivitySection(isMobile), const SizedBox(height: 28), - _buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'), + _buildSectionTitle( + tr('ui.userfront.sections.audit', fallback: '접속이력'), + tr( + 'msg.userfront.sections.audit_subtitle', + fallback: 'Baron 로그인 기준의 최근 접근 기록입니다.', + ), + ), const SizedBox(height: 12), _buildAccessHistory(timelineState, timelineWide), ], @@ -647,12 +811,18 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildHeaderCard(String userName, String department, DateTime? issuedAt) { - final sessionLabel = issuedAt != null ? _formatDateTime(issuedAt) : '알 수 없음'; + final sessionLabel = issuedAt != null + ? _formatDateTime(issuedAt) + : tr('ui.userfront.session.unknown', fallback: '알 수 없음'); final infoColumn = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '안녕하세요, $userName님', + tr( + 'msg.userfront.greeting', + fallback: '안녕하세요, {{name}}님', + params: {'name': userName}, + ), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: _ink), ), const SizedBox(height: 6), @@ -665,7 +835,10 @@ class _DashboardScreenState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - _buildInfoChip(Icons.verified_user, '세션 활성'), + _buildInfoChip( + Icons.verified_user, + tr('ui.userfront.session.active', fallback: '세션 활성'), + ), _buildInfoChip(Icons.lock_outline, _authMethodLabel()), _buildInfoChip(Icons.access_time, sessionLabel), ], @@ -682,7 +855,7 @@ class _DashboardScreenState extends ConsumerState { border: Border.all(color: _border), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.04), + color: Colors.black.withValues(alpha: 10), blurRadius: 18, offset: const Offset(0, 8), ), @@ -737,19 +910,24 @@ class _DashboardScreenState extends ConsumerState { return linkedRpsState.when( data: (linkedRps) { final activities = _buildActivityItems(linkedRps); - final grid = _buildActivityGrid(activities, isMobile); if (activities.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '연동된 앱이 없습니다.', + tr( + 'msg.userfront.dashboard.activities.empty', + fallback: '연동된 앱이 없습니다.', + ), style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600), ), const SizedBox(height: 6), Text( - '앱을 연동하면 최근 활동과 상태가 표시됩니다.', + tr( + 'msg.userfront.dashboard.activities.empty_detail', + fallback: '앱을 연동하면 최근 활동과 상태가 표시됩니다.', + ), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], @@ -765,13 +943,16 @@ class _DashboardScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '연동 정보를 불러오지 못했습니다.', + tr( + 'msg.userfront.dashboard.activities.error', + fallback: '연동 정보를 불러오지 못했습니다.', + ), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), const SizedBox(height: 8), TextButton( onPressed: () => ref.read(linkedRpsProvider.notifier).refresh(), - child: const Text('다시 시도'), + child: Text(tr('ui.common.retry', fallback: '다시 시도')), ), ], ), @@ -790,9 +971,9 @@ class _DashboardScreenState extends ConsumerState { final lastAuthLabel = rp.lastAuthenticatedAt != null ? _formatDateTime(rp.lastAuthenticatedAt!) - : '연동됨'; + : tr('ui.userfront.dashboard.activity.linked', fallback: '연동됨'); - final statusLabel = isRevoked ? '해지됨' : '활성'; + final statusCode = isRevoked ? 'revoked' : 'active'; final name = rp.name.isNotEmpty ? rp.name : rp.id; items.add( @@ -800,9 +981,8 @@ class _DashboardScreenState extends ConsumerState { clientId: rp.id, appName: name, lastAuthAt: lastAuthLabel, - status: statusLabel, + status: statusCode, scopes: rp.scopes, - canLogout: false, isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), url: rp.url, @@ -813,8 +993,8 @@ class _DashboardScreenState extends ConsumerState { // 정렬 로직 적용: 활성 우선 -> 최근 인증 최신순 -> 비활성 items.sort((a, b) { - final aActive = a.status == '활성'; - final bActive = b.status == '활성'; + final aActive = a.status == 'active'; + final bActive = b.status == 'active'; if (aActive && !bActive) return -1; if (!aActive && bActive) return 1; @@ -890,7 +1070,9 @@ class _DashboardScreenState extends ConsumerState { color: _showAllActivities ? Colors.grey : Colors.blueAccent, ), label: Text( - _showAllActivities ? '접기' : '+ 더보기', + _showAllActivities + ? tr('ui.common.collapse', fallback: '접기') + : tr('ui.common.show_more', fallback: '+ 더보기'), style: TextStyle( color: _showAllActivities ? Colors.grey : Colors.blueAccent, fontWeight: FontWeight.bold, @@ -906,9 +1088,9 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) { - final isActive = item.status == '활성'; + final isActive = item.status == 'active'; final statusColor = isActive ? Colors.green : Colors.grey; - final borderColor = isActive ? Colors.green.withOpacity(0.5) : _border; + final borderColor = isActive ? Colors.green.withValues(alpha: 128) : _border; final borderWidth = isActive ? 1.5 : 1.0; // 활성 상태면 클릭 가능 (URL 유무와 관계없이) @@ -924,7 +1106,7 @@ class _DashboardScreenState extends ConsumerState { border: Border.all(color: borderColor, width: borderWidth), boxShadow: isActive ? [ BoxShadow( - color: Colors.green.withOpacity(0.05), + color: Colors.green.withValues(alpha: 13), blurRadius: 10, offset: const Offset(0, 4), ) @@ -944,11 +1126,13 @@ class _DashboardScreenState extends ConsumerState { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: statusColor.withOpacity(0.12), + color: statusColor.withValues(alpha: 31), borderRadius: BorderRadius.circular(999), ), child: Text( - item.status, + item.status == 'active' + ? tr('ui.common.status.active', fallback: '활성') + : tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨'), style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600), ), ), @@ -956,7 +1140,7 @@ class _DashboardScreenState extends ConsumerState { ), const SizedBox(height: 12), Text( - '최근 인증', + tr('ui.userfront.dashboard.last_auth_label', fallback: '최근 인증'), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), const SizedBox(height: 4), @@ -975,23 +1159,13 @@ class _DashboardScreenState extends ConsumerState { side: const BorderSide(color: _border), padding: const EdgeInsets.symmetric(vertical: 8), ), - child: const Text('상세정보', style: TextStyle(fontSize: 13)), + child: Text( + tr('ui.common.details', fallback: '상세정보'), + style: const TextStyle(fontSize: 13), + ), ), ), const SizedBox(width: 8), - if (item.canLogout) - Expanded( - child: OutlinedButton( - onPressed: item.onLogout, - style: OutlinedButton.styleFrom( - foregroundColor: _ink, - side: const BorderSide(color: _border), - padding: const EdgeInsets.symmetric(vertical: 8), - ), - child: const Text('로그아웃', style: TextStyle(fontSize: 13)), - ), - ), - if (item.canLogout) const SizedBox(width: 8), Expanded( child: OutlinedButton( onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke, @@ -1006,7 +1180,12 @@ class _DashboardScreenState extends ConsumerState { height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent), ) - : Text(item.isRevoked ? '해지됨' : '연동 해지', style: const TextStyle(fontSize: 13)), + : Text( + item.isRevoked + ? tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨') + : tr('ui.userfront.dashboard.revoke.title', fallback: '연동 해지'), + style: const TextStyle(fontSize: 13), + ), ), ), ], @@ -1027,23 +1206,37 @@ class _DashboardScreenState extends ConsumerState { cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () async { + final messenger = ScaffoldMessenger.of(context); if (item.url != null && item.url!.isNotEmpty) { final uri = Uri.parse(item.url!); - if (await canLaunchUrl(uri)) { + final canOpen = await canLaunchUrl(uri); + if (!mounted) return; + if (canOpen) { await launchUrl(uri); - } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('해당 링크를 열 수 없습니다.')), - ); - } + return; } + messenger.showSnackBar( + SnackBar( + content: Text( + tr( + 'msg.userfront.dashboard.link_open_error', + fallback: '해당 링크를 열 수 없습니다.', + ), + ), + ), + ); } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('이동할 페이지 주소(Client URI)가 설정되지 않았습니다.')), - ); - } + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text( + tr( + 'msg.userfront.dashboard.link_missing', + fallback: '이동할 페이지 주소(Client URI)가 설정되지 않았습니다.', + ), + ), + ), + ); } }, child: opaqueCard, @@ -1067,11 +1260,16 @@ class _DashboardScreenState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('접속이력을 불러오지 못했습니다.'), + Text( + tr( + 'msg.userfront.dashboard.audit_load_error', + fallback: '접속이력을 불러오지 못했습니다.', + ), + ), const SizedBox(height: 8), TextButton( onPressed: () => ref.read(authTimelineProvider.notifier).refresh(), - child: const Text('다시 시도'), + child: Text(tr('ui.common.retry', fallback: '다시 시도')), ), ], ), @@ -1083,7 +1281,10 @@ class _DashboardScreenState extends ConsumerState { return _buildHistoryContainer( child: Center( child: Text( - '최근 접속 이력이 없습니다.', + tr( + 'msg.userfront.dashboard.audit_empty', + fallback: '최근 접속 이력이 없습니다.', + ), style: TextStyle(color: Colors.grey[600]), ), ), @@ -1122,30 +1323,92 @@ class _DashboardScreenState extends ConsumerState { child: DataTable( columnSpacing: 16, horizontalMargin: 12, - columns: const [ - DataColumn(label: Text('Session ID')), - DataColumn(label: Text('접속일자')), - DataColumn(label: Text('애플리케이션')), - DataColumn(label: Text('IP')), - DataColumn(label: Text('접속환경')), - DataColumn(label: Text('인증수단')), - DataColumn(label: Text('인증결과')), - DataColumn(label: Text('현황')), + columns: [ + DataColumn( + label: Text( + tr('ui.userfront.audit.table.session_id', fallback: 'Session ID'), + ), + ), + DataColumn( + label: Text( + tr('ui.userfront.audit.table.date', fallback: '접속일자'), + ), + ), + DataColumn( + label: Text( + tr('ui.userfront.audit.table.app', fallback: '애플리케이션'), + ), + ), + DataColumn( + label: Text( + tr('ui.userfront.audit.table.ip', fallback: 'IP'), + ), + ), + DataColumn( + label: Text( + tr('ui.userfront.audit.table.device', fallback: '접속환경'), + ), + ), + DataColumn( + label: Text( + tr('ui.userfront.audit.table.auth_method', fallback: '인증수단'), + ), + ), + DataColumn( + label: Text( + tr('ui.userfront.audit.table.result', fallback: '인증결과'), + ), + ), + DataColumn( + label: Text( + tr('ui.userfront.audit.table.status', fallback: '현황'), + ), + ), ], rows: state.items.map((log) { - final statusLabel = log.status == 'success' ? '성공' : '실패'; - final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; - final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); + final statusLabel = log.status == 'success' + ? tr('ui.common.status.success', fallback: '성공') + : tr('ui.common.status.failure', fallback: '실패'); + final statusColor = + log.status == 'success' ? Colors.green : Colors.redAccent; + final authMethod = log.authMethod.isNotEmpty + ? log.authMethod + : _authMethodLabel(); final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); return DataRow(cells: [ - DataCell(_selectableText(log.sessionId.isEmpty ? '-' : log.sessionId)), + DataCell( + _selectableText( + log.sessionId.isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : log.sessionId, + ), + ), DataCell(_selectableText(_formatDateTime(log.timestamp))), DataCell(_buildAppCell(log)), - DataCell(_selectableText(log.ipAddress.isEmpty ? '-' : log.ipAddress)), + DataCell( + _selectableText( + log.ipAddress.isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : log.ipAddress, + ), + ), DataCell(_selectableText(deviceLabel)), DataCell(_buildAuthMethodCell(log, authMethod)), - DataCell(_selectableText(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))), - DataCell(_selectableText('(준비중)', style: const TextStyle(color: Colors.grey))), + DataCell( + _selectableText( + statusLabel, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ), + DataCell( + _selectableText( + tr('ui.userfront.audit.table.pending', fallback: '(준비중)'), + style: const TextStyle(color: Colors.grey), + ), + ), ]); }).toList(), ), @@ -1184,7 +1447,9 @@ class _DashboardScreenState extends ConsumerState { ), ), _selectableText( - log.status == 'success' ? '성공' : '실패', + log.status == 'success' + ? tr('ui.common.status.success', fallback: '성공') + : tr('ui.common.status.failure', fallback: '실패'), style: TextStyle( color: log.status == 'success' ? Colors.green : Colors.redAccent, fontWeight: FontWeight.w600, @@ -1193,13 +1458,61 @@ class _DashboardScreenState extends ConsumerState { ], ), const SizedBox(height: 6), - _selectableText('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'), - _selectableText('접속일자: ${_formatDateTime(log.timestamp)}'), - _selectableText('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'), - _selectableText('접속환경: ${_deviceLabelFromUserAgent(log.userAgent)}'), + _selectableText( + tr( + 'msg.userfront.audit.session_id', + fallback: 'Session ID: {{value}}', + params: { + 'value': log.sessionId.isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : log.sessionId, + }, + ), + ), + _selectableText( + tr( + 'msg.userfront.audit.date', + fallback: '접속일자: {{value}}', + params: {'value': _formatDateTime(log.timestamp)}, + ), + ), + _selectableText( + tr( + 'msg.userfront.audit.ip', + fallback: '접속 IP: {{value}}', + params: { + 'value': log.ipAddress.isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : log.ipAddress, + }, + ), + ), + _selectableText( + tr( + 'msg.userfront.audit.device', + fallback: '접속환경: {{value}}', + params: {'value': _deviceLabelFromUserAgent(log.userAgent)}, + ), + ), _buildAuthMethodLine(log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel()), - _selectableText('인증결과: ${log.status == 'success' ? '성공' : '실패'}'), - _selectableText('현황: (준비중)', style: TextStyle(color: Colors.grey[600])), + _selectableText( + tr( + 'msg.userfront.audit.result', + fallback: '인증결과: {{value}}', + params: { + 'value': log.status == 'success' + ? tr('ui.common.status.success', fallback: '성공') + : tr('ui.common.status.failure', fallback: '실패'), + }, + ), + ), + _selectableText( + tr( + 'msg.userfront.audit.status', + fallback: '현황: (준비중)', + ), + style: TextStyle(color: Colors.grey[600]), + ), ], ), ), @@ -1222,10 +1535,15 @@ class _DashboardScreenState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('더 불러오지 못했습니다.'), + Text( + tr( + 'msg.userfront.audit.load_more_error', + fallback: '더 불러오지 못했습니다.', + ), + ), TextButton( onPressed: () => ref.read(authTimelineProvider.notifier).loadMore(), - child: const Text('재시도'), + child: Text(tr('ui.common.retry', fallback: '재시도')), ), ], ), @@ -1235,7 +1553,7 @@ class _DashboardScreenState extends ConsumerState { return Padding( padding: const EdgeInsets.only(top: 8), child: Text( - '더 이상 항목이 없습니다.', + tr('msg.userfront.audit.end', fallback: '더 이상 항목이 없습니다.'), style: TextStyle(color: Colors.grey[600], fontSize: 12), ), ); @@ -1251,9 +1569,7 @@ class _ActivityItem { final String status; final String? url; final List scopes; - final bool canLogout; final bool isRevoked; - final VoidCallback? onLogout; final VoidCallback? onRevoke; final DateTime? lastAuthDateTime; @@ -1263,11 +1579,9 @@ class _ActivityItem { required this.lastAuthAt, required this.status, required this.scopes, - required this.canLogout, this.url, this.isRevoked = false, - this.onLogout, this.onRevoke, this.lastAuthDateTime, }); -} \ No newline at end of file +} diff --git a/userfront/lib/features/profile/data/repositories/profile_repository.dart b/userfront/lib/features/profile/data/repositories/profile_repository.dart index 58edf995..c5659f50 100644 --- a/userfront/lib/features/profile/data/repositories/profile_repository.dart +++ b/userfront/lib/features/profile/data/repositories/profile_repository.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:userfront/i18n.dart'; import '../models/user_profile_model.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; @@ -23,7 +24,9 @@ class ProfileRepository { final token = await _getToken(); final useCookie = AuthTokenStore.usesCookie(); if (token == null && !useCookie) { - throw Exception('No active session'); + throw Exception( + tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'), + ); } final url = Uri.parse('$_baseUrl/api/v1/user/me'); @@ -40,7 +43,13 @@ class ProfileRepository { if (response.statusCode == 200) { return UserProfile.fromJson(jsonDecode(response.body)); } else { - throw Exception('Failed to load profile: ${response.body}'); + throw Exception( + tr( + 'err.userfront.profile.load_failed', + fallback: '프로필을 불러오지 못했습니다: {{error}}', + params: {'error': response.body}, + ), + ); } } @@ -51,7 +60,11 @@ class ProfileRepository { }) async { final token = await _getToken(); final useCookie = AuthTokenStore.usesCookie(); - if (token == null && !useCookie) throw Exception('No active session'); + if (token == null && !useCookie) { + throw Exception( + tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'), + ); + } final url = Uri.parse('$_baseUrl/api/v1/user/me'); final client = createHttpClient(withCredentials: useCookie); @@ -73,14 +86,24 @@ class ProfileRepository { client.close(); if (response.statusCode != 200) { - throw Exception('Failed to update profile: ${response.body}'); + throw Exception( + tr( + 'err.userfront.profile.update_failed', + fallback: '프로필 업데이트에 실패했습니다: {{error}}', + params: {'error': response.body}, + ), + ); } } Future sendUpdateCode(String phone) async { final token = await _getToken(); final useCookie = AuthTokenStore.usesCookie(); - if (token == null && !useCookie) throw Exception('No active session'); + if (token == null && !useCookie) { + throw Exception( + tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'), + ); + } final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code'); final client = createHttpClient(withCredentials: useCookie); @@ -98,7 +121,13 @@ class ProfileRepository { client.close(); if (response.statusCode != 200) { - throw Exception('인증번호 전송 실패: ${response.body}'); + throw Exception( + tr( + 'err.userfront.profile.send_code_failed', + fallback: '인증번호 전송 실패: {{error}}', + params: {'error': response.body}, + ), + ); } } @@ -108,7 +137,11 @@ class ProfileRepository { }) async { final token = await _getToken(); final useCookie = AuthTokenStore.usesCookie(); - if (token == null && !useCookie) throw Exception('No active session'); + if (token == null && !useCookie) { + throw Exception( + tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'), + ); + } final url = Uri.parse('$_baseUrl/api/v1/user/me/password'); final client = createHttpClient(withCredentials: useCookie); @@ -129,14 +162,24 @@ class ProfileRepository { client.close(); if (response.statusCode != 200) { - throw Exception('Failed to change password: ${response.body}'); + throw Exception( + tr( + 'err.userfront.profile.password_change_failed', + fallback: '비밀번호 변경에 실패했습니다: {{error}}', + params: {'error': response.body}, + ), + ); } } Future verifyUpdateCode(String phone, String code) async { final token = await _getToken(); final useCookie = AuthTokenStore.usesCookie(); - if (token == null && !useCookie) throw Exception('No active session'); + if (token == null && !useCookie) { + throw Exception( + tr('err.userfront.session.missing', fallback: '활성 세션이 없습니다.'), + ); + } final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code'); final client = createHttpClient(withCredentials: useCookie); @@ -154,7 +197,13 @@ class ProfileRepository { client.close(); if (response.statusCode != 200) { - throw Exception('인증 실패: ${response.body}'); + throw Exception( + tr( + 'err.userfront.profile.verify_code_failed', + fallback: '인증 실패: {{error}}', + params: {'error': response.body}, + ), + ); } } } diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 223383a8..0965dfef 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:userfront/i18n.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/ui/layout_breakpoints.dart'; @@ -229,14 +230,29 @@ class _ProfilePageState extends ConsumerState { }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('인증번호가 전송되었습니다.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.phone.code_sent', + fallback: '인증번호가 전송되었습니다.', + ), + ), + ), ); } } catch (e) { setState(() => _isVerifying = false); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('전송 실패: $e')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.phone.send_failed', + fallback: '전송 실패: {{error}}', + params: {'error': e.toString()}, + ), + ), + ), ); } } @@ -256,7 +272,14 @@ class _ProfilePageState extends ConsumerState { }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('인증되었습니다.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.phone.verified', + fallback: '인증되었습니다.', + ), + ), + ), ); } if (_editingField == 'phone') { @@ -266,7 +289,15 @@ class _ProfilePageState extends ConsumerState { setState(() => _isVerifying = false); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('인증 실패: $e')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.phone.verify_failed', + fallback: '인증 실패: {{error}}', + params: {'error': e.toString()}, + ), + ), + ), ); } } @@ -279,15 +310,24 @@ class _ProfilePageState extends ConsumerState { final confirmPassword = _confirmPasswordController?.text.trim() ?? ''; if (currentPassword.isEmpty) { - setState(() => _passwordError = '현재 비밀번호를 입력해 주세요.'); + setState(() => _passwordError = tr( + 'msg.userfront.profile.password.current_required', + fallback: '현재 비밀번호를 입력해 주세요.', + )); return; } if (newPassword.isEmpty) { - setState(() => _passwordError = '새 비밀번호를 입력해 주세요.'); + setState(() => _passwordError = tr( + 'msg.userfront.profile.password.new_required', + fallback: '새 비밀번호를 입력해 주세요.', + )); return; } if (newPassword != confirmPassword) { - setState(() => _passwordError = '새 비밀번호가 일치하지 않습니다.'); + setState(() => _passwordError = tr( + 'msg.userfront.profile.password.mismatch', + fallback: '새 비밀번호가 일치하지 않습니다.', + )); return; } @@ -306,12 +346,19 @@ class _ProfilePageState extends ConsumerState { _newPasswordController?.clear(); _confirmPasswordController?.clear(); setState(() { - _passwordSuccess = '비밀번호가 변경되었습니다.'; + _passwordSuccess = tr( + 'msg.userfront.profile.password.changed', + fallback: '비밀번호가 변경되었습니다.', + ); }); } catch (e) { final message = e.toString().replaceFirst('Exception: ', ''); setState(() { - _passwordError = '비밀번호 변경 실패: $message'; + _passwordError = tr( + 'msg.userfront.profile.password.change_failed', + fallback: '비밀번호 변경 실패: {{error}}', + params: {'error': message}, + ); }); } finally { if (mounted) { @@ -385,26 +432,54 @@ class _ProfilePageState extends ConsumerState { if (_editingField == 'name' && nextName.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('이름을 입력해주세요.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.name_required', + fallback: '이름을 입력해주세요.', + ), + ), + ), ); return; } if (_editingField == 'department' && nextDepartment.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('소속을 입력해주세요.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.department_required', + fallback: '소속을 입력해주세요.', + ), + ), + ), ); return; } if (_editingField == 'phone') { if (nextPhone.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('휴대폰 번호를 입력해주세요.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.phone_required', + fallback: '휴대폰 번호를 입력해주세요.', + ), + ), + ), ); return; } if (_isPhoneChanged && !_isPhoneVerified) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.phone_verify_required', + fallback: '휴대폰 번호 인증이 필요합니다.', + ), + ), + ), ); return; } @@ -441,13 +516,28 @@ class _ProfilePageState extends ConsumerState { _departmentTouched = false; }); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('정보가 수정되었습니다.')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.update_success', + fallback: '정보가 수정되었습니다.', + ), + ), + ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('수정 실패: $e')), + SnackBar( + content: Text( + tr( + 'msg.userfront.profile.update_failed', + fallback: '수정 실패: {{error}}', + params: {'error': e.toString()}, + ), + ), + ), ); } } finally { @@ -461,24 +551,32 @@ class _ProfilePageState extends ConsumerState { children: [ ListTile( leading: const Icon(Icons.home_outlined), - title: const Text('대시보드'), + title: Text( + tr('ui.userfront.nav.dashboard', fallback: '대시보드'), + ), onTap: () => context.go('/'), ), ListTile( leading: const Icon(Icons.person_outline), - title: const Text('내 정보'), + title: Text( + tr('ui.userfront.nav.profile', fallback: '내 정보'), + ), selected: true, onTap: () => context.go('/profile'), ), ListTile( leading: const Icon(Icons.qr_code_scanner), - title: const Text('QR 스캔'), + title: Text( + tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'), + ), onTap: () => context.go('/scan'), ), const Divider(), ListTile( leading: const Icon(Icons.logout), - title: const Text('로그아웃'), + title: Text( + tr('ui.userfront.nav.logout', fallback: '로그아웃'), + ), onTap: _logout, ), ], @@ -525,9 +623,15 @@ class _ProfilePageState extends ConsumerState { } Widget _buildHeaderCard(UserProfile profile) { - final name = profile.name.isEmpty ? '이름 없음' : profile.name; - final email = profile.email.isEmpty ? '이메일 없음' : profile.email; - final department = profile.department.isEmpty ? '소속 정보 없음' : profile.department; + final name = profile.name.isEmpty + ? tr('msg.userfront.profile.name_missing', fallback: '이름 없음') + : profile.name; + final email = profile.email.isEmpty + ? tr('msg.userfront.profile.email_missing', fallback: '이메일 없음') + : profile.email; + final department = profile.department.isEmpty + ? tr('msg.userfront.profile.department_missing', fallback: '소속 정보 없음') + : profile.department; return Container( width: double.infinity, @@ -538,7 +642,7 @@ class _ProfilePageState extends ConsumerState { border: Border.all(color: _border), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.04), + color: Colors.black.withValues(alpha: 10), blurRadius: 18, offset: const Offset(0, 8), ), @@ -556,8 +660,16 @@ class _ProfilePageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '안녕하세요, $name님', - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: _ink), + tr( + 'msg.userfront.profile.greeting', + fallback: '안녕하세요, {{name}}님', + params: {'name': name}, + ), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: _ink, + ), ), const SizedBox(height: 6), Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)), @@ -566,7 +678,10 @@ class _ProfilePageState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - _buildInfoChip(Icons.badge_outlined, '프로필 관리'), + _buildInfoChip( + Icons.badge_outlined, + tr('ui.userfront.profile.manage', fallback: '프로필 관리'), + ), _buildInfoChip(Icons.apartment, profile.tenant?.name ?? department), ], ), @@ -588,7 +703,7 @@ class _ProfilePageState extends ConsumerState { border: Border.all(color: _border), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.03), + color: Colors.black.withValues(alpha: 8), blurRadius: 12, offset: const Offset(0, 6), ), @@ -605,7 +720,7 @@ class _ProfilePageState extends ConsumerState { title: Text(label), subtitle: Text(displayValue), trailing: Text( - '읽기 전용', + tr('ui.common.read_only', fallback: '읽기 전용'), style: TextStyle(color: Colors.grey[500], fontSize: 12), ), ); @@ -629,7 +744,7 @@ class _ProfilePageState extends ConsumerState { subtitle: Text(displayValue), trailing: TextButton( onPressed: isUpdating ? null : () => _startEditing(field, profile), - child: const Text('수정'), + child: Text(tr('ui.common.edit', fallback: '수정')), ), ); } @@ -657,7 +772,7 @@ class _ProfilePageState extends ConsumerState { const SizedBox(width: 12), OutlinedButton( onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: const Text('취소'), + child: Text(tr('ui.common.cancel', fallback: '취소')), ), ], ), @@ -672,11 +787,13 @@ class _ProfilePageState extends ConsumerState { if (!isEditing) { return ListTile( contentPadding: EdgeInsets.zero, - title: const Text('전화번호'), + title: Text( + tr('ui.userfront.profile.phone.title', fallback: '전화번호'), + ), subtitle: Text(displayValue), trailing: TextButton( onPressed: isUpdating ? null : () => _startEditing('phone', profile), - child: const Text('수정'), + child: Text(tr('ui.common.edit', fallback: '수정')), ), ); } @@ -684,7 +801,10 @@ class _ProfilePageState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('전화번호', style: TextStyle(fontWeight: FontWeight.w600)), + Text( + tr('ui.userfront.profile.phone.title', fallback: '전화번호'), + style: const TextStyle(fontWeight: FontWeight.w600), + ), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.end, @@ -710,12 +830,19 @@ class _ProfilePageState extends ConsumerState { if (_isPhoneChanged && !_isPhoneVerified) ElevatedButton( onPressed: _isVerifying ? null : _sendCode, - child: Text(_isCodeSent ? '재전송' : '인증요청'), + child: Text( + _isCodeSent + ? tr('ui.common.resend', fallback: '재전송') + : tr( + 'ui.userfront.profile.phone.request_code', + fallback: '인증요청', + ), + ), ), const SizedBox(width: 8), OutlinedButton( onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: const Text('취소'), + child: Text(tr('ui.common.cancel', fallback: '취소')), ), ], ), @@ -731,26 +858,32 @@ class _ProfilePageState extends ConsumerState { keyboardType: TextInputType.number, textInputAction: TextInputAction.done, onSubmitted: (_) => _verifyCode(profile), - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: '인증번호 6자리', + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: tr( + 'ui.userfront.profile.phone.code_hint', + fallback: '인증번호 6자리', + ), ), ), ), const SizedBox(width: 8), ElevatedButton( onPressed: _isVerifying ? null : () => _verifyCode(profile), - child: const Text('확인'), + child: Text(tr('ui.common.confirm', fallback: '확인')), ), ], ), ], if (_isPhoneChanged && !_isPhoneVerified) - const Padding( - padding: EdgeInsets.only(top: 8.0), + Padding( + padding: const EdgeInsets.only(top: 8.0), child: Text( - '휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.', - style: TextStyle(color: Colors.orange, fontSize: 12), + tr( + 'msg.userfront.profile.phone.verify_notice', + fallback: '휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.', + ), + style: const TextStyle(color: Colors.orange, fontSize: 12), ), ), ], @@ -763,20 +896,26 @@ class _ProfilePageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '비밀번호 변경', + tr('ui.userfront.profile.password.title', fallback: '비밀번호 변경'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), const SizedBox(height: 8), - const Text( - '현재 비밀번호 확인 후 새 비밀번호로 변경합니다.', - style: TextStyle(color: Color(0xFF6B7280)), + Text( + tr( + 'msg.userfront.profile.password.subtitle', + fallback: '현재 비밀번호 확인 후 새 비밀번호로 변경합니다.', + ), + style: const TextStyle(color: Color(0xFF6B7280)), ), const SizedBox(height: 16), TextField( controller: _currentPasswordController, obscureText: !_showCurrentPassword, decoration: InputDecoration( - labelText: '현재 비밀번호', + labelText: tr( + 'ui.userfront.profile.password.current', + fallback: '현재 비밀번호', + ), border: const OutlineInputBorder(), suffixIcon: IconButton( icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility), @@ -791,7 +930,10 @@ class _ProfilePageState extends ConsumerState { controller: _newPasswordController, obscureText: !_showNewPassword, decoration: InputDecoration( - labelText: '새 비밀번호', + labelText: tr( + 'ui.userfront.profile.password.new', + fallback: '새 비밀번호', + ), border: const OutlineInputBorder(), suffixIcon: IconButton( icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility), @@ -806,7 +948,10 @@ class _ProfilePageState extends ConsumerState { controller: _confirmPasswordController, obscureText: !_showConfirmPassword, decoration: InputDecoration( - labelText: '새 비밀번호 확인', + labelText: tr( + 'ui.userfront.profile.password.confirm', + fallback: '새 비밀번호 확인', + ), border: const OutlineInputBorder(), suffixIcon: IconButton( icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility), @@ -841,12 +986,22 @@ class _ProfilePageState extends ConsumerState { height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('비밀번호 변경'), + : Text( + tr( + 'ui.userfront.profile.password.change', + fallback: '비밀번호 변경', + ), + ), ), const SizedBox(width: 12), TextButton( onPressed: () => context.go('/recovery'), - child: const Text('비밀번호를 잊으셨나요?'), + child: Text( + tr( + 'ui.userfront.profile.password.forgot', + fallback: '비밀번호를 잊으셨나요?', + ), + ), ), ], ), @@ -869,55 +1024,88 @@ class _ProfilePageState extends ConsumerState { children: [ _buildHeaderCard(profile), const SizedBox(height: 28), - _buildSectionTitle('기본 정보', '계정 기본 정보를 관리합니다.'), + _buildSectionTitle( + tr('ui.userfront.profile.section.basic', fallback: '기본 정보'), + tr( + 'msg.userfront.profile.section.basic', + fallback: '계정 기본 정보를 관리합니다.', + ), + ), const SizedBox(height: 12), _buildCard( Column( children: [ _buildEditableTile( field: 'name', - label: '이름', + label: tr('ui.userfront.profile.field.name', fallback: '이름'), value: profile.name, profile: profile, isUpdating: isUpdating, controller: _nameController!, ), const Divider(height: 24), - _buildReadOnlyTile('이메일', profile.email), + _buildReadOnlyTile( + tr('ui.userfront.profile.field.email', fallback: '이메일'), + profile.email, + ), const Divider(height: 24), _buildPhoneEditor(profile, isUpdating), ], ), ), const SizedBox(height: 28), - _buildSectionTitle('조직 정보', '소속 및 구분 정보입니다.'), + _buildSectionTitle( + tr('ui.userfront.profile.section.organization', fallback: '조직 정보'), + tr( + 'msg.userfront.profile.section.organization', + fallback: '소속 및 구분 정보입니다.', + ), + ), const SizedBox(height: 12), _buildCard( Column( children: [ _buildEditableTile( field: 'department', - label: '소속', + label: tr('ui.userfront.profile.field.department', fallback: '소속'), value: profile.department, profile: profile, isUpdating: isUpdating, controller: _departmentController!, ), const Divider(height: 24), - _buildReadOnlyTile('구분', profile.affiliationType), + _buildReadOnlyTile( + tr('ui.userfront.profile.field.affiliation', fallback: '구분'), + profile.affiliationType, + ), if (profile.tenant != null) ...[ const Divider(height: 24), - _buildReadOnlyTile('소속 테넌트', profile.tenant!.name), + _buildReadOnlyTile( + tr( + 'ui.userfront.profile.field.tenant', + fallback: '소속 테넌트', + ), + profile.tenant!.name, + ), ], if (profile.companyCode.isNotEmpty) ...[ const Divider(height: 24), - _buildReadOnlyTile('회사코드', profile.companyCode), + _buildReadOnlyTile( + tr('ui.userfront.profile.field.company_code', fallback: '회사코드'), + profile.companyCode, + ), ], ], ), ), const SizedBox(height: 28), - _buildSectionTitle('보안', '비밀번호를 안전하게 관리합니다.'), + _buildSectionTitle( + tr('ui.userfront.profile.section.security', fallback: '보안'), + tr( + 'msg.userfront.profile.section.security', + fallback: '비밀번호를 안전하게 관리합니다.', + ), + ), const SizedBox(height: 12), _buildPasswordSection(), if (isUpdating || _isVerifying) ...[ @@ -943,18 +1131,25 @@ class _ProfilePageState extends ConsumerState { final profile = profileState.value ?? _cachedProfile; if (profile == null) { return Scaffold( - appBar: AppBar(title: const Text('내 정보')), + appBar: AppBar( + title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')), + ), body: profileState.isLoading ? const Center(child: CircularProgressIndicator()) : Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('정보를 불러올 수 없습니다.'), + Text( + tr( + 'msg.userfront.profile.load_failed', + fallback: '정보를 불러올 수 없습니다.', + ), + ), const SizedBox(height: 16), ElevatedButton( onPressed: () => ref.read(profileProvider.notifier).loadProfile(), - child: const Text('재시도'), + child: Text(tr('ui.common.retry', fallback: '재시도')), ), ], ), @@ -971,8 +1166,8 @@ class _ProfilePageState extends ConsumerState { backgroundColor: _subtle, appBar: AppBar( title: Text( - 'Baron 로그인', - style: TextStyle(fontWeight: FontWeight.bold), + tr('ui.userfront.app_title', fallback: 'Baron 로그인'), + style: const TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, backgroundColor: _surface, @@ -980,17 +1175,17 @@ class _ProfilePageState extends ConsumerState { actions: [ IconButton( icon: const Icon(Icons.home_outlined), - tooltip: '대시보드', + tooltip: tr('ui.userfront.nav.dashboard', fallback: '대시보드'), onPressed: () => context.go('/'), ), IconButton( icon: const Icon(Icons.qr_code_scanner), - tooltip: 'QR 스캔', + tooltip: tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'), onPressed: () => context.push('/scan'), ), IconButton( icon: const Icon(Icons.logout), - tooltip: '로그아웃', + tooltip: tr('ui.userfront.nav.logout', fallback: '로그아웃'), onPressed: _logout, ), ], diff --git a/userfront/lib/i18n.dart b/userfront/lib/i18n.dart new file mode 100644 index 00000000..9343c1e1 --- /dev/null +++ b/userfront/lib/i18n.dart @@ -0,0 +1,40 @@ +import 'dart:ui'; + +import 'i18n_data.dart'; + +const _defaultLocale = 'ko'; +const _supportedLocales = ['ko', 'en']; + +String _resolveLocale() { + final locale = PlatformDispatcher.instance.locale; + final code = locale.languageCode.toLowerCase(); + if (_supportedLocales.contains(code)) { + return code; + } + return _defaultLocale; +} + +String _formatTemplate(String template, Map? params) { + if (params == null || params.isEmpty) { + return template; + } + var result = template; + params.forEach((key, value) { + result = result.replaceAll('{{$key}}', value); + }); + return result; +} + +String tr( + String key, { + String? fallback, + Map? params, +}) { + final locale = _resolveLocale(); + final map = locale == 'en' ? enStrings : koStrings; + final value = map[key]; + final template = (value != null && value.isNotEmpty) + ? value + : (fallback ?? key); + return _formatTemplate(template, params); +} diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart new file mode 100644 index 00000000..12f54d2c --- /dev/null +++ b/userfront/lib/i18n_data.dart @@ -0,0 +1,1612 @@ +// locales/*.toml에서 생성됨 +const Map koStrings = { + "domain.affiliation.affiliate": "가족사 임직원", + "domain.affiliation.general": "일반 사용자", + "domain.company.baron": "바론", + "domain.company.halla": "한라", + "domain.company.hanmac": "한맥", + "domain.company.jangheon": "장헌", + "domain.company.ptc": "PTC", + "domain.company.saman": "삼안", + "err.common.unknown": "알 수 없는 오류가 발생했습니다.", + "err.userfront.auth_proxy.consent_accept": "동의 처리에 실패했습니다.", + "err.userfront.auth_proxy.consent_fetch": "동의 정보를 가져오지 못했습니다.", + "err.userfront.auth_proxy.consent_reject": "동의 거부에 실패했습니다.", + "err.userfront.auth_proxy.linked_app_revoke": "연동 해지에 실패했습니다.", + "err.userfront.auth_proxy.login_failed": "로그인에 실패했습니다.", + "err.userfront.auth_proxy.oidc_accept": "OIDC 로그인 승인에 실패했습니다.", + "err.userfront.auth_proxy.password_reset_complete": "비밀번호 재설정에 실패했습니다.", + "err.userfront.auth_proxy.password_reset_init": "비밀번호 재설정을 시작하지 못했습니다.", + "err.userfront.profile.load_failed": "프로필을 불러오지 못했습니다: {{error}}", + "err.userfront.profile.password_change_failed": "비밀번호 변경에 실패했습니다: {{error}}", + "err.userfront.profile.send_code_failed": "인증번호 전송 실패: {{error}}", + "err.userfront.profile.update_failed": "프로필 업데이트에 실패했습니다: {{error}}", + "err.userfront.profile.verify_code_failed": "인증 실패: {{error}}", + "err.userfront.session.missing": "활성 세션이 없습니다.", + "msg.admin.api_keys.create.error": "API 키 생성에 실패했습니다.", + "msg.admin.api_keys.create.name_required": "이름은 필수입니다.", + "msg.admin.api_keys.create.scope_required": "최소 하나 이상의 권한을 선택해야 합니다.", + "msg.admin.api_keys.create.scopes_count": "총 {{count}}개의 권한이 할당됩니다.", + "msg.admin.api_keys.create.scopes_hint": "생성 즉시 활성화되어 사용 가능합니다.", + "msg.admin.api_keys.create.subtitle": "내부 시스템 연동을 위한 보안 인증 키를 구성합니다.", + "msg.admin.api_keys.create.success.copy_hint": "복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.", + "msg.admin.api_keys.create.success.notice": "아래의 비밀번호(Secret)는 보안을 위해 ", + "msg.admin.api_keys.create.success.notice_emphasis": "지금 한 번만", + "msg.admin.api_keys.create.success.notice_suffix": "표시됩니다.", + "msg.admin.api_keys.list.delete_confirm": "API 키 \\\"{{name}}\\\"를 삭제할까요?", + "msg.admin.api_keys.list.empty": "등록된 API 키가 없습니다.", + "msg.admin.api_keys.list.fetch_error": "API 키 목록 조회에 실패했습니다.", + "msg.admin.api_keys.list.registry.count": "총 {{count}}개 API 키", + "msg.admin.api_keys.list.subtitle": "서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.", + "msg.admin.audit.empty": "아직 수집된 감사 로그가 없습니다.", + "msg.admin.audit.end": "End of audit feed", + "msg.admin.audit.filters.empty": "필터 없음", + "msg.admin.audit.load_error": "Error loading logs: {{error}}", + "msg.admin.audit.loading": "Loading audit logs...", + "msg.admin.audit.registry.count": "로드된 로그 {{count}}건", + "msg.admin.audit.subtitle": "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.", + "msg.admin.groups.list.subtitle": "이 테넌트에 정의된 사용자 그룹 목록입니다.", + "msg.admin.groups.members.count": "{{count}} 명", + "msg.admin.groups.members.empty": "멤버가 없습니다.", + "msg.admin.groups.members.title": "[{{name}}] 멤버 관리", + "msg.admin.groups.prompt.user_id": "추가할 사용자의 UUID를 입력하세요:", + "msg.admin.header.subtitle": "Tenant isolation & least privilege by default", + "msg.admin.idp_env_prod": "IDP env: prod", + "msg.admin.notice.idp_policy": "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다.", + "msg.admin.notice.scope": "관리 기능은 /admin 네임스페이스에서만 노출합니다.", + "msg.admin.overview.description": "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.", + "msg.admin.overview.idp_fallback": "Fallback: Descope", + "msg.admin.overview.idp_primary": "IDP: Ory primary", + "msg.admin.overview.playbook.description": "운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.", + "msg.admin.overview.playbook.idp_body": "모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.", + "msg.admin.overview.playbook.idp_title": "Backend-only IDP access", + "msg.admin.overview.playbook.tenant_body": "Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.", + "msg.admin.overview.playbook.tenant_title": "Tenant isolation", + "msg.admin.overview.quick_links.description": "주요 운영 화면으로 바로 이동합니다.", + "msg.admin.scope_admin": "Scoped to /admin", + "msg.admin.session_ttl": "Session TTL: 15m admin", + "msg.admin.tenant_headers": "Tenant-aware headers", + "msg.admin.tenants.create.form.domains_help": "Users with these email domains will be automatically assigned to this tenant.", + "msg.admin.tenants.create.memo.body": "생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.", + "msg.admin.tenants.create.memo.subtitle": "Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.", + "msg.admin.tenants.create.profile.subtitle": "필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.", + "msg.admin.tenants.create.subtitle": "글로벌 운영 기준의 신규 테넌트를 등록합니다.", + "msg.admin.tenants.delete_confirm": "테넌트 \\\"{{name}}\\\"를 삭제할까요?", + "msg.admin.tenants.empty": "아직 등록된 테넌트가 없습니다.", + "msg.admin.tenants.fetch_error": "테넌트 목록 조회에 실패했습니다.", + "msg.admin.tenants.members.empty": "소속된 사용자가 없습니다.", + "msg.admin.tenants.registry.count": "총 {{count}}개 테넌트", + "msg.admin.tenants.schema.empty": "No custom fields defined. Click \\\"Add Field\\\" to begin.", + "msg.admin.tenants.schema.missing_id": "Tenant ID missing", + "msg.admin.tenants.schema.subtitle": "Define custom attributes for users in this tenant.", + "msg.admin.tenants.schema.update_error": "Failed to update schema", + "msg.admin.tenants.schema.update_success": "Schema updated successfully", + "msg.admin.tenants.sub.empty": "하위 테넌트가 없습니다.", + "msg.admin.tenants.sub.subtitle": "현재 테넌트 하위에 생성된 조직입니다.", + "msg.admin.tenants.subtitle": "현재 등록된 테넌트를 확인하고 상태를 관리합니다.", + "msg.admin.users.create.account.subtitle": "새로운 사용자를 시스템에 등록합니다.", + "msg.admin.users.create.error": "사용자 생성에 실패했습니다.", + "msg.admin.users.create.form.email_required": "이메일은 필수입니다.", + "msg.admin.users.create.form.name_required": "이름은 필수입니다.", + "msg.admin.users.create.form.password_auto_help": "비워두면 시스템이 초기 비밀번호를 자동 생성합니다.", + "msg.admin.users.create.form.password_manual_help": "초기 비밀번호를 직접 설정합니다.", + "msg.admin.users.create.form.role_help": "시스템 접근 권한을 결정합니다.", + "msg.admin.users.create.password_generated.default": "초기 비밀번호가 생성되었습니다.", + "msg.admin.users.create.password_generated.with_email": "{{email}} 계정의 초기 비밀번호입니다.", + "msg.admin.users.create.password_required": "비밀번호를 입력하거나 자동 생성을 사용해 주세요.", + "msg.admin.users.detail.edit_subtitle": "{{email}} 계정의 정보를 수정합니다.", + "msg.admin.users.detail.form.name_required": "이름은 필수입니다.", + "msg.admin.users.detail.not_found": "사용자를 찾을 수 없습니다.", + "msg.admin.users.detail.security.password_hint": "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.", + "msg.admin.users.detail.update_error": "사용자 수정에 실패했습니다.", + "msg.admin.users.detail.update_success": "사용자 정보가 수정되었습니다.", + "msg.admin.users.list.delete_confirm": "사용자 \\\"{{name}}\\\"을(를) 정말 삭제하시겠습니까?", + "msg.admin.users.list.empty": "검색 결과가 없습니다.", + "msg.admin.users.list.fetch_error": "사용자 목록 조회에 실패했습니다.", + "msg.admin.users.list.registry.count": "총 {{count}}명의 사용자가 등록되어 있습니다.", + "msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다. (Local DB)", + "msg.common.loading": "로딩 중...", + "msg.common.saving": "저장 중...", + "msg.common.unknown_error": "unknown error", + "msg.dev.clients.consents.empty": "No consents found.", + "msg.dev.clients.consents.load_error": "Error loading consents: {{error}}", + "msg.dev.clients.consents.loading": "Loading consents...", + "msg.dev.clients.consents.showing": "Showing {{from}} to {{to}} of {{total}} users", + "msg.dev.clients.consents.subtitle": "OIDC Relying Party 사용자 권한을 검토·관리합니다.", + "msg.dev.clients.copy_client_id": "클라이언트 ID가 복사되었습니다.", + "msg.dev.clients.details.copy_client_id": "Client ID가 복사되었습니다.", + "msg.dev.clients.details.copy_client_secret": "Client Secret이 복사되었습니다.", + "msg.dev.clients.details.copy_endpoint": "{{label}}가 복사되었습니다.", + "msg.dev.clients.details.load_error": "Error loading client: {{error}}", + "msg.dev.clients.details.loading": "Loading client...", + "msg.dev.clients.details.missing_id": "Client ID가 필요합니다.", + "msg.dev.clients.details.redirect.description": "인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.", + "msg.dev.clients.details.redirect_saved": "Redirect URIs가 저장되었습니다.", + "msg.dev.clients.details.rotate_confirm": "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\\\\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?", + "msg.dev.clients.details.rotate_error": "재발급 실패: {{error}}", + "msg.dev.clients.details.save_error": "저장 실패: {{error}}", + "msg.dev.clients.details.secret_rotated": "Client Secret이 재발급되었습니다.", + "msg.dev.clients.details.secret_unavailable": "SECRET_NOT_AVAILABLE", + "msg.dev.clients.details.security.footer": "비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.", + "msg.dev.clients.details.security.note": "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.", + "msg.dev.clients.details.subtitle": "OIDC 자격 증명과 엔드포인트를 관리합니다.", + "msg.dev.clients.general.identity.logo_help": "인증 화면에 표시될 PNG/SVG URL입니다.", + "msg.dev.clients.general.identity.subtitle": "앱 이름과 설명, 로고를 설정합니다.", + "msg.dev.clients.general.load_error": "Error loading client: {{error}}", + "msg.dev.clients.general.loading": "Loading client...", + "msg.dev.clients.general.redirect.help": "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.", + "msg.dev.clients.general.saved": "설정이 저장되었습니다.", + "msg.dev.clients.general.scopes.empty": "등록된 스코프가 없습니다.", + "msg.dev.clients.general.scopes.subtitle": "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.", + "msg.dev.clients.general.security.confidential_help": "서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.", + "msg.dev.clients.general.security.public_help": "SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.", + "msg.dev.clients.general.security.subtitle": "클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.", + "msg.dev.clients.help.docs_body": "Includes PKCE, client_secret_basic, redirect URI validation tips.", + "msg.dev.clients.help.subtitle": "Developer guides for Confidential/Public clients, redirect URIs, and auth methods.", + "msg.dev.clients.load_error": "Error loading clients: {{error}}", + "msg.dev.clients.loading": "Loading clients...", + "msg.dev.clients.registry.description": "OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.", + "msg.dev.clients.scopes.email": "이메일 주소 접근", + "msg.dev.clients.scopes.openid": "OIDC 인증 필수 스코프", + "msg.dev.clients.scopes.profile": "기본 프로필 정보 접근", + "msg.dev.clients.showing": "Showing {{shown}} of {{total}} clients", + "msg.dev.clients.status_update_error": "Failed to update client status", + "msg.dev.clients.status_updated": "클라이언트가 {{status}}되었습니다.", + "msg.dev.dashboard.hero.body": "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.", + "msg.dev.dashboard.hero.title_emphasis": " 하나의 화면", + "msg.dev.dashboard.hero.title_prefix": "RP 등록 현황과 Consent 상태를", + "msg.dev.dashboard.hero.title_suffix": "에서 관리합니다.", + "msg.dev.dashboard.notice.consent_audit": "Consent 회수는 감사 로그와 연계", + "msg.dev.dashboard.notice.dev_scope": "RP 정책은 dev scope에서만 적용", + "msg.dev.dashboard.notice.hydra_health": "Hydra Admin 상태 체크 준비", + "msg.dev.sidebar.notice": "개발자 전용 콘솔입니다.", + "msg.dev.sidebar.notice_detail": "클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다.", + "msg.info.saved_success": "저장이 완료되었습니다.", + "msg.userfront.audit.date": "접속일자: {{value}}", + "msg.userfront.audit.device": "접속환경: {{value}}", + "msg.userfront.audit.end": "더 이상 항목이 없습니다.", + "msg.userfront.audit.ip": "접속 IP: {{value}}", + "msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.", + "msg.userfront.audit.result": "인증결과: {{value}}", + "msg.userfront.audit.session_id": "Session ID: {{value}}", + "msg.userfront.audit.status": "현황: (준비중)", + "msg.userfront.dashboard.activities.empty": "연동된 앱이 없습니다.", + "msg.userfront.dashboard.activities.empty_detail": "앱을 연동하면 최근 활동과 상태가 표시됩니다.", + "msg.userfront.dashboard.activities.error": "연동 정보를 불러오지 못했습니다.", + "msg.userfront.dashboard.approved_device": "승인 기기: {{device}}", + "msg.userfront.dashboard.approved_ip": "승인 IP: {{ip}}", + "msg.userfront.dashboard.approved_session.copy_click": "{{label}}: {{id}}\\\\\\\\n클릭하면 복사됩니다.", + "msg.userfront.dashboard.approved_session.copy_tap": "{{label}}: {{id}}\\\\\\\\n탭하면 복사됩니다.", + "msg.userfront.dashboard.approved_session.none": "{{label}} 없음", + "msg.userfront.dashboard.audit_empty": "최근 접속 이력이 없습니다.", + "msg.userfront.dashboard.audit_load_error": "접속이력을 불러오지 못했습니다.", + "msg.userfront.dashboard.auth_method": "인증수단: {{method}}", + "msg.userfront.dashboard.client_id": "Client ID: {{id}}", + "msg.userfront.dashboard.client_id_missing": "Client ID 없음", + "msg.userfront.dashboard.current_status": "현재 상태: {{status}}", + "msg.userfront.dashboard.last_auth": "최근 인증: {{value}}", + "msg.userfront.dashboard.link_missing": "이동할 페이지 주소(Client URI)가 설정되지 않았습니다.", + "msg.userfront.dashboard.link_open_error": "해당 링크를 열 수 없습니다.", + "msg.userfront.dashboard.revoke.confirm": "{{app}} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.", + "msg.userfront.dashboard.revoke.error": "해지 실패: {{error}}", + "msg.userfront.dashboard.revoke.success": "{{app}} 연동이 해지되었습니다.", + "msg.userfront.dashboard.scopes.empty": "요청된 권한이 없습니다.", + "msg.userfront.dashboard.session_id_copied": "세션 ID가 복사되었습니다.", + "msg.userfront.dashboard.timeline.load_error": "접속이력을 불러오지 못했습니다.", + "msg.userfront.error.detail_contact": "msg.userfront.error.detail_contact", + "msg.userfront.error.detail_generic": "오류가 발생했습니다.", + "msg.userfront.error.detail_request": "요청을 처리하는 중 문제가 발생했습니다.", + "msg.userfront.error.id": "오류 ID: {{id}}", + "msg.userfront.error.title": "인증 과정에서 오류가 발생했습니다", + "msg.userfront.error.title_generic": "오류가 발생했습니다", + "msg.userfront.error.title_with_code": "오류: {{code}}", + "msg.userfront.error.type": "오류 종류: {{type}}", + "msg.userfront.error.whitelist.\$normalizedCode": "에러가 계속되면 관리자에게 문의해주세요", + "msg.userfront.forgot.description": "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.", + "msg.userfront.forgot.dry_send": "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.", + "msg.userfront.forgot.error": "전송에 실패했습니다: {{error}}", + "msg.userfront.forgot.input_required": "이메일 또는 휴대폰 번호를 입력해주세요.", + "msg.userfront.forgot.sent": "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요.", + "msg.userfront.greeting": "안녕하세요, {{name}}님", + "msg.userfront.login.cookie_check_failed": "로그인 확인 실패: {{error}}", + "msg.userfront.login.dry_send": "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.", + "msg.userfront.login.link.approved": "링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.", + "msg.userfront.login.link.helper": "입력하신 정보로 로그인 링크를 전송합니다.", + "msg.userfront.login.link.missing_login_id": "이메일 또는 휴대폰 번호를 입력해 주세요.", + "msg.userfront.login.link.missing_phone": "휴대폰 번호를 입력해 주세요.", + "msg.userfront.login.link.resend_wait": "재발송은 {{time}} 후 가능합니다.", + "msg.userfront.login.link.short_code_help": "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.", + "msg.userfront.login.link_failed": "오류: {{error}}", + "msg.userfront.login.link_send_failed": "전송 실패: {{error}}", + "msg.userfront.login.link_sent_email": "입력하신 이메일로 로그인 링크를 보냈습니다.", + "msg.userfront.login.link_sent_phone": "입력하신 번호로 로그인 링크를 보냈습니다.", + "msg.userfront.login.link_timeout": "로그인 요청 시간이 초과되었습니다.", + "msg.userfront.login.no_account": "계정이 없으신가요?", + "msg.userfront.login.oidc_failed": "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요.", + "msg.userfront.login.password.failed": "로그인 실패: {{error}}", + "msg.userfront.login.password.missing_credentials": "이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.", + "msg.userfront.login.qr.load_failed": "QR 코드를 불러오지 못했습니다.", + "msg.userfront.login.qr.scan_hint": "모바일 앱으로 스캔하세요", + "msg.userfront.login.qr_expired": "QR 세션이 만료되었습니다.", + "msg.userfront.login.qr_init_failed": "QR 초기화에 실패했습니다: {{error}}", + "msg.userfront.login.qr_login_required": "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다", + "msg.userfront.login.short_code.invalid": "문자 2개와 숫자 6자리를 입력해 주세요.", + "msg.userfront.login.token_missing": "로그인 토큰을 확인할 수 없습니다.", + "msg.userfront.login.unregistered.body": "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요.", + "msg.userfront.login.verification.approved": "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + "msg.userfront.login.verification.approved_local": "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + "msg.userfront.login.verification.success": "로그인 승인에 성공했습니다.", + "msg.userfront.login.verification_failed": "승인 처리에 실패했습니다: {{error}}", + "msg.userfront.login_success.subtitle": "성공적으로 로그인되었습니다.", + "msg.userfront.profile.department_missing": "소속 정보 없음", + "msg.userfront.profile.department_required": "소속을 입력해주세요.", + "msg.userfront.profile.email_missing": "이메일 없음", + "msg.userfront.profile.greeting": "안녕하세요, {{name}}님", + "msg.userfront.profile.load_failed": "정보를 불러올 수 없습니다.", + "msg.userfront.profile.name_missing": "이름 없음", + "msg.userfront.profile.name_required": "이름을 입력해주세요.", + "msg.userfront.profile.password.change_failed": "비밀번호 변경 실패: {{error}}", + "msg.userfront.profile.password.changed": "비밀번호가 변경되었습니다.", + "msg.userfront.profile.password.current_required": "현재 비밀번호를 입력해 주세요.", + "msg.userfront.profile.password.mismatch": "새 비밀번호가 일치하지 않습니다.", + "msg.userfront.profile.password.new_required": "새 비밀번호를 입력해 주세요.", + "msg.userfront.profile.password.subtitle": "현재 비밀번호 확인 후 새 비밀번호로 변경합니다.", + "msg.userfront.profile.phone.code_sent": "인증번호가 전송되었습니다.", + "msg.userfront.profile.phone.send_failed": "전송 실패: {{error}}", + "msg.userfront.profile.phone.verified": "인증되었습니다.", + "msg.userfront.profile.phone.verify_failed": "인증 실패: {{error}}", + "msg.userfront.profile.phone.verify_notice": "휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.", + "msg.userfront.profile.phone_required": "휴대폰 번호를 입력해주세요.", + "msg.userfront.profile.phone_verify_required": "휴대폰 번호 인증이 필요합니다.", + "msg.userfront.profile.section.basic": "계정 기본 정보를 관리합니다.", + "msg.userfront.profile.section.organization": "소속 및 구분 정보입니다.", + "msg.userfront.profile.section.security": "비밀번호를 안전하게 관리합니다.", + "msg.userfront.profile.update_failed": "수정 실패: {{error}}", + "msg.userfront.profile.update_success": "정보가 수정되었습니다.", + "msg.userfront.qr.approve_error": "QR 승인 실패: {{error}}", + "msg.userfront.qr.approve_success": "QR 승인 완료! PC 화면에서 로그인이 진행됩니다.", + "msg.userfront.qr.camera_error": "카메라 오류: {{error}}", + "msg.userfront.qr.permission_error": "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.", + "msg.userfront.qr.permission_required": "카메라 권한이 필요합니다.", + "msg.userfront.reset.error.empty_password": "비밀번호를 입력해주세요.", + "msg.userfront.reset.error.generic": "비밀번호 변경에 실패했습니다: {{error}}", + "msg.userfront.reset.error.lowercase": "최소 1개 이상의 소문자를 포함해야 합니다.", + "msg.userfront.reset.error.min_length": "비밀번호는 최소 {{count}}자 이상이어야 합니다.", + "msg.userfront.reset.error.min_types": "비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.", + "msg.userfront.reset.error.mismatch": "비밀번호가 일치하지 않습니다.", + "msg.userfront.reset.error.number": "최소 1개 이상의 숫자를 포함해야 합니다.", + "msg.userfront.reset.error.symbol": "최소 1개 이상의 특수문자를 포함해야 합니다.", + "msg.userfront.reset.error.uppercase": "최소 1개 이상의 대문자를 포함해야 합니다.", + "msg.userfront.reset.invalid_body": "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.", + "msg.userfront.reset.invalid_link": "유효하지 않은 재설정 링크입니다. (loginId/token 누락)", + "msg.userfront.reset.invalid_title": "유효하지 않은 링크입니다.", + "msg.userfront.reset.policy.lowercase": "소문자 1개 이상", + "msg.userfront.reset.policy.min_length": "최소 {{count}}자 이상", + "msg.userfront.reset.policy.min_types": "영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상", + "msg.userfront.reset.policy.number": "숫자 1개 이상", + "msg.userfront.reset.policy.symbol": "특수문자 1개 이상", + "msg.userfront.reset.policy.uppercase": "대문자 1개 이상", + "msg.userfront.reset.policy_loading": "비밀번호 정책을 불러오는 중입니다...", + "msg.userfront.reset.success": "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요.", + "msg.userfront.sections.apps_subtitle": "현재 연결된 앱과 최근 인증 상태입니다.", + "msg.userfront.sections.audit_subtitle": "Baron 로그인 기준의 최근 접근 기록입니다.", + "msg.userfront.settings.disabled": "현재 계정 설정 화면은 준비 중입니다.", + "msg.userfront.signup.agreement.title": "서비스 이용을 위해\\\\n약관에 동의해주세요", + "msg.userfront.signup.auth.affiliate_notice": "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.", + "msg.userfront.signup.auth.title": "본인 확인을 위해\\\\n인증을 진행해주세요", + "msg.userfront.signup.email.code_mismatch": "인증코드가 일치하지 않습니다.", + "msg.userfront.signup.email.duplicate": "이미 가입된 이메일입니다.", + "msg.userfront.signup.email.invalid": "유효한 이메일 형식이 아닙니다.", + "msg.userfront.signup.email.send_failed": "발송 실패: {{error}}", + "msg.userfront.signup.email.verified": "✅ 이메일 인증 완료", + "msg.userfront.signup.email.verify_failed": "인증 실패: {{error}}", + "msg.userfront.signup.failed": "가입 실패: {{error}}", + "msg.userfront.signup.password.length_required": "비밀번호는 최소 12자 이상이어야 합니다.", + "msg.userfront.signup.password.lowercase_required": "소문자가 최소 1개 이상 포함되어야 합니다.", + "msg.userfront.signup.password.mismatch": "비밀번호가 일치하지 않습니다.", + "msg.userfront.signup.password.number_required": "숫자가 최소 1개 이상 포함되어야 합니다.", + "msg.userfront.signup.password.rule.lowercase": "소문자", + "msg.userfront.signup.password.rule.min_length": "{{count}}자 이상", + "msg.userfront.signup.password.rule.min_types": "문자 유형 {{count}}가지 이상", + "msg.userfront.signup.password.rule.number": "숫자", + "msg.userfront.signup.password.rule.symbol": "특수문자", + "msg.userfront.signup.password.rule.uppercase": "대문자", + "msg.userfront.signup.password.symbol_required": "특수문자가 최소 1개 이상 포함되어야 합니다.", + "msg.userfront.signup.password.title": "마지막으로\\\\n비밀번호를 설정해주세요", + "msg.userfront.signup.password.uppercase_required": "대문자가 최소 1개 이상 포함되어야 합니다.", + "msg.userfront.signup.phone.code_mismatch": "인증코드가 일치하지 않습니다.", + "msg.userfront.signup.phone.send_failed": "발송 실패: {{error}}", + "msg.userfront.signup.phone.verified": "✅ 휴대폰 인증 완료", + "msg.userfront.signup.phone.verify_failed": "인증 실패: {{error}}", + "msg.userfront.signup.policy.loading": "비밀번호 정책을 불러오는 중입니다...", + "msg.userfront.signup.policy.lowercase": "소문자", + "msg.userfront.signup.policy.min_length": "최소 {{count}}자 이상", + "msg.userfront.signup.policy.min_types": "영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상", + "msg.userfront.signup.policy.number": "숫자", + "msg.userfront.signup.policy.summary": "보안 정책: {{rules}}", + "msg.userfront.signup.policy.symbol": "특수문자", + "msg.userfront.signup.policy.uppercase": "대문자", + "msg.userfront.signup.privacy_full": "\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관 장소를 달리하여 보존합니다.\\n③ 개인정보 파기의 절차 및 방법은 다음과 같습니다:\\n- 파기 절차: 회사는 파기 사유가 발생한 개인정보를 선정하고, 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.\\n- 파기 방법: 전자적 파일 형태로 기록된 개인정보는 복구할 수 없도록 기술적 방법을 사용해 삭제하며, 종이 문서에 기록된 개인정보는 분쇄기로 분쇄하거나 소각하여 파기합니다.\\n제9조 (개인정보의 안전성 확보 조치)\\n회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취합니다:\\n- 관리적 조치: 내부관리계획 수립·시행, 정기적 직원 교육\\n- 기술적 조치: 개인정보처리시스템 접근 권한 관리, 접근통제시스템 설치, 고유식별정보 암호화, 보안 프로그램 설치\\n- 물리적 조치: 전산실 및 자료보관실 접근 통제\\n제10조 (개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항)\\n회사는 쿠키(Cookie)를 사용하지 않습니다. 쿠키는 이용자의 이용 정보를 저장하고 수시로 불러오는 작은 파일로, 바론서비스에서는 쿠키를 사용하지 않습니다.\\n제11조 (개인정보 보호책임자)\\n회사는 개인정보 처리에 관한 업무를 총괄하여 책임지고, 개인정보 처리와 관련된 정보주체의 불만처리 및 피해구제를 위해 개인정보 보호책임자를 지정하고 있습니다.\\n개인정보 보호책임자:\\n- 성명: 염승호\\n- 직책: 수석연구원\\n- 연락처: 02-2141-7448\\n- 팩스번호: 02-2141-7599\\n- 이메일: b23008@baroncs.co.kr\\n제12조 (개인정보 열람청구)\\n정보주체는 「개인정보 보호법」 제35조에 따른 개인정보 열람 청구를 아래 부서에 할 수 있습니다. 회사는 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.\\n개인정보 열람청구 접수·처리 부서:\\n- 부서명: 총괄기획실\\n- 담당자: 권혁진\\n- 연락처: 02-2141-7465\\n- 팩스번호: 02-2141-7599\\n- 이메일: baroncs@baroncs.co.kr\\n제13조 (권익침해 구제방법)\\n정보주체는 개인정보 침해로 인한 구제를 위해 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보해신고센터 등에 분쟁 해결이나 상담을 신청할 수 있습니다.\\n- 개인정보분쟁조정위원회: (국번없이) 1833-6972 (www.kopico.go.kr)\\n- 개인정보침해신고센터: (국번없이) 118 (privacy.kisa.or.kr)\\n- 대검찰청: (국번없이) 1301 (www.spo.go.kr)\\n- 경찰청: (국번없이) 182 (www.police.go.kr)\\n제14조 (개인정보 처리방침의 변경)\\n본 개인정보처리방침은 법령, 정책 또는 보안 기술의 변경에 따라 내용의 추가, 삭제 및 수정이 있을 시, 개정 최소 7일 전에 홈페이지를 통해 사전 공지합니다.\\n\\n부칙\\n제1조 (시행일자)\\n이 개인정보처리방침은 2024년 10월 1일부터 시행됩니다.\\n제2조 (개정 및 고지의 의무)\\n회사는 개인정보처리방침을 변경하는 경우, 변경사항을 시행일자 7일 전부터 서비스 내 공지사항 페이지를 통해 고지할 것입니다. 다만, 이용자의 권리나 의무에 중대한 변경이 발생하는 경우에는 시행일자 30일 전부터 고지합니다.\\n제3조 (유효성)\\n본 개인정보처리방침의 일부 조항이 법적 또는 기타 사유로 인해 무효화되거나 시행할 수 없는 경우, 나머지 조항들은 계속해서 유효합니다. 무효화된 조항은 관련 법령에 부합하는 방식으로 수정되어 효력을 지속합니다.\\n제4조 (변경 통지의 방법)\\n회사는 개인정보처리방침의 변경 시, 다음의 방법으로 이용자에게 고지합니다:\\n- 서비스 초기화면 또는 팝업 공지\\n- 이메일 발송\\n- 회사 홈페이지 공지사항\\n제5조 (비회원의 개인정보 보호)\\n회사는 비회원의 개인정보도 회원과 동일한 수준으로 보호합니다. 비회원이 개인정보 제공을 거부할 경우 일부 서비스 이용에 제한이 있을 수 있습니다.\\n제6조 (14세 미만 아동의 개인정보 보호)\\n회사는 14세 미만 아동의 개인정보를 수집하지 않습니다. 만일 14세 미만 아동의 개인정보가 수집된 경우, 법정 대리인의 동의를 받아야 하며, 법정 대리인의 동의 없이 수집된 경우 이를 지체 없이 파기합니다.\\n제7조 (개인정보의 국외 이전)\\n회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.\\n제8조 (기타)\\n본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.\\n", + "msg.userfront.signup.profile.affiliate_hint": "가족사 이메일 사용 시 자동으로 선택됩니다.", + "msg.userfront.signup.profile.title": "회원님의\\\\n소속 정보를 알려주세요", + "msg.userfront.signup.success.body": "성공적으로 가입되었습니다.", + "msg.userfront.signup.success.title": "회원가입 완료", + "msg.userfront.signup.tos_full": "\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한)\\n회사는 회원이 약관을 위반할 경우 서비스 이용을 제한할 수 있습니다.\\n제8장 손해 배상 및 면책 조항\\n제18조 (손해 배상)\\n회사는 무료로 제공되는 서비스와 관련하여 회원에게 발생한 손해에 대해 책임을 지지 않습니다.\\n제19조 (면책 조항)\\n회사는 천재지변 등 불가항력적인 사유로 인해 서비스를 제공하지 못하는 경우 책임을 지지 않습니다.\\n제9장 유료 서비스\\n20조 (유료 서비스의 이용)\\n① 회사는 회원에게 특정 서비스에 대해 유료로 제공할 수 있습니다. ② 유료 서비스의 이용 요금, 결제 방식, 환불 절차 등에 대한 상세 내용은 서비스 안내 페이지와 결제 화면에 명시합니다. ③ 유료 서비스 이용 요금은 회사가 정한 결제 방식에 따라 결제됩니다. 회원은 신용카드, 계좌이체, 휴대전화 결제 등 회사가 제공하는 다양한 결제 방식을 통해 요금을 납부할 수 있습니다. ④ 유료 서비스의 이용 요금은 선불 결제를 원칙으로 하며, 이용 기간 중 서비스 중지 및 해지 시 남은 이용 기간에 대한 환불은 회사의 환불 정책에 따라 처리됩니다. ⑤ 회사는 회원의 유료 서비스 이용과 관련하여 발생한 문제에 대해 최선을 다해 해결하도록 노력합니다. 다만, 회사의 고의 또는 중대한 과실이 없는 한 회원이 유료 서비스 이용 중 입은 손해에 대해서는 책임을 지지 않습니다.\\n제21조(환불 정책)\\n① 회원은 결제 후 7일 이내에 서비스 이용을 시작하지 않은 경우, 요금 전액을 환불받을 수 있습니다. ② 유료 서비스 이용 중 부득이한 사유로 서비스가 중지된 경우, 회사는 이용하지 않은 부분에 대해 환불 절차를 밟습니다. ③ 회원의 귀책사유로 인해 서비스 이용이 중지된 경우, 환불이 불가능합니다. ④ 환불은 회원이 지정한 계좌로 환불 절차를 거치며, 환불 요청 후 7일 이내에 처리됩니다.\\n제22조 (유료 서비스의 중지 및 해지)\\n① 회원이 유료 서비스를 해지하고자 하는 경우, 회사의 고객 지원 센터에 해지 신청을 해야 합니다. ② 회사는 회원이 약관을 위반하거나 부정한 방법으로 유료 서비스를 이용한 경우, 유료 서비스 이용을 즉시 중지하고 계약을 해지할 수 있습니다.\\n제10장 양도 금지\\n제23조 (양도 금지)\\n회원은 서비스 이용권한, 기타 이용계약상의 지위를 제3자에게 양도, 증여할 수 없으며, 이를 담보로 제공할 수 없습니다.\\n제11장 관할 법원\\n제24조 (분쟁 해결)\\n서비스 이용과 관련하여 분쟁이 발생한 경우, 회사와 회원은 성실히 협의하여 해결합니다.\\n제25조 (관할 법원)\\n본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.\\n부칙\\n본 약관은 2024년 10월 1일부터 시행됩니다.\\n", + "ui.admin.api_keys.create.name_label": "서비스 또는 목적 식별 이름", + "ui.admin.api_keys.create.name_placeholder": "예: Jenkins-CI, Grafana-Dashboard", + "ui.admin.api_keys.create.section_name": "키 이름 지정", + "ui.admin.api_keys.create.section_scopes": "권한 범위(Scopes) 선택", + "ui.admin.api_keys.create.submit": "API 키 발급하기", + "ui.admin.api_keys.create.success.copy_secret": "보안 시크릿 복사", + "ui.admin.api_keys.create.success.go_list": "저장했습니다. 목록으로 이동", + "ui.admin.api_keys.create.success.title": "API 키 생성 완료", + "ui.admin.api_keys.create.title": "새 API 키 생성", + "ui.admin.api_keys.list.add": "API 키 생성", + "ui.admin.api_keys.list.breadcrumb.list": "List", + "ui.admin.api_keys.list.breadcrumb.section": "API Keys", + "ui.admin.api_keys.list.registry.title": "API Key Registry", + "ui.admin.api_keys.list.table.actions": "ACTIONS", + "ui.admin.api_keys.list.table.client_id": "CLIENT ID", + "ui.admin.api_keys.list.table.last_used": "LAST USED", + "ui.admin.api_keys.list.table.name": "NAME", + "ui.admin.api_keys.list.table.scopes": "SCOPES", + "ui.admin.api_keys.list.title": "API 키 관리 (M2M)", + "ui.admin.audit.breadcrumb.logs": "Logs", + "ui.admin.audit.breadcrumb.section": "Audit", + "ui.admin.audit.copy.actor_id": "Copy actor id", + "ui.admin.audit.copy.request_id": "Copy request id", + "ui.admin.audit.copy.target": "Copy target", + "ui.admin.audit.details.actor": "Actor", + "ui.admin.audit.details.actor_id": "Actor ID · {{value}}", + "ui.admin.audit.details.after": "After · {{value}}", + "ui.admin.audit.details.before": "Before · {{value}}", + "ui.admin.audit.details.device": "Device · {{value}}", + "ui.admin.audit.details.error": "Error · {{value}}", + "ui.admin.audit.details.event_id": "Event ID · {{value}}", + "ui.admin.audit.details.ip": "IP · {{value}}", + "ui.admin.audit.details.latency": "Latency · {{value}}", + "ui.admin.audit.details.request": "Request", + "ui.admin.audit.details.request_id": "Request ID · {{value}}", + "ui.admin.audit.details.result": "Result", + "ui.admin.audit.details.tenant": "Tenant · {{value}}", + "ui.admin.audit.export_csv": "Export CSV", + "ui.admin.audit.filters.placeholder": "필터 추가 (예: status:failure)", + "ui.admin.audit.filters.remove": "{{filter}} 필터 제거", + "ui.admin.audit.load_more": "Load more", + "ui.admin.audit.registry.title": "Audit registry", + "ui.admin.audit.table.action_target": "Action / Target", + "ui.admin.audit.table.actor": "ACTOR (ID)", + "ui.admin.audit.table.path": "PATH", + "ui.admin.audit.table.request": "REQUEST", + "ui.admin.audit.table.status": "STATUS", + "ui.admin.audit.table.time": "TIME", + "ui.admin.audit.target": "Target · {{target}}", + "ui.admin.audit.title": "감사 로그", + "ui.admin.brand": "Baron 로그인", + "ui.admin.dev_role_switcher": "🛠 DEV Role Switcher", + "ui.admin.groups.create.title": "새 그룹 생성", + "ui.admin.groups.form.desc_label": "설명", + "ui.admin.groups.form.desc_placeholder": "그룹 용도 설명", + "ui.admin.groups.form.name_label": "그룹 이름", + "ui.admin.groups.form.name_placeholder": "예: 개발팀, 인사팀", + "ui.admin.groups.form.submit": "생성하기", + "ui.admin.groups.list.title": "User Groups", + "ui.admin.groups.members.table.email": "이메일", + "ui.admin.groups.members.table.name": "이름", + "ui.admin.groups.members.table.remove": "제거", + "ui.admin.groups.table.actions": "ACTIONS", + "ui.admin.groups.table.members": "MEMBERS", + "ui.admin.groups.table.name": "NAME", + "ui.admin.header.plane": "Admin Plane", + "ui.admin.overview.kicker": "Global Overview", + "ui.admin.overview.playbook.title": "Admin playbook", + "ui.admin.overview.quick_links.add_tenant": "테넌트 추가", + "ui.admin.overview.quick_links.tenant_dashboard": "테넌트 대시보드", + "ui.admin.overview.quick_links.title": "빠른 이동", + "ui.admin.overview.quick_links.view_audit_logs": "감사 로그 보기", + "ui.admin.overview.title": "Tenant-independent control plane", + "ui.admin.role.rp_admin": "RP ADMIN", + "ui.admin.role.super_admin": "SUPER ADMIN", + "ui.admin.role.tenant_admin": "TENANT ADMIN", + "ui.admin.role.tenant_member": "TENANT MEMBER", + "ui.admin.tenants.add": "테넌트 추가", + "ui.admin.tenants.breadcrumb.list": "List", + "ui.admin.tenants.breadcrumb.section": "Tenants", + "ui.admin.tenants.create.breadcrumb.action": "Create", + "ui.admin.tenants.create.breadcrumb.section": "Tenants", + "ui.admin.tenants.create.form.description": "Description", + "ui.admin.tenants.create.form.domains_label": "Allowed Domains (Comma separated)", + "ui.admin.tenants.create.form.domains_placeholder": "example.com, example.kr", + "ui.admin.tenants.create.form.name": "Tenant name", + "ui.admin.tenants.create.form.slug": "Slug", + "ui.admin.tenants.create.form.slug_placeholder": "tenant-slug", + "ui.admin.tenants.create.form.status": "Status", + "ui.admin.tenants.create.memo.title": "정책 메모", + "ui.admin.tenants.create.profile.title": "Tenant Profile", + "ui.admin.tenants.create.title": "테넌트 추가", + "ui.admin.tenants.members.table.email": "EMAIL", + "ui.admin.tenants.members.table.name": "NAME", + "ui.admin.tenants.members.table.role": "ROLE", + "ui.admin.tenants.members.table.status": "STATUS", + "ui.admin.tenants.members.title": "Tenant Members ({{count}})", + "ui.admin.tenants.registry.title": "Tenant registry", + "ui.admin.tenants.schema.add_field": "Add Field", + "ui.admin.tenants.schema.field.key": "Field Key (ID)", + "ui.admin.tenants.schema.field.key_placeholder": "e.g. employee_id", + "ui.admin.tenants.schema.field.label": "Display Label", + "ui.admin.tenants.schema.field.label_placeholder": "e.g. 사번", + "ui.admin.tenants.schema.field.type": "Type", + "ui.admin.tenants.schema.field.type_boolean": "Boolean", + "ui.admin.tenants.schema.field.type_number": "Number", + "ui.admin.tenants.schema.field.type_text": "Text", + "ui.admin.tenants.schema.save": "Save Schema Changes", + "ui.admin.tenants.schema.title": "User Schema Extension", + "ui.admin.tenants.sub.add": "하위 테넌트 추가", + "ui.admin.tenants.sub.manage": "관리", + "ui.admin.tenants.sub.table.action": "ACTION", + "ui.admin.tenants.sub.table.name": "NAME", + "ui.admin.tenants.sub.table.slug": "SLUG", + "ui.admin.tenants.sub.table.status": "STATUS", + "ui.admin.tenants.sub.title": "Sub-tenants ({{count}})", + "ui.admin.tenants.table.actions": "ACTIONS", + "ui.admin.tenants.table.name": "NAME", + "ui.admin.tenants.table.slug": "SLUG", + "ui.admin.tenants.table.status": "STATUS", + "ui.admin.tenants.table.updated": "UPDATED", + "ui.admin.tenants.title": "테넌트 목록", + "ui.admin.title": "Admin Control", + "ui.admin.users.create.account.title": "계정 정보", + "ui.admin.users.create.back": "목록으로 돌아가기", + "ui.admin.users.create.breadcrumb.new": "New", + "ui.admin.users.create.breadcrumb.section": "Users", + "ui.admin.users.create.custom_fields.title": "테넌트 확장 정보 (Custom Fields)", + "ui.admin.users.create.form.auto_password": "자동 생성", + "ui.admin.users.create.form.department": "부서", + "ui.admin.users.create.form.department_placeholder": "개발팀", + "ui.admin.users.create.form.email": "이메일", + "ui.admin.users.create.form.email_placeholder": "user@example.com", + "ui.admin.users.create.form.name": "이름", + "ui.admin.users.create.form.name_placeholder": "홍길동", + "ui.admin.users.create.form.password": "비밀번호", + "ui.admin.users.create.form.password_placeholder": "********", + "ui.admin.users.create.form.phone": "전화번호", + "ui.admin.users.create.form.phone_placeholder": "010-1234-5678", + "ui.admin.users.create.form.role": "역할 (Role)", + "ui.admin.users.create.form.tenant": "테넌트 (Tenant)", + "ui.admin.users.create.form.tenant_global": "시스템 전역 (소속 없음)", + "ui.admin.users.create.go_list": "목록으로 이동", + "ui.admin.users.create.password_generated.title": "초기 비밀번호 생성 완료", + "ui.admin.users.create.submit": "사용자 생성", + "ui.admin.users.create.title": "사용자 추가", + "ui.admin.users.detail.back": "목록으로 돌아가기", + "ui.admin.users.detail.breadcrumb.section": "Users", + "ui.admin.users.detail.custom_fields.title": "테넌트 확장 정보 (Custom Fields)", + "ui.admin.users.detail.edit_title": "정보 수정", + "ui.admin.users.detail.form.department": "부서", + "ui.admin.users.detail.form.department_placeholder": "개발팀", + "ui.admin.users.detail.form.name": "이름", + "ui.admin.users.detail.form.name_placeholder": "홍길동", + "ui.admin.users.detail.form.phone": "전화번호", + "ui.admin.users.detail.form.phone_placeholder": "010-1234-5678", + "ui.admin.users.detail.form.role": "역할 (Role)", + "ui.admin.users.detail.form.status": "상태", + "ui.admin.users.detail.form.tenant": "테넌트 (Tenant)", + "ui.admin.users.detail.form.tenant_global": "시스템 전역 (소속 없음)", + "ui.admin.users.detail.security.password": "비밀번호 변경", + "ui.admin.users.detail.security.password_placeholder": "변경할 경우에만 입력", + "ui.admin.users.detail.security.title": "보안 설정", + "ui.admin.users.detail.title": "사용자 상세", + "ui.admin.users.list.add": "사용자 추가", + "ui.admin.users.list.breadcrumb.list": "List", + "ui.admin.users.list.breadcrumb.section": "Users", + "ui.admin.users.list.delete_aria": "사용자 삭제: {{name}}", + "ui.admin.users.list.edit_aria": "사용자 수정: {{name}}", + "ui.admin.users.list.registry.title": "User Registry", + "ui.admin.users.list.search_placeholder": "이름 또는 이메일 검색...", + "ui.admin.users.list.table.actions": "ACTIONS", + "ui.admin.users.list.table.created": "CREATED", + "ui.admin.users.list.table.name_email": "NAME / EMAIL", + "ui.admin.users.list.table.role": "ROLE", + "ui.admin.users.list.table.status": "STATUS", + "ui.admin.users.list.table.tenant_dept": "TENANT / DEPT", + "ui.admin.users.list.tenant_slug": "Slug: {{slug}}", + "ui.admin.users.list.title": "사용자 관리", + "ui.btn.cancel": "취소", + "ui.btn.save": "저장", + "ui.common.add": "추가", + "ui.common.back": "돌아가기", + "ui.common.badge.admin_only": "Admin only", + "ui.common.badge.command_only": "Command only", + "ui.common.badge.system": "System", + "ui.common.cancel": "취소", + "ui.common.close": "닫기", + "ui.common.collapse": "접기", + "ui.common.confirm": "확인", + "ui.common.copy": "복사", + "ui.common.create": "생성", + "ui.common.delete": "삭제", + "ui.common.details": "상세정보", + "ui.common.edit": "편집", + "ui.common.hyphen": "-", + "ui.common.na": "N/A", + "ui.common.never": "Never", + "ui.common.next": "Next", + "ui.common.page_of": "Page {{page}} of {{total}}", + "ui.common.prev": "이전", + "ui.common.previous": "Previous", + "ui.common.qr": "QR", + "ui.common.read_only": "읽기 전용", + "ui.common.refresh": "새로고침", + "ui.common.requesting": "요청 중...", + "ui.common.resend": "재발송", + "ui.common.retry": "다시 시도", + "ui.common.role.admin": "Admin", + "ui.common.role.user": "User", + "ui.common.save": "저장", + "ui.common.search": "검색", + "ui.common.show_more": "+ 더보기", + "ui.common.status.active": "Active", + "ui.common.status.blocked": "Blocked", + "ui.common.status.failure": "실패", + "ui.common.status.inactive": "Inactive", + "ui.common.status.ok": "정상", + "ui.common.status.pending": "준비 중", + "ui.common.status.success": "성공", + "ui.common.theme_dark": "Dark", + "ui.common.theme_light": "Light", + "ui.common.theme_toggle": "테마 전환", + "ui.common.unknown": "Unknown", + "ui.dev.brand": "Baron 로그인", + "ui.dev.clients.badge.admin_session": "관리자 세션", + "ui.dev.clients.badge.tenant_selected": "테넌트: 선택됨", + "ui.dev.clients.consents.breadcrumb.clients": "Clients", + "ui.dev.clients.consents.breadcrumb.current": "User Consent Grants", + "ui.dev.clients.consents.breadcrumb.home": "Home", + "ui.dev.clients.consents.export_csv": "Export CSV", + "ui.dev.clients.consents.filters.advanced": "Advanced Filters", + "ui.dev.clients.consents.revoke": "Revoke", + "ui.dev.clients.consents.search_placeholder": "사용자 ID, 이름, 이메일로 검색", + "ui.dev.clients.consents.stats.active_grants": "Active Grants", + "ui.dev.clients.consents.stats.avg_scopes": "Avg. Scopes per User", + "ui.dev.clients.consents.stats.total_scopes": "Total Scopes Issued", + "ui.dev.clients.consents.status_all": "All Statuses", + "ui.dev.clients.consents.status_label": "Status:", + "ui.dev.clients.consents.status_revoked": "Revoked", + "ui.dev.clients.consents.subject": "Subject", + "ui.dev.clients.consents.table.action": "Action", + "ui.dev.clients.consents.table.first_granted": "First Granted", + "ui.dev.clients.consents.table.last_auth": "Last Authenticated", + "ui.dev.clients.consents.table.scopes": "Granted Scopes", + "ui.dev.clients.consents.table.status": "Status", + "ui.dev.clients.consents.table.tenant": "Tenant", + "ui.dev.clients.consents.table.user": "User", + "ui.dev.clients.consents.title": "User Consent Grants", + "ui.dev.clients.copy_client_id": "Copy client id", + "ui.dev.clients.details.breadcrumb.current": "클라이언트 상세", + "ui.dev.clients.details.breadcrumb.section": "Relying Parties", + "ui.dev.clients.details.credentials.client_id": "Client ID", + "ui.dev.clients.details.credentials.client_secret": "Client Secret", + "ui.dev.clients.details.credentials.title": "클라이언트 자격 증명", + "ui.dev.clients.details.endpoints.read_only": "읽기 전용", + "ui.dev.clients.details.endpoints.title": "OIDC 엔드포인트", + "ui.dev.clients.details.redirect.callback_label": "인증 콜백 URL", + "ui.dev.clients.details.redirect.label": "Redirect URIs", + "ui.dev.clients.details.redirect.placeholder": "https://your-app.com/callback, http://localhost:3000/auth/callback", + "ui.dev.clients.details.redirect.save": "Redirect URIs 저장", + "ui.dev.clients.details.redirect.title": "리디렉션 URI 설정", + "ui.dev.clients.details.secret.hide": "비밀키 숨기기", + "ui.dev.clients.details.secret.rotate": "비밀키 재발급 (Rotate)", + "ui.dev.clients.details.secret.show": "비밀키 보기", + "ui.dev.clients.details.security.title": "보안 메모", + "ui.dev.clients.details.tab.connection": "Connection", + "ui.dev.clients.details.tab.consents": "Consent & Users", + "ui.dev.clients.details.tab.settings": "Settings", + "ui.dev.clients.general.breadcrumb.section": "Applications", + "ui.dev.clients.general.create": "클라이언트 생성", + "ui.dev.clients.general.display_new": "새 클라이언트", + "ui.dev.clients.general.footer.client_id": "Client ID", + "ui.dev.clients.general.footer.created_on": "Created On", + "ui.dev.clients.general.identity.description": "Description", + "ui.dev.clients.general.identity.description_placeholder": "앱에 대한 간단한 설명을 입력하세요.", + "ui.dev.clients.general.identity.logo": "App Logo URL", + "ui.dev.clients.general.identity.logo_placeholder": "https://example.com/logo.png", + "ui.dev.clients.general.identity.logo_preview": "Logo Preview", + "ui.dev.clients.general.identity.name": "앱 이름", + "ui.dev.clients.general.identity.name_placeholder": "My Awesome Application", + "ui.dev.clients.general.identity.title": "Application Identity", + "ui.dev.clients.general.redirect.label": "Redirect URIs", + "ui.dev.clients.general.redirect.placeholder": "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)", + "ui.dev.clients.general.save": "설정 저장", + "ui.dev.clients.general.scopes.add": "Scope 추가", + "ui.dev.clients.general.scopes.description_placeholder": "권한에 대한 설명", + "ui.dev.clients.general.scopes.name_placeholder": "e.g. profile", + "ui.dev.clients.general.scopes.table.description": "Description", + "ui.dev.clients.general.scopes.table.mandatory": "Mandatory", + "ui.dev.clients.general.scopes.table.name": "Scope Name", + "ui.dev.clients.general.scopes.title": "Scopes", + "ui.dev.clients.general.security.confidential": "Confidential", + "ui.dev.clients.general.security.public": "Public", + "ui.dev.clients.general.security.title": "보안 설정", + "ui.dev.clients.general.title_create": "Create Client", + "ui.dev.clients.general.title_edit": "Client Settings", + "ui.dev.clients.help.docs_title": "Docs & Examples", + "ui.dev.clients.help.title": "Need help with OIDC configuration?", + "ui.dev.clients.help.view_guides": "View guides", + "ui.dev.clients.list.title": "클라이언트 목록", + "ui.dev.clients.new": "새 클라이언트", + "ui.dev.clients.owner.avatar_alt": "ops user", + "ui.dev.clients.owner.email": "admin@brsw.kr", + "ui.dev.clients.owner.name": "AI Admin Bot", + "ui.dev.clients.owner.role": "Role: Tenant Admin", + "ui.dev.clients.owner.scope": "Scope: TENANT-12", + "ui.dev.clients.owner.subtitle": "Tenant admin on-call", + "ui.dev.clients.owner.title": "Owner", + "ui.dev.clients.registry.subtitle": "Relying Parties", + "ui.dev.clients.registry.title": "RP registry", + "ui.dev.clients.search_placeholder": "클라이언트 이름/ID로 검색...", + "ui.dev.clients.table.actions": "액션", + "ui.dev.clients.table.application": "애플리케이션", + "ui.dev.clients.table.client_id": "Client ID", + "ui.dev.clients.table.created_at": "생성일", + "ui.dev.clients.table.status": "상태", + "ui.dev.clients.table.type": "유형", + "ui.dev.clients.tenant_scoped": "Tenant-scoped", + "ui.dev.clients.type.confidential": "기밀(Confidential)", + "ui.dev.clients.type.public": "Public", + "ui.dev.clients.untitled": "Untitled", + "ui.dev.console_title": "Developer Console", + "ui.dev.dashboard.badge.consent_guard": "Consent guard ready", + "ui.dev.dashboard.badge.policy_toggle": "Policy toggle enabled", + "ui.dev.dashboard.badge.rp_synced": "RP registry synced", + "ui.dev.dashboard.next.subtitle": "Ship the RP controls", + "ui.dev.dashboard.next.title": "Next actions", + "ui.dev.dashboard.ops.card.consent_revoked": "Consent 회수 건수", + "ui.dev.dashboard.ops.card.hydra_status": "Hydra 상태", + "ui.dev.dashboard.ops.card.rp_requests": "RP 요청 추이", + "ui.dev.dashboard.ops.subtitle": "현재 관측", + "ui.dev.dashboard.ops.tag.consent": "Consent grants", + "ui.dev.dashboard.ops.tag.rp_status": "RP status", + "ui.dev.dashboard.ops.title": "Ops board", + "ui.dev.dashboard.ready_badge": "devfront ready", + "ui.dev.dashboard.stack.notes": "Setup notes", + "ui.dev.dashboard.stack.subtitle": "Devfront baseline", + "ui.dev.dashboard.stack.title": "Stack readiness", + "ui.dev.env_badge": "Env: dev", + "ui.dev.header.plane": "Dev Plane", + "ui.dev.header.subtitle": "Manage your applications", + "ui.dev.scope_badge": "Scoped to /dev", + "ui.nav.dashboard": "대시보드", + "ui.userfront.app_label.admin_console": "Admin Console", + "ui.userfront.app_label.baron": "Baron 로그인", + "ui.userfront.app_label.dev_console": "Dev Console", + "ui.userfront.app_title": "Baron 로그인", + "ui.userfront.audit.table.app": "애플리케이션", + "ui.userfront.audit.table.auth_method": "인증수단", + "ui.userfront.audit.table.date": "접속일자", + "ui.userfront.audit.table.device": "접속환경", + "ui.userfront.audit.table.ip": "IP", + "ui.userfront.audit.table.pending": "(준비중)", + "ui.userfront.audit.table.result": "인증결과", + "ui.userfront.audit.table.session_id": "Session ID", + "ui.userfront.audit.table.status": "현황", + "ui.userfront.auth_method.ory": "Ory 세션", + "ui.userfront.auth_method.session": "세션", + "ui.userfront.dashboard.activity.linked": "연동됨", + "ui.userfront.dashboard.approved_session.default": "승인한 세션 ID", + "ui.userfront.dashboard.approved_session.userfront": "승인한 Userfront 세션 ID", + "ui.userfront.dashboard.last_auth_label": "최근 인증", + "ui.userfront.dashboard.revoke.confirm_button": "해지하기", + "ui.userfront.dashboard.revoke.title": "연동 해지", + "ui.userfront.dashboard.scopes.title": "권한 (Scopes)", + "ui.userfront.dashboard.status.revoked": "해지됨", + "ui.userfront.dashboard.status_history": "상태 이력", + "ui.userfront.device.android": "Mobile(Android)", + "ui.userfront.device.ios": "Mobile(iOS)", + "ui.userfront.device.linux": "Desktop(Linux)", + "ui.userfront.device.macos": "Desktop(macOS)", + "ui.userfront.device.windows": "Desktop(Windows)", + "ui.userfront.error.go_home": "홈으로 이동", + "ui.userfront.error.go_login": "로그인으로 이동", + "ui.userfront.forgot.heading": "비밀번호를 잊으셨나요?", + "ui.userfront.forgot.input_label": "이메일 또는 휴대폰 번호", + "ui.userfront.forgot.submit": "재설정 링크 전송", + "ui.userfront.forgot.title": "비밀번호 재설정", + "ui.userfront.login.action.submit": "로그인", + "ui.userfront.login.field.login_id": "이메일 또는 휴대폰 번호", + "ui.userfront.login.field.password": "비밀번호", + "ui.userfront.login.forgot_password": "비밀번호를 잊으셨나요?", + "ui.userfront.login.link.action_label": "로그인 화면으로 이동", + "ui.userfront.login.link.code_only": "코드만 받기({{time}})", + "ui.userfront.login.link.page_title": "링크 로그인", + "ui.userfront.login.link.resend_with_time": "재발송 ({{time}})", + "ui.userfront.login.link.send": "로그인 링크 전송", + "ui.userfront.login.link.title": "링크 로그인 완료", + "ui.userfront.login.qr.expired": "QR 코드 만료됨", + "ui.userfront.login.qr.refresh": "QR 코드 새로고침", + "ui.userfront.login.qr.remaining": "남은 시간: {{time}}", + "ui.userfront.login.short_code.digits": "숫자 6자리", + "ui.userfront.login.short_code.expire_time": "유효시간 {{time}}", + "ui.userfront.login.short_code.prefix": "영문 2자리", + "ui.userfront.login.short_code.submit": "코드로 로그인", + "ui.userfront.login.signup": "회원가입", + "ui.userfront.login.tabs.link": "로그인 링크", + "ui.userfront.login.tabs.password": "비밀번호", + "ui.userfront.login.tabs.qr": "QR 코드", + "ui.userfront.login.unregistered.action": "회원가입 하기", + "ui.userfront.login.unregistered.title": "미등록 회원", + "ui.userfront.login.verification.action_label": "확인", + "ui.userfront.login.verification.page_title": "로그인 승인", + "ui.userfront.login.verification.title": "승인 완료", + "ui.userfront.login_success.later": "나중에 하기 (대시보드로 이동)", + "ui.userfront.login_success.qr": "QR 인증 (카메라 켜기)", + "ui.userfront.login_success.title": "로그인 완료", + "ui.userfront.nav.dashboard": "대시보드", + "ui.userfront.nav.logout": "로그아웃", + "ui.userfront.nav.profile": "내 정보", + "ui.userfront.nav.qr_scan": "QR 스캔", + "ui.userfront.profile.department_empty": "소속 정보 없음", + "ui.userfront.profile.field.affiliation": "구분", + "ui.userfront.profile.field.company_code": "회사코드", + "ui.userfront.profile.field.department": "소속", + "ui.userfront.profile.field.email": "이메일", + "ui.userfront.profile.field.name": "이름", + "ui.userfront.profile.field.tenant": "소속 테넌트", + "ui.userfront.profile.manage": "프로필 관리", + "ui.userfront.profile.password.change": "비밀번호 변경", + "ui.userfront.profile.password.confirm": "새 비밀번호 확인", + "ui.userfront.profile.password.current": "현재 비밀번호", + "ui.userfront.profile.password.forgot": "비밀번호를 잊으셨나요?", + "ui.userfront.profile.password.new": "새 비밀번호", + "ui.userfront.profile.password.title": "비밀번호 변경", + "ui.userfront.profile.phone.code_hint": "인증번호 6자리", + "ui.userfront.profile.phone.request_code": "인증요청", + "ui.userfront.profile.phone.title": "전화번호", + "ui.userfront.profile.section.basic": "기본 정보", + "ui.userfront.profile.section.organization": "조직 정보", + "ui.userfront.profile.section.security": "보안", + "ui.userfront.profile.user_fallback": "User", + "ui.userfront.qr.request_permission": "카메라 권한 요청하기", + "ui.userfront.qr.rescan": "다시 스캔", + "ui.userfront.qr.result_failure": "승인 실패", + "ui.userfront.qr.result_success": "승인 완료", + "ui.userfront.qr.title": "Scan QR Code", + "ui.userfront.reset.confirm_password": "새 비밀번호 확인", + "ui.userfront.reset.new_password": "새 비밀번호", + "ui.userfront.reset.submit": "비밀번호 변경", + "ui.userfront.reset.subtitle": "새로운 비밀번호 설정", + "ui.userfront.reset.title": "새 비밀번호 설정", + "ui.userfront.sections.apps": "나의 App 현황", + "ui.userfront.sections.audit": "접속이력", + "ui.userfront.session.active": "세션 활성", + "ui.userfront.session.unknown": "알 수 없음", + "ui.userfront.signup.agreement.all": "모두 동의합니다", + "ui.userfront.signup.agreement.privacy_title": "개인정보 수집 및 이용 동의 (필수)", + "ui.userfront.signup.agreement.tos_title": "바론 소프트웨어 이용약관 (필수)", + "ui.userfront.signup.auth.code_label": "인증코드 6자리", + "ui.userfront.signup.auth.email.label": "이메일 주소", + "ui.userfront.signup.auth.email.title": "이메일 인증", + "ui.userfront.signup.auth.request_code": "인증요청", + "ui.userfront.signup.complete": "가입 완료", + "ui.userfront.signup.next_step": "다음 단계", + "ui.userfront.signup.password.confirm_label": "비밀번호 확인", + "ui.userfront.signup.password.label": "비밀번호", + "ui.userfront.signup.phone.label": "휴대폰 번호 (-없이)", + "ui.userfront.signup.phone.title": "휴대폰 인증", + "ui.userfront.signup.profile.affiliation_type": "소속 유형", + "ui.userfront.signup.profile.company": "가족사 선택", + "ui.userfront.signup.profile.department": "부서명", + "ui.userfront.signup.profile.department_optional": "소속 정보 (선택)", + "ui.userfront.signup.profile.name": "이름", + "ui.userfront.signup.steps.agreement": "약관동의", + "ui.userfront.signup.steps.password": "비밀번호", + "ui.userfront.signup.steps.profile": "정보입력", + "ui.userfront.signup.steps.verify": "본인인증", + "ui.userfront.signup.success.action": "로그인하기", + "ui.userfront.signup.title": "회원가입", +}; + +const Map enStrings = { + "domain.affiliation.affiliate": "Affiliate", + "domain.affiliation.general": "General", + "domain.company.baron": "Baron", + "domain.company.halla": "Halla", + "domain.company.hanmac": "Hanmac", + "domain.company.jangheon": "Jangheon", + "domain.company.ptc": "PTC", + "domain.company.saman": "Saman", + "err.common.unknown": "An unknown error occurred.", + "err.userfront.auth_proxy.consent_accept": "Consent Accept", + "err.userfront.auth_proxy.consent_fetch": "Consent Fetch", + "err.userfront.auth_proxy.consent_reject": "Consent Reject", + "err.userfront.auth_proxy.linked_app_revoke": "Linked App Revoke", + "err.userfront.auth_proxy.login_failed": "Login Failed", + "err.userfront.auth_proxy.oidc_accept": "OIDC Accept", + "err.userfront.auth_proxy.password_reset_complete": "Password Reset Complete", + "err.userfront.auth_proxy.password_reset_init": "Password Reset Init", + "err.userfront.profile.load_failed": "Load Failed", + "err.userfront.profile.password_change_failed": "Password Change Failed", + "err.userfront.profile.send_code_failed": "Send Code Failed", + "err.userfront.profile.update_failed": "Update Failed", + "err.userfront.profile.verify_code_failed": "Verify Code Failed", + "err.userfront.session.missing": "Missing", + "msg.admin.api_keys.create.error": "Error", + "msg.admin.api_keys.create.name_required": "Name Required", + "msg.admin.api_keys.create.scope_required": "Scope Required", + "msg.admin.api_keys.create.scopes_count": "Scopes Count", + "msg.admin.api_keys.create.scopes_hint": "Scopes Hint", + "msg.admin.api_keys.create.subtitle": "Subtitle", + "msg.admin.api_keys.create.success.copy_hint": "Copy Hint", + "msg.admin.api_keys.create.success.notice": "Notice", + "msg.admin.api_keys.create.success.notice_emphasis": "Notice Emphasis", + "msg.admin.api_keys.create.success.notice_suffix": "Notice Suffix", + "msg.admin.api_keys.list.delete_confirm": "Delete Confirm", + "msg.admin.api_keys.list.empty": "Empty", + "msg.admin.api_keys.list.fetch_error": "Fetch Error", + "msg.admin.api_keys.list.registry.count": "Count", + "msg.admin.api_keys.list.subtitle": "Subtitle", + "msg.admin.audit.empty": "Empty", + "msg.admin.audit.end": "End of audit feed", + "msg.admin.audit.filters.empty": "Empty", + "msg.admin.audit.load_error": "Error loading logs: {{error}}", + "msg.admin.audit.loading": "Loading audit logs...", + "msg.admin.audit.registry.count": "Count", + "msg.admin.audit.subtitle": "Subtitle", + "msg.admin.groups.list.subtitle": "Subtitle", + "msg.admin.groups.members.count": "Count", + "msg.admin.groups.members.empty": "Empty", + "msg.admin.groups.members.title": "Title", + "msg.admin.groups.prompt.user_id": "User Id", + "msg.admin.header.subtitle": "Tenant isolation & least privilege by default", + "msg.admin.idp_env_prod": "IDP env: prod", + "msg.admin.notice.idp_policy": "IDP Policy", + "msg.admin.notice.scope": "Scope", + "msg.admin.overview.description": "Description", + "msg.admin.overview.idp_fallback": "Fallback: Descope", + "msg.admin.overview.idp_primary": "IDP: Ory primary", + "msg.admin.overview.playbook.description": "Description", + "msg.admin.overview.playbook.idp_body": "IDP Body", + "msg.admin.overview.playbook.idp_title": "Backend-only IDP access", + "msg.admin.overview.playbook.tenant_body": "Tenant Body", + "msg.admin.overview.playbook.tenant_title": "Tenant isolation", + "msg.admin.overview.quick_links.description": "Description", + "msg.admin.scope_admin": "Scoped to /admin", + "msg.admin.session_ttl": "Session TTL: 15m admin", + "msg.admin.tenant_headers": "Tenant-aware headers", + "msg.admin.tenants.create.form.domains_help": "Users with these email domains will be automatically assigned to this tenant.", + "msg.admin.tenants.create.memo.body": "Body", + "msg.admin.tenants.create.memo.subtitle": "Subtitle", + "msg.admin.tenants.create.profile.subtitle": "Subtitle", + "msg.admin.tenants.create.subtitle": "Subtitle", + "msg.admin.tenants.delete_confirm": "Delete Tenant \\\"{{name}}\\\"?", + "msg.admin.tenants.empty": "Empty", + "msg.admin.tenants.fetch_error": "Fetch Error", + "msg.admin.tenants.members.empty": "Empty", + "msg.admin.tenants.registry.count": "Count", + "msg.admin.tenants.schema.empty": "No custom fields defined. Click \\\"Add Field\\\" to begin.", + "msg.admin.tenants.schema.missing_id": "Tenant ID missing", + "msg.admin.tenants.schema.subtitle": "Define custom attributes for users in this tenant.", + "msg.admin.tenants.schema.update_error": "Failed to update schema", + "msg.admin.tenants.schema.update_success": "Schema updated successfully", + "msg.admin.tenants.sub.empty": "Empty", + "msg.admin.tenants.sub.subtitle": "Subtitle", + "msg.admin.tenants.subtitle": "Subtitle", + "msg.admin.users.create.account.subtitle": "Subtitle", + "msg.admin.users.create.error": "Failed to User Create.", + "msg.admin.users.create.form.email_required": "Email Required", + "msg.admin.users.create.form.name_required": "Name Required", + "msg.admin.users.create.form.password_auto_help": "Password Auto Help", + "msg.admin.users.create.form.password_manual_help": "Password Manual Help", + "msg.admin.users.create.form.role_help": "Role Help", + "msg.admin.users.create.password_generated.default": "Default", + "msg.admin.users.create.password_generated.with_email": "With Email", + "msg.admin.users.create.password_required": "Password Required", + "msg.admin.users.detail.edit_subtitle": "Edit Subtitle", + "msg.admin.users.detail.form.name_required": "Name Required", + "msg.admin.users.detail.not_found": "Not Found", + "msg.admin.users.detail.security.password_hint": "Password Hint", + "msg.admin.users.detail.update_error": "Failed to User Edit.", + "msg.admin.users.detail.update_success": "Update Success", + "msg.admin.users.list.delete_confirm": "Delete Confirm", + "msg.admin.users.list.empty": "Empty", + "msg.admin.users.list.fetch_error": "Fetch Error", + "msg.admin.users.list.registry.count": "Count", + "msg.admin.users.list.subtitle": "Subtitle", + "msg.common.loading": "Loading...", + "msg.common.saving": "Saving...", + "msg.common.unknown_error": "unknown error", + "msg.dev.clients.consents.empty": "No consents found.", + "msg.dev.clients.consents.load_error": "Error loading consents: {{error}}", + "msg.dev.clients.consents.loading": "Loading consents...", + "msg.dev.clients.consents.showing": "Showing {{from}} to {{to}} of {{total}} users", + "msg.dev.clients.consents.subtitle": "Subtitle", + "msg.dev.clients.copy_client_id": "Copy Client Id", + "msg.dev.clients.details.copy_client_id": "Client ID copied.", + "msg.dev.clients.details.copy_client_secret": "Copy Client Secret", + "msg.dev.clients.details.copy_endpoint": "{{label}} copied.", + "msg.dev.clients.details.load_error": "Error loading client: {{error}}", + "msg.dev.clients.details.loading": "Loading client...", + "msg.dev.clients.details.missing_id": "Client ID is required.", + "msg.dev.clients.details.redirect.description": "Description", + "msg.dev.clients.details.redirect_saved": "Redirect URIs saved.", + "msg.dev.clients.details.rotate_confirm": "Rotate Confirm", + "msg.dev.clients.details.rotate_error": "Rotate Error", + "msg.dev.clients.details.save_error": "Save Error", + "msg.dev.clients.details.secret_rotated": "Secret Rotated", + "msg.dev.clients.details.secret_unavailable": "SECRET_NOT_AVAILABLE", + "msg.dev.clients.details.security.footer": "Footer", + "msg.dev.clients.details.security.note": "Note", + "msg.dev.clients.details.subtitle": "Subtitle", + "msg.dev.clients.general.identity.logo_help": "Logo Help", + "msg.dev.clients.general.identity.subtitle": "Subtitle", + "msg.dev.clients.general.load_error": "Error loading client: {{error}}", + "msg.dev.clients.general.loading": "Loading client...", + "msg.dev.clients.general.redirect.help": "Help", + "msg.dev.clients.general.saved": "Saved", + "msg.dev.clients.general.scopes.empty": "Empty", + "msg.dev.clients.general.scopes.subtitle": "Subtitle", + "msg.dev.clients.general.security.confidential_help": "Confidential Help", + "msg.dev.clients.general.security.public_help": "Public Help", + "msg.dev.clients.general.security.subtitle": "Subtitle", + "msg.dev.clients.help.docs_body": "Includes PKCE, client_secret_basic, redirect URI validation tips.", + "msg.dev.clients.help.subtitle": "Developer guides for Confidential/Public clients, redirect URIs, and auth methods.", + "msg.dev.clients.load_error": "Error loading clients: {{error}}", + "msg.dev.clients.loading": "Loading clients...", + "msg.dev.clients.registry.description": "Description", + "msg.dev.clients.scopes.email": "Email", + "msg.dev.clients.scopes.openid": "Openid", + "msg.dev.clients.scopes.profile": "Profile", + "msg.dev.clients.showing": "Showing {{shown}} of {{total}} clients", + "msg.dev.clients.status_update_error": "Failed to update client status", + "msg.dev.clients.status_updated": "Status Updated", + "msg.dev.dashboard.hero.body": "Body", + "msg.dev.dashboard.hero.title_emphasis": "Title Emphasis", + "msg.dev.dashboard.hero.title_prefix": "Title Prefix", + "msg.dev.dashboard.hero.title_suffix": "Title Suffix", + "msg.dev.dashboard.notice.consent_audit": "Consent Audit", + "msg.dev.dashboard.notice.dev_scope": "Dev Scope", + "msg.dev.dashboard.notice.hydra_health": "Hydra Health", + "msg.dev.sidebar.notice": "Notice", + "msg.dev.sidebar.notice_detail": "Notice Detail", + "msg.info.saved_success": "Saved successfully.", + "msg.userfront.audit.date": "Date", + "msg.userfront.audit.device": "Device", + "msg.userfront.audit.end": "End", + "msg.userfront.audit.ip": "Ip", + "msg.userfront.audit.load_more_error": "Load More Error", + "msg.userfront.audit.result": "Result", + "msg.userfront.audit.session_id": "Session ID: {{value}}", + "msg.userfront.audit.status": "Status", + "msg.userfront.dashboard.activities.empty": "Empty", + "msg.userfront.dashboard.activities.empty_detail": "Empty Detail", + "msg.userfront.dashboard.activities.error": "Error", + "msg.userfront.dashboard.approved_device": "Approved Device", + "msg.userfront.dashboard.approved_ip": "Approve IP: {{ip}}", + "msg.userfront.dashboard.approved_session.copy_click": "Copy Click", + "msg.userfront.dashboard.approved_session.copy_tap": "Copy Tap", + "msg.userfront.dashboard.approved_session.none": "None", + "msg.userfront.dashboard.audit_empty": "Audit Empty", + "msg.userfront.dashboard.audit_load_error": "Audit Load Error", + "msg.userfront.dashboard.auth_method": "Auth Method", + "msg.userfront.dashboard.client_id": "Client ID: {{id}}", + "msg.userfront.dashboard.client_id_missing": "Client Id Missing", + "msg.userfront.dashboard.current_status": "Current Status", + "msg.userfront.dashboard.last_auth": "Last Auth", + "msg.userfront.dashboard.link_missing": "Link Missing", + "msg.userfront.dashboard.link_open_error": "Link Open Error", + "msg.userfront.dashboard.revoke.confirm": "Confirm", + "msg.userfront.dashboard.revoke.error": "Error", + "msg.userfront.dashboard.revoke.success": "Success", + "msg.userfront.dashboard.scopes.empty": "Empty", + "msg.userfront.dashboard.session_id_copied": "Session Id Copied", + "msg.userfront.dashboard.timeline.load_error": "Load Error", + "msg.userfront.error.detail_contact": "msg.userfront.error.detail_contact", + "msg.userfront.error.detail_generic": "Detail Generic", + "msg.userfront.error.detail_request": "Detail Request", + "msg.userfront.error.id": "Id", + "msg.userfront.error.title": "Title", + "msg.userfront.error.title_generic": "Title Generic", + "msg.userfront.error.title_with_code": "Title With Code", + "msg.userfront.error.type": "Type", + "msg.userfront.error.whitelist.\$normalizedCode": "\$NormalizedCode", + "msg.userfront.forgot.description": "Description", + "msg.userfront.forgot.dry_send": "Dry Send", + "msg.userfront.forgot.error": "Error", + "msg.userfront.forgot.input_required": "Input Required", + "msg.userfront.forgot.sent": "Sent", + "msg.userfront.greeting": "Greeting", + "msg.userfront.login.cookie_check_failed": "Cookie Check Failed", + "msg.userfront.login.dry_send": "Dry Send", + "msg.userfront.login.link.approved": "Approved", + "msg.userfront.login.link.helper": "Helper", + "msg.userfront.login.link.missing_login_id": "Missing Login Id", + "msg.userfront.login.link.missing_phone": "Missing Phone", + "msg.userfront.login.link.resend_wait": "Resend Wait", + "msg.userfront.login.link.short_code_help": "Short Code Help", + "msg.userfront.login.link_failed": "Link Failed", + "msg.userfront.login.link_send_failed": "Link Send Failed", + "msg.userfront.login.link_sent_email": "Link Sent Email", + "msg.userfront.login.link_sent_phone": "Link Sent Phone", + "msg.userfront.login.link_timeout": "Link Timeout", + "msg.userfront.login.no_account": "No Account", + "msg.userfront.login.oidc_failed": "OIDC Failed", + "msg.userfront.login.password.failed": "Failed", + "msg.userfront.login.password.missing_credentials": "Missing Credentials", + "msg.userfront.login.qr.load_failed": "Load Failed", + "msg.userfront.login.qr.scan_hint": "Scan Hint", + "msg.userfront.login.qr_expired": "QR Expired", + "msg.userfront.login.qr_init_failed": "QR Init Failed", + "msg.userfront.login.qr_login_required": "QR Login Required", + "msg.userfront.login.short_code.invalid": "Invalid", + "msg.userfront.login.token_missing": "Token Missing", + "msg.userfront.login.unregistered.body": "Body", + "msg.userfront.login.verification.approved": "Approved", + "msg.userfront.login.verification.approved_local": "Approved Local", + "msg.userfront.login.verification.success": "Success", + "msg.userfront.login.verification_failed": "Verification Failed", + "msg.userfront.login_success.subtitle": "Subtitle", + "msg.userfront.profile.department_missing": "Department Missing", + "msg.userfront.profile.department_required": "Department Required", + "msg.userfront.profile.email_missing": "Email Missing", + "msg.userfront.profile.greeting": "Greeting", + "msg.userfront.profile.load_failed": "Load Failed", + "msg.userfront.profile.name_missing": "Name Missing", + "msg.userfront.profile.name_required": "Name Required", + "msg.userfront.profile.password.change_failed": "Change Failed", + "msg.userfront.profile.password.changed": "Changed", + "msg.userfront.profile.password.current_required": "Current Required", + "msg.userfront.profile.password.mismatch": "Mismatch", + "msg.userfront.profile.password.new_required": "New Required", + "msg.userfront.profile.password.subtitle": "Subtitle", + "msg.userfront.profile.phone.code_sent": "Code Sent", + "msg.userfront.profile.phone.send_failed": "Send Failed", + "msg.userfront.profile.phone.verified": "Verified", + "msg.userfront.profile.phone.verify_failed": "Verify Failed", + "msg.userfront.profile.phone.verify_notice": "Verify Notice", + "msg.userfront.profile.phone_required": "Phone Required", + "msg.userfront.profile.phone_verify_required": "Phone Verify Required", + "msg.userfront.profile.section.basic": "Basic", + "msg.userfront.profile.section.organization": "Organization", + "msg.userfront.profile.section.security": "Security", + "msg.userfront.profile.update_failed": "Update Failed", + "msg.userfront.profile.update_success": "Update Success", + "msg.userfront.qr.approve_error": "Approve Error", + "msg.userfront.qr.approve_success": "Approve Success", + "msg.userfront.qr.camera_error": "Camera Error", + "msg.userfront.qr.permission_error": "Permission Error", + "msg.userfront.qr.permission_required": "Permission Required", + "msg.userfront.reset.error.empty_password": "Please enter Password.", + "msg.userfront.reset.error.generic": "Generic", + "msg.userfront.reset.error.lowercase": "Lowercase", + "msg.userfront.reset.error.min_length": "Min Length", + "msg.userfront.reset.error.min_types": "Min Types", + "msg.userfront.reset.error.mismatch": "Mismatch", + "msg.userfront.reset.error.number": "Number", + "msg.userfront.reset.error.symbol": "Symbol", + "msg.userfront.reset.error.uppercase": "Uppercase", + "msg.userfront.reset.invalid_body": "Invalid Body", + "msg.userfront.reset.invalid_link": "Invalid Link", + "msg.userfront.reset.invalid_title": "Invalid Title", + "msg.userfront.reset.policy.lowercase": "Lowercase", + "msg.userfront.reset.policy.min_length": "Min Length", + "msg.userfront.reset.policy.min_types": "Min Types", + "msg.userfront.reset.policy.number": "Number", + "msg.userfront.reset.policy.symbol": "Symbol", + "msg.userfront.reset.policy.uppercase": "Uppercase", + "msg.userfront.reset.policy_loading": "Policy Loading", + "msg.userfront.reset.success": "Success", + "msg.userfront.sections.apps_subtitle": "Apps Subtitle", + "msg.userfront.sections.audit_subtitle": "Audit Subtitle", + "msg.userfront.settings.disabled": "Disabled", + "msg.userfront.signup.agreement.title": "Title", + "msg.userfront.signup.auth.affiliate_notice": "Affiliate Notice", + "msg.userfront.signup.auth.title": "Title", + "msg.userfront.signup.email.code_mismatch": "Code Mismatch", + "msg.userfront.signup.email.duplicate": "Duplicate", + "msg.userfront.signup.email.invalid": "Invalid", + "msg.userfront.signup.email.send_failed": "Send Failed", + "msg.userfront.signup.email.verified": "Verified", + "msg.userfront.signup.email.verify_failed": "Verify Failed", + "msg.userfront.signup.failed": "Failed", + "msg.userfront.signup.password.length_required": "Length Required", + "msg.userfront.signup.password.lowercase_required": "Lowercase Required", + "msg.userfront.signup.password.mismatch": "Mismatch", + "msg.userfront.signup.password.number_required": "Number Required", + "msg.userfront.signup.password.rule.lowercase": "Lowercase", + "msg.userfront.signup.password.rule.min_length": "Min Length", + "msg.userfront.signup.password.rule.min_types": "Min Types", + "msg.userfront.signup.password.rule.number": "Number", + "msg.userfront.signup.password.rule.symbol": "Symbol", + "msg.userfront.signup.password.rule.uppercase": "Uppercase", + "msg.userfront.signup.password.symbol_required": "Symbol Required", + "msg.userfront.signup.password.title": "Title", + "msg.userfront.signup.password.uppercase_required": "Uppercase Required", + "msg.userfront.signup.phone.code_mismatch": "Code Mismatch", + "msg.userfront.signup.phone.send_failed": "Send Failed", + "msg.userfront.signup.phone.verified": "Verified", + "msg.userfront.signup.phone.verify_failed": "Verify Failed", + "msg.userfront.signup.policy.loading": "Loading", + "msg.userfront.signup.policy.lowercase": "Lowercase", + "msg.userfront.signup.policy.min_length": "Min Length", + "msg.userfront.signup.policy.min_types": "Min Types", + "msg.userfront.signup.policy.number": "Number", + "msg.userfront.signup.policy.summary": "Summary", + "msg.userfront.signup.policy.symbol": "Symbol", + "msg.userfront.signup.policy.uppercase": "Uppercase", + "msg.userfront.signup.privacy_full": "\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관 장소를 달리하여 보존합니다.\\n③ 개인정보 파기의 절차 및 방법은 다음과 같습니다:\\n- 파기 절차: 회사는 파기 사유가 발생한 개인정보를 선정하고, 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.\\n- 파기 방법: 전자적 파일 형태로 기록된 개인정보는 복구할 수 없도록 기술적 방법을 사용해 삭제하며, 종이 문서에 기록된 개인정보는 분쇄기로 분쇄하거나 소각하여 파기합니다.\\n제9조 (개인정보의 안전성 확보 조치)\\n회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취합니다:\\n- 관리적 조치: 내부관리계획 수립·시행, 정기적 직원 교육\\n- 기술적 조치: 개인정보처리시스템 접근 권한 관리, 접근통제시스템 설치, 고유식별정보 암호화, 보안 프로그램 설치\\n- 물리적 조치: 전산실 및 자료보관실 접근 통제\\n제10조 (개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항)\\n회사는 쿠키(Cookie)를 사용하지 않습니다. 쿠키는 이용자의 이용 정보를 저장하고 수시로 불러오는 작은 파일로, 바론서비스에서는 쿠키를 사용하지 않습니다.\\n제11조 (개인정보 보호책임자)\\n회사는 개인정보 처리에 관한 업무를 총괄하여 책임지고, 개인정보 처리와 관련된 정보주체의 불만처리 및 피해구제를 위해 개인정보 보호책임자를 지정하고 있습니다.\\n개인정보 보호책임자:\\n- 성명: 염승호\\n- 직책: 수석연구원\\n- 연락처: 02-2141-7448\\n- 팩스번호: 02-2141-7599\\n- 이메일: b23008@baroncs.co.kr\\n제12조 (개인정보 열람청구)\\n정보주체는 「개인정보 보호법」 제35조에 따른 개인정보 열람 청구를 아래 부서에 할 수 있습니다. 회사는 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.\\n개인정보 열람청구 접수·처리 부서:\\n- 부서명: 총괄기획실\\n- 담당자: 권혁진\\n- 연락처: 02-2141-7465\\n- 팩스번호: 02-2141-7599\\n- 이메일: baroncs@baroncs.co.kr\\n제13조 (권익침해 구제방법)\\n정보주체는 개인정보 침해로 인한 구제를 위해 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보해신고센터 등에 분쟁 해결이나 상담을 신청할 수 있습니다.\\n- 개인정보분쟁조정위원회: (국번없이) 1833-6972 (www.kopico.go.kr)\\n- 개인정보침해신고센터: (국번없이) 118 (privacy.kisa.or.kr)\\n- 대검찰청: (국번없이) 1301 (www.spo.go.kr)\\n- 경찰청: (국번없이) 182 (www.police.go.kr)\\n제14조 (개인정보 처리방침의 변경)\\n본 개인정보처리방침은 법령, 정책 또는 보안 기술의 변경에 따라 내용의 추가, 삭제 및 수정이 있을 시, 개정 최소 7일 전에 홈페이지를 통해 사전 공지합니다.\\n\\n부칙\\n제1조 (시행일자)\\n이 개인정보처리방침은 2024년 10월 1일부터 시행됩니다.\\n제2조 (개정 및 고지의 의무)\\n회사는 개인정보처리방침을 변경하는 경우, 변경사항을 시행일자 7일 전부터 서비스 내 공지사항 페이지를 통해 고지할 것입니다. 다만, 이용자의 권리나 의무에 중대한 변경이 발생하는 경우에는 시행일자 30일 전부터 고지합니다.\\n제3조 (유효성)\\n본 개인정보처리방침의 일부 조항이 법적 또는 기타 사유로 인해 무효화되거나 시행할 수 없는 경우, 나머지 조항들은 계속해서 유효합니다. 무효화된 조항은 관련 법령에 부합하는 방식으로 수정되어 효력을 지속합니다.\\n제4조 (변경 통지의 방법)\\n회사는 개인정보처리방침의 변경 시, 다음의 방법으로 이용자에게 고지합니다:\\n- 서비스 초기화면 또는 팝업 공지\\n- 이메일 발송\\n- 회사 홈페이지 공지사항\\n제5조 (비회원의 개인정보 보호)\\n회사는 비회원의 개인정보도 회원과 동일한 수준으로 보호합니다. 비회원이 개인정보 제공을 거부할 경우 일부 서비스 이용에 제한이 있을 수 있습니다.\\n제6조 (14세 미만 아동의 개인정보 보호)\\n회사는 14세 미만 아동의 개인정보를 수집하지 않습니다. 만일 14세 미만 아동의 개인정보가 수집된 경우, 법정 대리인의 동의를 받아야 하며, 법정 대리인의 동의 없이 수집된 경우 이를 지체 없이 파기합니다.\\n제7조 (개인정보의 국외 이전)\\n회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.\\n제8조 (기타)\\n본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.\\n", + "msg.userfront.signup.profile.affiliate_hint": "Affiliate Hint", + "msg.userfront.signup.profile.title": "Title", + "msg.userfront.signup.success.body": "Body", + "msg.userfront.signup.success.title": "Title", + "msg.userfront.signup.tos_full": "\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한)\\n회사는 회원이 약관을 위반할 경우 서비스 이용을 제한할 수 있습니다.\\n제8장 손해 배상 및 면책 조항\\n제18조 (손해 배상)\\n회사는 무료로 제공되는 서비스와 관련하여 회원에게 발생한 손해에 대해 책임을 지지 않습니다.\\n제19조 (면책 조항)\\n회사는 천재지변 등 불가항력적인 사유로 인해 서비스를 제공하지 못하는 경우 책임을 지지 않습니다.\\n제9장 유료 서비스\\n20조 (유료 서비스의 이용)\\n① 회사는 회원에게 특정 서비스에 대해 유료로 제공할 수 있습니다. ② 유료 서비스의 이용 요금, 결제 방식, 환불 절차 등에 대한 상세 내용은 서비스 안내 페이지와 결제 화면에 명시합니다. ③ 유료 서비스 이용 요금은 회사가 정한 결제 방식에 따라 결제됩니다. 회원은 신용카드, 계좌이체, 휴대전화 결제 등 회사가 제공하는 다양한 결제 방식을 통해 요금을 납부할 수 있습니다. ④ 유료 서비스의 이용 요금은 선불 결제를 원칙으로 하며, 이용 기간 중 서비스 중지 및 해지 시 남은 이용 기간에 대한 환불은 회사의 환불 정책에 따라 처리됩니다. ⑤ 회사는 회원의 유료 서비스 이용과 관련하여 발생한 문제에 대해 최선을 다해 해결하도록 노력합니다. 다만, 회사의 고의 또는 중대한 과실이 없는 한 회원이 유료 서비스 이용 중 입은 손해에 대해서는 책임을 지지 않습니다.\\n제21조(환불 정책)\\n① 회원은 결제 후 7일 이내에 서비스 이용을 시작하지 않은 경우, 요금 전액을 환불받을 수 있습니다. ② 유료 서비스 이용 중 부득이한 사유로 서비스가 중지된 경우, 회사는 이용하지 않은 부분에 대해 환불 절차를 밟습니다. ③ 회원의 귀책사유로 인해 서비스 이용이 중지된 경우, 환불이 불가능합니다. ④ 환불은 회원이 지정한 계좌로 환불 절차를 거치며, 환불 요청 후 7일 이내에 처리됩니다.\\n제22조 (유료 서비스의 중지 및 해지)\\n① 회원이 유료 서비스를 해지하고자 하는 경우, 회사의 고객 지원 센터에 해지 신청을 해야 합니다. ② 회사는 회원이 약관을 위반하거나 부정한 방법으로 유료 서비스를 이용한 경우, 유료 서비스 이용을 즉시 중지하고 계약을 해지할 수 있습니다.\\n제10장 양도 금지\\n제23조 (양도 금지)\\n회원은 서비스 이용권한, 기타 이용계약상의 지위를 제3자에게 양도, 증여할 수 없으며, 이를 담보로 제공할 수 없습니다.\\n제11장 관할 법원\\n제24조 (분쟁 해결)\\n서비스 이용과 관련하여 분쟁이 발생한 경우, 회사와 회원은 성실히 협의하여 해결합니다.\\n제25조 (관할 법원)\\n본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.\\n부칙\\n본 약관은 2024년 10월 1일부터 시행됩니다.\\n", + "ui.admin.api_keys.create.name_label": "Name Label", + "ui.admin.api_keys.create.name_placeholder": "Name Placeholder", + "ui.admin.api_keys.create.section_name": "Section Name", + "ui.admin.api_keys.create.section_scopes": "Section Scopes", + "ui.admin.api_keys.create.submit": "Submit", + "ui.admin.api_keys.create.success.copy_secret": "Copy Secret", + "ui.admin.api_keys.create.success.go_list": "Go List", + "ui.admin.api_keys.create.success.title": "Title", + "ui.admin.api_keys.create.title": "Title", + "ui.admin.api_keys.list.add": "Add", + "ui.admin.api_keys.list.breadcrumb.list": "List", + "ui.admin.api_keys.list.breadcrumb.section": "API Keys", + "ui.admin.api_keys.list.registry.title": "API Key Registry", + "ui.admin.api_keys.list.table.actions": "ACTIONS", + "ui.admin.api_keys.list.table.client_id": "CLIENT ID", + "ui.admin.api_keys.list.table.last_used": "LAST USED", + "ui.admin.api_keys.list.table.name": "NAME", + "ui.admin.api_keys.list.table.scopes": "SCOPES", + "ui.admin.api_keys.list.title": "Title", + "ui.admin.audit.breadcrumb.logs": "Logs", + "ui.admin.audit.breadcrumb.section": "Audit", + "ui.admin.audit.copy.actor_id": "Copy actor id", + "ui.admin.audit.copy.request_id": "Copy request id", + "ui.admin.audit.copy.target": "Copy target", + "ui.admin.audit.details.actor": "Actor", + "ui.admin.audit.details.actor_id": "Actor ID · {{value}}", + "ui.admin.audit.details.after": "After · {{value}}", + "ui.admin.audit.details.before": "Before · {{value}}", + "ui.admin.audit.details.device": "Device · {{value}}", + "ui.admin.audit.details.error": "Error · {{value}}", + "ui.admin.audit.details.event_id": "Event ID · {{value}}", + "ui.admin.audit.details.ip": "IP · {{value}}", + "ui.admin.audit.details.latency": "Latency · {{value}}", + "ui.admin.audit.details.request": "Request", + "ui.admin.audit.details.request_id": "Request ID · {{value}}", + "ui.admin.audit.details.result": "Result", + "ui.admin.audit.details.tenant": "Tenant · {{value}}", + "ui.admin.audit.export_csv": "Export CSV", + "ui.admin.audit.filters.placeholder": "Placeholder", + "ui.admin.audit.filters.remove": "Remove", + "ui.admin.audit.load_more": "Load more", + "ui.admin.audit.registry.title": "Audit registry", + "ui.admin.audit.table.action_target": "Action / Target", + "ui.admin.audit.table.actor": "ACTOR (ID)", + "ui.admin.audit.table.path": "PATH", + "ui.admin.audit.table.request": "REQUEST", + "ui.admin.audit.table.status": "STATUS", + "ui.admin.audit.table.time": "TIME", + "ui.admin.audit.target": "Target · {{target}}", + "ui.admin.audit.title": "Title", + "ui.admin.brand": "Brand", + "ui.admin.dev_role_switcher": "🛠 DEV Role Switcher", + "ui.admin.groups.create.title": "Title", + "ui.admin.groups.form.desc_label": "Description", + "ui.admin.groups.form.desc_placeholder": "Desc Placeholder", + "ui.admin.groups.form.name_label": "Group Name", + "ui.admin.groups.form.name_placeholder": "Name Placeholder", + "ui.admin.groups.form.submit": "Submit", + "ui.admin.groups.list.title": "User Groups", + "ui.admin.groups.members.table.email": "Email", + "ui.admin.groups.members.table.name": "Name", + "ui.admin.groups.members.table.remove": "Remove", + "ui.admin.groups.table.actions": "ACTIONS", + "ui.admin.groups.table.members": "MEMBERS", + "ui.admin.groups.table.name": "NAME", + "ui.admin.header.plane": "Admin Plane", + "ui.admin.overview.kicker": "Global Overview", + "ui.admin.overview.playbook.title": "Admin playbook", + "ui.admin.overview.quick_links.add_tenant": "Tenant Add", + "ui.admin.overview.quick_links.tenant_dashboard": "Tenant Dashboard", + "ui.admin.overview.quick_links.title": "Title", + "ui.admin.overview.quick_links.view_audit_logs": "View Audit Logs", + "ui.admin.overview.title": "Tenant-independent control plane", + "ui.admin.role.rp_admin": "RP ADMIN", + "ui.admin.role.super_admin": "SUPER ADMIN", + "ui.admin.role.tenant_admin": "TENANT ADMIN", + "ui.admin.role.tenant_member": "TENANT MEMBER", + "ui.admin.tenants.add": "Tenant Add", + "ui.admin.tenants.breadcrumb.list": "List", + "ui.admin.tenants.breadcrumb.section": "Tenants", + "ui.admin.tenants.create.breadcrumb.action": "Create", + "ui.admin.tenants.create.breadcrumb.section": "Tenants", + "ui.admin.tenants.create.form.description": "Description", + "ui.admin.tenants.create.form.domains_label": "Allowed Domains (Comma separated)", + "ui.admin.tenants.create.form.domains_placeholder": "example.com, example.kr", + "ui.admin.tenants.create.form.name": "Tenant name", + "ui.admin.tenants.create.form.slug": "Slug", + "ui.admin.tenants.create.form.slug_placeholder": "tenant-slug", + "ui.admin.tenants.create.form.status": "Status", + "ui.admin.tenants.create.memo.title": "Title", + "ui.admin.tenants.create.profile.title": "Tenant Profile", + "ui.admin.tenants.create.title": "Tenant Add", + "ui.admin.tenants.members.table.email": "EMAIL", + "ui.admin.tenants.members.table.name": "NAME", + "ui.admin.tenants.members.table.role": "ROLE", + "ui.admin.tenants.members.table.status": "STATUS", + "ui.admin.tenants.members.title": "Tenant Members ({{count}})", + "ui.admin.tenants.registry.title": "Tenant registry", + "ui.admin.tenants.schema.add_field": "Add Field", + "ui.admin.tenants.schema.field.key": "Field Key (ID)", + "ui.admin.tenants.schema.field.key_placeholder": "e.g. employee_id", + "ui.admin.tenants.schema.field.label": "Display Label", + "ui.admin.tenants.schema.field.label_placeholder": "Label Placeholder", + "ui.admin.tenants.schema.field.type": "Type", + "ui.admin.tenants.schema.field.type_boolean": "Boolean", + "ui.admin.tenants.schema.field.type_number": "Number", + "ui.admin.tenants.schema.field.type_text": "Text", + "ui.admin.tenants.schema.save": "Save Schema Changes", + "ui.admin.tenants.schema.title": "User Schema Extension", + "ui.admin.tenants.sub.add": "Add", + "ui.admin.tenants.sub.manage": "Manage", + "ui.admin.tenants.sub.table.action": "ACTION", + "ui.admin.tenants.sub.table.name": "NAME", + "ui.admin.tenants.sub.table.slug": "SLUG", + "ui.admin.tenants.sub.table.status": "STATUS", + "ui.admin.tenants.sub.title": "Sub-tenants ({{count}})", + "ui.admin.tenants.table.actions": "ACTIONS", + "ui.admin.tenants.table.name": "NAME", + "ui.admin.tenants.table.slug": "SLUG", + "ui.admin.tenants.table.status": "STATUS", + "ui.admin.tenants.table.updated": "UPDATED", + "ui.admin.tenants.title": "Tenant List", + "ui.admin.title": "Admin Control", + "ui.admin.users.create.account.title": "Title", + "ui.admin.users.create.back": "Back", + "ui.admin.users.create.breadcrumb.new": "New", + "ui.admin.users.create.breadcrumb.section": "Users", + "ui.admin.users.create.custom_fields.title": "Title", + "ui.admin.users.create.form.auto_password": "Auto Password", + "ui.admin.users.create.form.department": "Department", + "ui.admin.users.create.form.department_placeholder": "Department Placeholder", + "ui.admin.users.create.form.email": "Email", + "ui.admin.users.create.form.email_placeholder": "user@example.com", + "ui.admin.users.create.form.name": "Name", + "ui.admin.users.create.form.name_placeholder": "Name Placeholder", + "ui.admin.users.create.form.password": "Password", + "ui.admin.users.create.form.password_placeholder": "********", + "ui.admin.users.create.form.phone": "Phone number", + "ui.admin.users.create.form.phone_placeholder": "010-1234-5678", + "ui.admin.users.create.form.role": "Role", + "ui.admin.users.create.form.tenant": "Tenant (Tenant)", + "ui.admin.users.create.form.tenant_global": "Tenant Global", + "ui.admin.users.create.go_list": "Go List", + "ui.admin.users.create.password_generated.title": "Title", + "ui.admin.users.create.submit": "User Create", + "ui.admin.users.create.title": "User Add", + "ui.admin.users.detail.back": "Back", + "ui.admin.users.detail.breadcrumb.section": "Users", + "ui.admin.users.detail.custom_fields.title": "Title", + "ui.admin.users.detail.edit_title": "Edit Title", + "ui.admin.users.detail.form.department": "Department", + "ui.admin.users.detail.form.department_placeholder": "Department Placeholder", + "ui.admin.users.detail.form.name": "Name", + "ui.admin.users.detail.form.name_placeholder": "Name Placeholder", + "ui.admin.users.detail.form.phone": "Phone number", + "ui.admin.users.detail.form.phone_placeholder": "010-1234-5678", + "ui.admin.users.detail.form.role": "Role", + "ui.admin.users.detail.form.status": "Status", + "ui.admin.users.detail.form.tenant": "Tenant (Tenant)", + "ui.admin.users.detail.form.tenant_global": "Tenant Global", + "ui.admin.users.detail.security.password": "Password", + "ui.admin.users.detail.security.password_placeholder": "Password Placeholder", + "ui.admin.users.detail.security.title": "Security Settings", + "ui.admin.users.detail.title": "User Details", + "ui.admin.users.list.add": "User Add", + "ui.admin.users.list.breadcrumb.list": "List", + "ui.admin.users.list.breadcrumb.section": "Users", + "ui.admin.users.list.delete_aria": "User Delete: {{name}}", + "ui.admin.users.list.edit_aria": "User Edit: {{name}}", + "ui.admin.users.list.registry.title": "User Registry", + "ui.admin.users.list.search_placeholder": "Search Placeholder", + "ui.admin.users.list.table.actions": "ACTIONS", + "ui.admin.users.list.table.created": "CREATED", + "ui.admin.users.list.table.name_email": "NAME / EMAIL", + "ui.admin.users.list.table.role": "ROLE", + "ui.admin.users.list.table.status": "STATUS", + "ui.admin.users.list.table.tenant_dept": "TENANT / DEPT", + "ui.admin.users.list.tenant_slug": "Slug: {{slug}}", + "ui.admin.users.list.title": "User Manage", + "ui.btn.cancel": "Cancel", + "ui.btn.save": "Save", + "ui.common.add": "Add", + "ui.common.back": "Back", + "ui.common.badge.admin_only": "Admin only", + "ui.common.badge.command_only": "Command only", + "ui.common.badge.system": "System", + "ui.common.cancel": "Cancel", + "ui.common.close": "Close", + "ui.common.collapse": "Collapse", + "ui.common.confirm": "Confirm", + "ui.common.copy": "Copy", + "ui.common.create": "Create", + "ui.common.delete": "Delete", + "ui.common.details": "Details", + "ui.common.edit": "Edit", + "ui.common.hyphen": "-", + "ui.common.na": "N/A", + "ui.common.never": "Never", + "ui.common.next": "Next", + "ui.common.page_of": "Page {{page}} of {{total}}", + "ui.common.prev": "Prev", + "ui.common.previous": "Previous", + "ui.common.qr": "QR", + "ui.common.read_only": "Read Only", + "ui.common.refresh": "Refresh", + "ui.common.requesting": "Requesting", + "ui.common.resend": "Resend", + "ui.common.retry": "Retry", + "ui.common.role.admin": "Admin", + "ui.common.role.user": "User", + "ui.common.save": "Save", + "ui.common.search": "Search", + "ui.common.show_more": "Show More", + "ui.common.status.active": "Active", + "ui.common.status.blocked": "Blocked", + "ui.common.status.failure": "Failure", + "ui.common.status.inactive": "Inactive", + "ui.common.status.ok": "Ok", + "ui.common.status.pending": "Pending", + "ui.common.status.success": "Success", + "ui.common.theme_dark": "Dark", + "ui.common.theme_light": "Light", + "ui.common.theme_toggle": "Theme Toggle", + "ui.common.unknown": "Unknown", + "ui.dev.brand": "Brand", + "ui.dev.clients.badge.admin_session": "Admin Session", + "ui.dev.clients.badge.tenant_selected": "Tenant Selected", + "ui.dev.clients.consents.breadcrumb.clients": "Clients", + "ui.dev.clients.consents.breadcrumb.current": "User Consent Grants", + "ui.dev.clients.consents.breadcrumb.home": "Home", + "ui.dev.clients.consents.export_csv": "Export CSV", + "ui.dev.clients.consents.filters.advanced": "Advanced Filters", + "ui.dev.clients.consents.revoke": "Revoke", + "ui.dev.clients.consents.search_placeholder": "Search Placeholder", + "ui.dev.clients.consents.stats.active_grants": "Active Grants", + "ui.dev.clients.consents.stats.avg_scopes": "Avg. Scopes per User", + "ui.dev.clients.consents.stats.total_scopes": "Total Scopes Issued", + "ui.dev.clients.consents.status_all": "All Statuses", + "ui.dev.clients.consents.status_label": "Status:", + "ui.dev.clients.consents.status_revoked": "Revoked", + "ui.dev.clients.consents.subject": "Subject", + "ui.dev.clients.consents.table.action": "Action", + "ui.dev.clients.consents.table.first_granted": "First Granted", + "ui.dev.clients.consents.table.last_auth": "Last Authenticated", + "ui.dev.clients.consents.table.scopes": "Granted Scopes", + "ui.dev.clients.consents.table.status": "Status", + "ui.dev.clients.consents.table.tenant": "Tenant", + "ui.dev.clients.consents.table.user": "User", + "ui.dev.clients.consents.title": "User Consent Grants", + "ui.dev.clients.copy_client_id": "Copy client id", + "ui.dev.clients.details.breadcrumb.current": "Current", + "ui.dev.clients.details.breadcrumb.section": "Relying Parties", + "ui.dev.clients.details.credentials.client_id": "Client ID", + "ui.dev.clients.details.credentials.client_secret": "Client Secret", + "ui.dev.clients.details.credentials.title": "Title", + "ui.dev.clients.details.endpoints.read_only": "Read Only", + "ui.dev.clients.details.endpoints.title": "Title", + "ui.dev.clients.details.redirect.callback_label": "Callback Label", + "ui.dev.clients.details.redirect.label": "Redirect URIs", + "ui.dev.clients.details.redirect.placeholder": "https://your-app.com/callback, http://localhost:3000/auth/callback", + "ui.dev.clients.details.redirect.save": "Save", + "ui.dev.clients.details.redirect.title": "Title", + "ui.dev.clients.details.secret.hide": "Hide", + "ui.dev.clients.details.secret.rotate": "Rotate", + "ui.dev.clients.details.secret.show": "Show", + "ui.dev.clients.details.security.title": "Title", + "ui.dev.clients.details.tab.connection": "Connection", + "ui.dev.clients.details.tab.consents": "Consent & Users", + "ui.dev.clients.details.tab.settings": "Settings", + "ui.dev.clients.general.breadcrumb.section": "Applications", + "ui.dev.clients.general.create": "Create", + "ui.dev.clients.general.display_new": "Display New", + "ui.dev.clients.general.footer.client_id": "Client ID", + "ui.dev.clients.general.footer.created_on": "Created On", + "ui.dev.clients.general.identity.description": "Description", + "ui.dev.clients.general.identity.description_placeholder": "Description Placeholder", + "ui.dev.clients.general.identity.logo": "App Logo URL", + "ui.dev.clients.general.identity.logo_placeholder": "https://example.com/logo.png", + "ui.dev.clients.general.identity.logo_preview": "Logo Preview", + "ui.dev.clients.general.identity.name": "Name", + "ui.dev.clients.general.identity.name_placeholder": "My Awesome Application", + "ui.dev.clients.general.identity.title": "Application Identity", + "ui.dev.clients.general.redirect.label": "Redirect URIs", + "ui.dev.clients.general.redirect.placeholder": "Placeholder", + "ui.dev.clients.general.save": "Settings Save", + "ui.dev.clients.general.scopes.add": "Scope Add", + "ui.dev.clients.general.scopes.description_placeholder": "Description Placeholder", + "ui.dev.clients.general.scopes.name_placeholder": "e.g. profile", + "ui.dev.clients.general.scopes.table.description": "Description", + "ui.dev.clients.general.scopes.table.mandatory": "Mandatory", + "ui.dev.clients.general.scopes.table.name": "Scope Name", + "ui.dev.clients.general.scopes.title": "Scopes", + "ui.dev.clients.general.security.confidential": "Confidential", + "ui.dev.clients.general.security.public": "Public", + "ui.dev.clients.general.security.title": "Security Settings", + "ui.dev.clients.general.title_create": "Create Client", + "ui.dev.clients.general.title_edit": "Client Settings", + "ui.dev.clients.help.docs_title": "Docs & Examples", + "ui.dev.clients.help.title": "Need help with OIDC configuration?", + "ui.dev.clients.help.view_guides": "View guides", + "ui.dev.clients.list.title": "Title", + "ui.dev.clients.new": "New", + "ui.dev.clients.owner.avatar_alt": "ops user", + "ui.dev.clients.owner.email": "admin@brsw.kr", + "ui.dev.clients.owner.name": "AI Admin Bot", + "ui.dev.clients.owner.role": "Role: Tenant Admin", + "ui.dev.clients.owner.scope": "Scope: TENANT-12", + "ui.dev.clients.owner.subtitle": "Tenant admin on-call", + "ui.dev.clients.owner.title": "Owner", + "ui.dev.clients.registry.subtitle": "Relying Parties", + "ui.dev.clients.registry.title": "RP registry", + "ui.dev.clients.search_placeholder": "Search Placeholder", + "ui.dev.clients.table.actions": "Actions", + "ui.dev.clients.table.application": "Application", + "ui.dev.clients.table.client_id": "Client ID", + "ui.dev.clients.table.created_at": "Created At", + "ui.dev.clients.table.status": "Status", + "ui.dev.clients.table.type": "Type", + "ui.dev.clients.tenant_scoped": "Tenant-scoped", + "ui.dev.clients.type.confidential": "Confidential", + "ui.dev.clients.type.public": "Public", + "ui.dev.clients.untitled": "Untitled", + "ui.dev.console_title": "Developer Console", + "ui.dev.dashboard.badge.consent_guard": "Consent guard ready", + "ui.dev.dashboard.badge.policy_toggle": "Policy toggle enabled", + "ui.dev.dashboard.badge.rp_synced": "RP registry synced", + "ui.dev.dashboard.next.subtitle": "Ship the RP controls", + "ui.dev.dashboard.next.title": "Next actions", + "ui.dev.dashboard.ops.card.consent_revoked": "Consent Revoked", + "ui.dev.dashboard.ops.card.hydra_status": "Hydra Status", + "ui.dev.dashboard.ops.card.rp_requests": "Rp Requests", + "ui.dev.dashboard.ops.subtitle": "Subtitle", + "ui.dev.dashboard.ops.tag.consent": "Consent grants", + "ui.dev.dashboard.ops.tag.rp_status": "RP status", + "ui.dev.dashboard.ops.title": "Ops board", + "ui.dev.dashboard.ready_badge": "devfront ready", + "ui.dev.dashboard.stack.notes": "Setup notes", + "ui.dev.dashboard.stack.subtitle": "Devfront baseline", + "ui.dev.dashboard.stack.title": "Stack readiness", + "ui.dev.env_badge": "Env: dev", + "ui.dev.header.plane": "Dev Plane", + "ui.dev.header.subtitle": "Manage your applications", + "ui.dev.scope_badge": "Scoped to /dev", + "ui.nav.dashboard": "Dashboard", + "ui.userfront.app_label.admin_console": "Admin Console", + "ui.userfront.app_label.baron": "Baron", + "ui.userfront.app_label.dev_console": "Dev Console", + "ui.userfront.app_title": "App Title", + "ui.userfront.audit.table.app": "App", + "ui.userfront.audit.table.auth_method": "Auth Method", + "ui.userfront.audit.table.date": "Date", + "ui.userfront.audit.table.device": "Device", + "ui.userfront.audit.table.ip": "IP", + "ui.userfront.audit.table.pending": "Pending", + "ui.userfront.audit.table.result": "Result", + "ui.userfront.audit.table.session_id": "Session ID", + "ui.userfront.audit.table.status": "Status", + "ui.userfront.auth_method.ory": "Ory", + "ui.userfront.auth_method.session": "Session", + "ui.userfront.dashboard.activity.linked": "Linked", + "ui.userfront.dashboard.approved_session.default": "Default", + "ui.userfront.dashboard.approved_session.userfront": "Userfront", + "ui.userfront.dashboard.last_auth_label": "Last Auth Label", + "ui.userfront.dashboard.revoke.confirm_button": "Confirm Button", + "ui.userfront.dashboard.revoke.title": "Title", + "ui.userfront.dashboard.scopes.title": "Permission (Scopes)", + "ui.userfront.dashboard.status.revoked": "Revoked", + "ui.userfront.dashboard.status_history": "Status History", + "ui.userfront.device.android": "Mobile(Android)", + "ui.userfront.device.ios": "Mobile(iOS)", + "ui.userfront.device.linux": "Desktop(Linux)", + "ui.userfront.device.macos": "Desktop(macOS)", + "ui.userfront.device.windows": "Desktop(Windows)", + "ui.userfront.error.go_home": "Go Home", + "ui.userfront.error.go_login": "Go Login", + "ui.userfront.forgot.heading": "Heading", + "ui.userfront.forgot.input_label": "Input Label", + "ui.userfront.forgot.submit": "Submit", + "ui.userfront.forgot.title": "Title", + "ui.userfront.login.action.submit": "Submit", + "ui.userfront.login.field.login_id": "Login Id", + "ui.userfront.login.field.password": "Password", + "ui.userfront.login.forgot_password": "Forgot Password", + "ui.userfront.login.link.action_label": "Action Label", + "ui.userfront.login.link.code_only": "Code Only", + "ui.userfront.login.link.page_title": "Page Title", + "ui.userfront.login.link.resend_with_time": "Resend With Time", + "ui.userfront.login.link.send": "Send", + "ui.userfront.login.link.title": "Title", + "ui.userfront.login.qr.expired": "Expired", + "ui.userfront.login.qr.refresh": "Refresh", + "ui.userfront.login.qr.remaining": "Remaining", + "ui.userfront.login.short_code.digits": "Digits", + "ui.userfront.login.short_code.expire_time": "Expire Time", + "ui.userfront.login.short_code.prefix": "Prefix", + "ui.userfront.login.short_code.submit": "Submit", + "ui.userfront.login.signup": "Signup", + "ui.userfront.login.tabs.link": "Link", + "ui.userfront.login.tabs.password": "Password", + "ui.userfront.login.tabs.qr": "QR", + "ui.userfront.login.unregistered.action": "Action", + "ui.userfront.login.unregistered.title": "Title", + "ui.userfront.login.verification.action_label": "Confirm", + "ui.userfront.login.verification.page_title": "Page Title", + "ui.userfront.login.verification.title": "Title", + "ui.userfront.login_success.later": "Later", + "ui.userfront.login_success.qr": "QR", + "ui.userfront.login_success.title": "Title", + "ui.userfront.nav.dashboard": "Dashboard", + "ui.userfront.nav.logout": "Logout", + "ui.userfront.nav.profile": "Profile", + "ui.userfront.nav.qr_scan": "QR Scan", + "ui.userfront.profile.department_empty": "Department Empty", + "ui.userfront.profile.field.affiliation": "Affiliation", + "ui.userfront.profile.field.company_code": "Company Code", + "ui.userfront.profile.field.department": "Department", + "ui.userfront.profile.field.email": "Email", + "ui.userfront.profile.field.name": "Name", + "ui.userfront.profile.field.tenant": "Tenant", + "ui.userfront.profile.manage": "Manage", + "ui.userfront.profile.password.change": "Change", + "ui.userfront.profile.password.confirm": "Confirm", + "ui.userfront.profile.password.current": "Current", + "ui.userfront.profile.password.forgot": "Forgot", + "ui.userfront.profile.password.new": "New", + "ui.userfront.profile.password.title": "Title", + "ui.userfront.profile.phone.code_hint": "Code Hint", + "ui.userfront.profile.phone.request_code": "Request Code", + "ui.userfront.profile.phone.title": "Phone number", + "ui.userfront.profile.section.basic": "Basic", + "ui.userfront.profile.section.organization": "Organization", + "ui.userfront.profile.section.security": "Security", + "ui.userfront.profile.user_fallback": "User", + "ui.userfront.qr.request_permission": "Request Permission", + "ui.userfront.qr.rescan": "Rescan", + "ui.userfront.qr.result_failure": "Result Failure", + "ui.userfront.qr.result_success": "Result Success", + "ui.userfront.qr.title": "Scan QR Code", + "ui.userfront.reset.confirm_password": "Confirm Password", + "ui.userfront.reset.new_password": "New Password", + "ui.userfront.reset.submit": "Submit", + "ui.userfront.reset.subtitle": "Subtitle", + "ui.userfront.reset.title": "Title", + "ui.userfront.sections.apps": "Apps", + "ui.userfront.sections.audit": "Audit", + "ui.userfront.session.active": "Active", + "ui.userfront.session.unknown": "Unknown", + "ui.userfront.signup.agreement.all": "All", + "ui.userfront.signup.agreement.privacy_title": "Privacy Title", + "ui.userfront.signup.agreement.tos_title": "Tos Title", + "ui.userfront.signup.auth.code_label": "Code Label", + "ui.userfront.signup.auth.email.label": "Label", + "ui.userfront.signup.auth.email.title": "Title", + "ui.userfront.signup.auth.request_code": "Request Code", + "ui.userfront.signup.complete": "Complete", + "ui.userfront.signup.next_step": "Next Step", + "ui.userfront.signup.password.confirm_label": "Password Confirm", + "ui.userfront.signup.password.label": "Password", + "ui.userfront.signup.phone.label": "Label", + "ui.userfront.signup.phone.title": "Title", + "ui.userfront.signup.profile.affiliation_type": "Affiliation Type", + "ui.userfront.signup.profile.company": "Company", + "ui.userfront.signup.profile.department": "Department", + "ui.userfront.signup.profile.department_optional": "Department Optional", + "ui.userfront.signup.profile.name": "Name", + "ui.userfront.signup.steps.agreement": "Agreement", + "ui.userfront.signup.steps.password": "Password", + "ui.userfront.signup.steps.profile": "Profile", + "ui.userfront.signup.steps.verify": "Verify", + "ui.userfront.signup.success.action": "Action", + "ui.userfront.signup.title": "Title", +}; diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 51d3d318..5c94c1b1 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -21,6 +21,7 @@ import 'core/services/logger_service.dart'; import 'core/notifiers/auth_notifier.dart'; import 'package:logging/logging.dart'; import 'features/auth/presentation/consent_screen.dart'; +import 'i18n.dart'; final _log = Logger('Main'); @@ -200,9 +201,12 @@ final _router = GoRouter( path: '/settings', builder: (context, state) { _routerLogger.info("Navigating to /settings (disabled)"); - return const ErrorScreen( + return ErrorScreen( errorCode: 'settings_disabled', - description: '현재 계정 설정 화면은 준비 중입니다.', + description: tr( + 'msg.userfront.settings.disabled', + fallback: '현재 계정 설정 화면은 준비 중입니다.', + ), ); }, ), @@ -295,7 +299,7 @@ class BaronSSOApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router( - title: 'Baron 로그인', + title: tr('ui.userfront.app_title', fallback: 'Baron 로그인'), theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index 77ba4108..9830d941 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -156,7 +156,7 @@ packages: source: sdk version: "0.0.0" flutter_web_plugins: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" diff --git a/userfront/pubspec.yaml b/userfront/pubspec.yaml index bdef42b8..d97edd4e 100644 --- a/userfront/pubspec.yaml +++ b/userfront/pubspec.yaml @@ -30,6 +30,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_web_plugins: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/userfront/test/dashboard_providers_test.dart b/userfront/test/dashboard_providers_test.dart index 2e46a640..e6d6ff1b 100644 --- a/userfront/test/dashboard_providers_test.dart +++ b/userfront/test/dashboard_providers_test.dart @@ -1,8 +1,11 @@ +import 'dart:ui'; + import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:userfront/features/dashboard/domain/dashboard_providers.dart'; import 'package:userfront/features/dashboard/domain/models.dart'; +import 'package:userfront/i18n.dart'; AuditLogEntry _log(String id) { return AuditLogEntry.fromJson({ @@ -21,6 +24,14 @@ Future _drainMicrotasks() async { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher; + dispatcher.localeTestValue = const Locale('ko'); + dispatcher.localesTestValue = const [Locale('ko')]; + + tearDownAll(() { + dispatcher.clearLocaleTestValue(); + dispatcher.clearLocalesTestValue(); + }); test('AuthTimelineNotifier는 초기 페이지를 로드한다', () async { final cursors = []; @@ -103,7 +114,13 @@ void main() { final state = container.read(authTimelineProvider); expect(state.items.isEmpty, true); - expect(state.error, '접속이력을 불러오지 못했습니다.'); + expect( + state.error, + tr( + 'msg.userfront.dashboard.timeline.load_error', + fallback: '접속이력을 불러오지 못했습니다.', + ), + ); container.dispose(); }); } diff --git a/userfront/test/error_screen_test.dart b/userfront/test/error_screen_test.dart index 5ed8c55d..28de4646 100644 --- a/userfront/test/error_screen_test.dart +++ b/userfront/test/error_screen_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/constants/error_whitelist.dart'; import 'package:userfront/features/auth/presentation/error_screen.dart'; +import 'package:userfront/i18n.dart'; Future _pumpErrorScreen( WidgetTester tester, { @@ -21,6 +23,19 @@ Future _pumpErrorScreen( } void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher; + dispatcher.localeTestValue = const Locale('ko'); + dispatcher.localesTestValue = const [Locale('ko')]; + }); + + tearDownAll(() { + final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher; + dispatcher.clearLocaleTestValue(); + dispatcher.clearLocalesTestValue(); + }); + testWidgets('개발환경은 원문 메시지를 노출한다', (WidgetTester tester) async { await _pumpErrorScreen( tester, @@ -29,9 +44,20 @@ void main() { isProdOverride: false, ); - expect(find.text('오류: custom_error'), findsOneWidget); + final title = tr( + 'msg.userfront.error.title_with_code', + fallback: '오류: {{code}}', + params: {'code': 'custom_error'}, + ); + final type = tr( + 'msg.userfront.error.type', + fallback: '오류 종류: {{type}}', + params: {'type': 'custom_error'}, + ); + + expect(find.text(title), findsOneWidget); expect(find.text('원문 메시지'), findsOneWidget); - expect(find.text('오류 종류: custom_error'), findsOneWidget); + expect(find.text(type), findsOneWidget); }); testWidgets('프로덕션은 whitelist 메시지를 노출한다', (WidgetTester tester) async { @@ -42,10 +68,24 @@ void main() { isProdOverride: true, ); - expect(find.text('인증 과정에서 오류가 발생했습니다'), findsOneWidget); - expect(find.text('현재 계정 설정 화면은 준비 중입니다.'), findsOneWidget); + final title = tr( + 'msg.userfront.error.title', + fallback: '인증 과정에서 오류가 발생했습니다', + ); + final detail = tr( + 'msg.userfront.error.whitelist.settings_disabled', + fallback: errorWhitelistMessages['settings_disabled']!, + ); + final type = tr( + 'msg.userfront.error.type', + fallback: '오류 종류: {{type}}', + params: {'type': 'settings_disabled'}, + ); + + expect(find.text(title), findsOneWidget); + expect(find.text(detail), findsOneWidget); expect(find.text('원문 메시지'), findsNothing); - expect(find.text('오류 종류: settings_disabled'), findsOneWidget); + expect(find.text(type), findsOneWidget); }); testWidgets('프로덕션은 비허용 에러를 unknown_error로 처리한다', (WidgetTester tester) async { @@ -56,9 +96,23 @@ void main() { isProdOverride: true, ); - expect(find.text('인증 과정에서 오류가 발생했습니다'), findsOneWidget); - expect(find.text('에러가 계속되면 관리자에게 문의해주세요'), findsOneWidget); + final title = tr( + 'msg.userfront.error.title', + fallback: '인증 과정에서 오류가 발생했습니다', + ); + final detail = tr( + 'msg.userfront.error.detail_contact', + fallback: '에러가 계속되면 관리자에게 문의해주세요', + ); + final type = tr( + 'msg.userfront.error.type', + fallback: '오류 종류: {{type}}', + params: {'type': 'unknown_error'}, + ); + + expect(find.text(title), findsOneWidget); + expect(find.text(detail), findsOneWidget); expect(find.text('원문 메시지'), findsNothing); - expect(find.text('오류 종류: unknown_error'), findsOneWidget); + expect(find.text(type), findsOneWidget); }); }