diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index a609298b..d321a411 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -469,6 +469,9 @@ class AuthProxyService { } static Future sendLog(String level, String message, {Map? data}) async { + if (!_canSendClientLog()) { + return; + } final url = Uri.parse('$_baseUrl/api/v1/client-log'); try { await http.post( @@ -480,7 +483,9 @@ class AuthProxyService { if (data != null) 'data': data, }), ); + _recordClientLogSuccess(); } catch (_) { + _recordClientLogFailure(); // Ignore logging errors to prevent loops } } @@ -493,6 +498,34 @@ class AuthProxyService { await sendLog('ERROR', message, data: data); } + static int _clientLogFailureCount = 0; + static DateTime? _clientLogLastFailureAt; + static DateTime? _clientLogOpenUntil; + + static bool _canSendClientLog() { + final now = DateTime.now(); + final openUntil = _clientLogOpenUntil; + if (openUntil != null && now.isBefore(openUntil)) { + return false; + } + return true; + } + + static void _recordClientLogFailure() { + _clientLogFailureCount += 1; + _clientLogLastFailureAt = DateTime.now(); + if (_clientLogFailureCount >= 3) { + _clientLogOpenUntil = DateTime.now().add(const Duration(minutes: 1)); + _clientLogFailureCount = 0; + } + } + + static void _recordClientLogSuccess() { + _clientLogFailureCount = 0; + _clientLogLastFailureAt = null; + _clientLogOpenUntil = null; + } + // --- Signup Methods --- static Future checkEmailAvailability(String email) async { diff --git a/userfront/lib/core/services/logger_service.dart b/userfront/lib/core/services/logger_service.dart index 7f8df236..2f964cfd 100644 --- a/userfront/lib/core/services/logger_service.dart +++ b/userfront/lib/core/services/logger_service.dart @@ -25,7 +25,7 @@ class LoggerService { ); // 2. Configure Standard Logger (logging package) - std_log.Logger.root.level = kReleaseMode ? std_log.Level.INFO : std_log.Level.ALL; + std_log.Logger.root.level = kReleaseMode ? std_log.Level.WARNING : std_log.Level.ALL; std_log.Logger.root.onRecord.listen((record) { if (kReleaseMode) { @@ -71,7 +71,7 @@ class LoggerService { debugPrint(jsonEncode(logData)); // 2. Relay to Backend (Docker Terminal) - if (record.level >= std_log.Level.INFO) { + if (record.level >= std_log.Level.WARNING) { AuthProxyService.sendLog( record.level.name, record.message, diff --git a/userfront/lib/features/auth/presentation/approve_qr_screen.dart b/userfront/lib/features/auth/presentation/approve_qr_screen.dart index ea0b480f..e3ac8460 100644 --- a/userfront/lib/features/auth/presentation/approve_qr_screen.dart +++ b/userfront/lib/features/auth/presentation/approve_qr_screen.dart @@ -17,11 +17,12 @@ class _ApproveQrScreenState extends State { String? _message; bool _success = false; bool _isCheckingSession = false; + bool _redirectingToLogin = false; @override void initState() { super.initState(); - _bootstrapCookieSession(); + _bootstrapCookieSession().then((_) => _redirectIfNotLoggedIn()); } Future _bootstrapCookieSession() async { @@ -45,6 +46,21 @@ class _ApproveQrScreenState extends State { } } + void _redirectIfNotLoggedIn() { + if (_redirectingToLogin || !mounted) return; + final hasStoredToken = AuthTokenStore.getToken() != null; + final hasDescopeSession = Descope.sessionManager.session?.refreshToken.isExpired == false; + final usesCookie = AuthTokenStore.usesCookie(); + final isLoggedIn = hasStoredToken || hasDescopeSession || usesCookie; + if (!isLoggedIn) { + _redirectingToLogin = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.go('/signin?notice=qr_login_required'); + }); + } + } + Future _handleApprove() async { if (widget.pendingRef == null) return; @@ -56,8 +72,9 @@ class _ApproveQrScreenState extends State { hasCookie = await _bootstrapCookieSession(); } if (storedToken == null && (session == null || session.refreshToken.isExpired) && !hasCookie) { - setState(() => _message = "Please log in on your phone first."); - context.go('/signin'); // Redirect to login + if (mounted) { + context.go('/signin?notice=qr_login_required'); + } return; } @@ -96,6 +113,10 @@ class _ApproveQrScreenState extends State { final usesCookie = AuthTokenStore.usesCookie(); final isLoggedIn = hasStoredToken || hasDescopeSession || usesCookie || _isCheckingSession; + if (!isLoggedIn && !_redirectingToLogin) { + _redirectIfNotLoggedIn(); + } + return Scaffold( appBar: AppBar(title: const Text("QR Login Approval")), body: Center( diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 3b22a9a8..fd258e99 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -48,6 +48,12 @@ class _LoginScreenState extends ConsumerState bool _verificationOnly = false; bool _verificationApproved = false; String _verificationMessage = ''; + String _verificationTitle = '승인 완료'; + String _verificationPageTitle = '로그인 승인'; + String _verificationActionLabel = '확인'; + String _verificationActionPath = '/'; + Timer? _verificationRedirectTimer; + bool _noticeHandled = false; bool _drySendEnabled = false; @override @@ -69,6 +75,7 @@ class _LoginScreenState extends ConsumerState final hasVerificationToken = widget.verificationToken != null || hasTokenParam; final hasLoginCode = loginIdParam != null && codeParam != null; _verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath; + final notice = uri.queryParameters['notice']; if (hasShortCodePath) { final shortCode = uri.pathSegments[1]; @@ -80,6 +87,11 @@ class _LoginScreenState extends ConsumerState _verifyToken(widget.verificationToken ?? uri.queryParameters['t']!); } + if (!_noticeHandled && notice == 'qr_login_required') { + _noticeHandled = true; + _showInfo('로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다'); + } + if (!_verificationOnly) { _tryCookieSession(); } @@ -360,12 +372,31 @@ class _LoginScreenState extends ConsumerState return AuthTokenStore.usesCookie(); } - void _markVerificationApproved(String message) { + void _markVerificationApproved( + String message, { + String title = '승인 완료', + String pageTitle = '로그인 승인', + String actionLabel = '확인', + String actionPath = '/', + bool autoRedirect = false, + Duration redirectDelay = const Duration(seconds: 2), + }) { if (!mounted) return; setState(() { _verificationApproved = true; _verificationMessage = message; + _verificationTitle = title; + _verificationPageTitle = pageTitle; + _verificationActionLabel = actionLabel; + _verificationActionPath = actionPath; }); + _verificationRedirectTimer?.cancel(); + if (autoRedirect) { + _verificationRedirectTimer = Timer(redirectDelay, () { + if (!mounted) return; + context.go(actionPath); + }); + } } Widget _buildVerificationResultView() { @@ -377,9 +408,9 @@ class _LoginScreenState extends ConsumerState children: [ const Icon(Icons.check_circle_outline, color: Colors.green, size: 72), const SizedBox(height: 16), - const Text( - '승인 완료', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green), + Text( + _verificationTitle, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green), ), const SizedBox(height: 12), Text( @@ -389,8 +420,8 @@ class _LoginScreenState extends ConsumerState ), const SizedBox(height: 24), FilledButton( - onPressed: () => context.go('/'), - child: const Text('확인'), + onPressed: () => context.go(_verificationActionPath), + child: Text(_verificationActionLabel), ), ], ), @@ -409,11 +440,20 @@ class _LoginScreenState extends ConsumerState final hasLocalSession = _hasLocalSession(); if (jwt is String && jwt.isNotEmpty) { - if (hasLocalSession) { - _markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다."); + if (hasLocalSession) { + _markVerificationApproved( + "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + ); return; } - _completeLoginFromToken(jwt, provider: provider); + _markVerificationApproved( + "링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.", + title: '링크 로그인 완료', + pageTitle: '링크 로그인', + actionLabel: '로그인 화면으로 이동', + actionPath: '/signin', + autoRedirect: true, + ); return; } @@ -451,7 +491,9 @@ class _LoginScreenState extends ConsumerState if (jwt is String && jwt.isNotEmpty) { if (hasLocalSession) { - _markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다."); + _markVerificationApproved( + "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + ); return; } _completeLoginFromToken(jwt, provider: res['provider'] as String?); @@ -489,7 +531,9 @@ class _LoginScreenState extends ConsumerState if (jwt is String && jwt.isNotEmpty) { if (hasLocalSession) { - _markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다."); + _markVerificationApproved( + "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + ); return; } _completeLoginFromToken(jwt, provider: res['provider'] as String?); @@ -510,6 +554,7 @@ class _LoginScreenState extends ConsumerState @override void dispose() { _stopQrPolling(); + _verificationRedirectTimer?.cancel(); _tabController.dispose(); _linkIdController.dispose(); _passwordLoginIdController.dispose(); @@ -851,7 +896,7 @@ class _LoginScreenState extends ConsumerState if (_verificationOnly && _verificationApproved) { return Scaffold( appBar: AppBar( - title: const Text('로그인 승인'), + title: Text(_verificationPageTitle), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/'), diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen.dart b/userfront/lib/features/auth/presentation/qr_scan_screen.dart index 3bcd15fd..805db385 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen.dart @@ -21,6 +21,7 @@ class _QRScanScreenState extends State { bool _isScanned = false; bool _isCheckingSession = false; bool _isProcessing = false; + bool _isRequestingCamera = false; bool? _isSuccess; String? _resultMessage; @@ -100,10 +101,7 @@ class _QRScanScreenState extends State { } if (sessionToken == null && !usesCookie) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('로그인이 필요합니다.'), backgroundColor: Colors.red), - ); - context.pop(); + context.go('/signin?notice=qr_login_required'); } return; } @@ -148,6 +146,28 @@ class _QRScanScreenState extends State { controller.start(); } + Future _requestCameraPermission() async { + if (_isRequestingCamera) return; + setState(() => _isRequestingCamera = true); + try { + await controller.start(); + } catch (e) { + _log.warning('Camera permission request failed: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isRequestingCamera = false); + } + } + } + Widget _buildResultView() { final success = _isSuccess == true; final icon = success ? Icons.check_circle_outline : Icons.error_outline; @@ -207,13 +227,30 @@ class _QRScanScreenState extends State { controller: controller, onDetect: _onDetect, errorBuilder: (context, error) { + final isPermissionDenied = error.errorCode == + MobileScannerErrorCode.permissionDenied; return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error, color: Colors.red, size: 50), const SizedBox(height: 10), - Text('Camera Error: ${error.errorCode}'), + Text( + isPermissionDenied + ? '카메라 권한이 필요합니다.' + : '카메라 오류: ${error.errorCode}', + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _isRequestingCamera + ? null + : _requestCameraPermission, + child: Text( + _isRequestingCamera + ? '요청 중...' + : '카메라 권한 요청하기', + ), + ), ], ), ); diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 7a46ab02..d3a657d1 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -31,6 +31,11 @@ class _ProfilePageState extends ConsumerState { final FocusNode _departmentFocus = FocusNode(); final FocusNode _phoneFocus = FocusNode(); final FocusNode _phoneCodeFocus = FocusNode(); + bool _nameTouched = false; + bool _departmentTouched = false; + bool _phoneTouched = false; + bool _phoneCodeTouched = false; + bool _isSavingField = false; String _initialPhone = ''; bool _isPhoneChanged = false; @@ -102,6 +107,8 @@ class _ProfilePageState extends ConsumerState { _isCodeSent = false; _isVerifying = false; _codeController?.clear(); + _phoneTouched = false; + _phoneCodeTouched = false; } void _startEditing(String field, UserProfile profile) { @@ -117,6 +124,16 @@ class _ProfilePageState extends ConsumerState { _resetPhoneState(); } }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (field == 'name') { + FocusScope.of(context).requestFocus(_nameFocus); + } else if (field == 'department') { + FocusScope.of(context).requestFocus(_departmentFocus); + } else if (field == 'phone') { + FocusScope.of(context).requestFocus(_phoneFocus); + } + }); } void _cancelEditing(UserProfile profile) { @@ -131,6 +148,8 @@ class _ProfilePageState extends ConsumerState { _resetPhoneState(); } _editingField = null; + _nameTouched = false; + _departmentTouched = false; }); } @@ -193,18 +212,55 @@ class _ProfilePageState extends ConsumerState { void _autoSaveIfEditing(UserProfile profile, String field) { if (_editingField != field) return; if (_isVerifying) return; + if (_isSavingField) return; + if (!_hasFieldChanged(profile, field)) { + setState(() { + if (field == 'phone') { + _resetPhoneState(); + } + _editingField = null; + if (field == 'name') { + _nameTouched = false; + } else if (field == 'department') { + _departmentTouched = false; + } + }); + return; + } _saveField(profile); } void _handlePhoneFocusChange(UserProfile profile) { if (_editingField != 'phone') return; if (_isVerifying) return; + if (_isSavingField) return; if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return; + if (!_hasFieldChanged(profile, 'phone')) { + setState(() { + _resetPhoneState(); + _editingField = null; + }); + return; + } _saveField(profile); } + bool _hasFieldChanged(UserProfile profile, String field) { + if (field == 'name') { + return (_nameController?.text.trim() ?? '') != profile.name; + } + if (field == 'department') { + return (_departmentController?.text.trim() ?? '') != profile.department; + } + if (field == 'phone') { + return (_phoneController?.text.trim() ?? '') != profile.phone; + } + return false; + } + Future _saveField(UserProfile profile) async { if (_editingField == null) return; + if (_isSavingField) return; final nextName = _editingField == 'name' ? _nameController!.text.trim() @@ -243,6 +299,20 @@ class _ProfilePageState extends ConsumerState { } } + if (!_hasFieldChanged(profile, _editingField!)) { + setState(() { + if (_editingField == 'phone') { + _resetPhoneState(); + } + _editingField = null; + _nameTouched = false; + _departmentTouched = false; + }); + return; + } + + _isSavingField = true; + try { await ref.read(profileProvider.notifier).updateProfile( name: nextName, @@ -256,6 +326,8 @@ class _ProfilePageState extends ConsumerState { _resetPhoneState(); } _editingField = null; + _nameTouched = false; + _departmentTouched = false; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('정보가 수정되었습니다.')), @@ -267,6 +339,8 @@ class _ProfilePageState extends ConsumerState { SnackBar(content: Text('수정 실패: $e')), ); } + } finally { + _isSavingField = false; } } @@ -454,23 +528,53 @@ class _ProfilePageState extends ConsumerState { children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), - Focus( - focusNode: field == 'name' ? _nameFocus : _departmentFocus, - onFocusChange: (hasFocus) { - if (!hasFocus) { - _autoSaveIfEditing(profile, field); - } - }, - child: TextField( - controller: controller, - focusNode: field == 'name' ? _nameFocus : _departmentFocus, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _autoSaveIfEditing(profile, field), - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: label, + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Focus( + focusNode: field == 'name' ? _nameFocus : _departmentFocus, + onFocusChange: (hasFocus) { + if (field == 'name') { + if (hasFocus) { + _nameTouched = true; + return; + } + if (!_nameTouched) { + return; + } + } + if (field == 'department') { + if (hasFocus) { + _departmentTouched = true; + return; + } + if (!_departmentTouched) { + return; + } + } + if (!hasFocus) { + _autoSaveIfEditing(profile, field); + } + }, + child: TextField( + controller: controller, + focusNode: field == 'name' ? _nameFocus : _departmentFocus, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _autoSaveIfEditing(profile, field), + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: label, + ), + ), + ), ), - ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: isUpdating ? null : () => _cancelEditing(profile), + child: const Text('취소'), + ), + ], ), ], ); @@ -504,6 +608,13 @@ class _ProfilePageState extends ConsumerState { child: Focus( focusNode: _phoneFocus, onFocusChange: (hasFocus) { + if (hasFocus) { + _phoneTouched = true; + return; + } + if (!_phoneTouched) { + return; + } if (!hasFocus) { _handlePhoneFocusChange(profile); } @@ -531,6 +642,11 @@ class _ProfilePageState extends ConsumerState { onPressed: _isVerifying ? null : _sendCode, child: Text(_isCodeSent ? '재전송' : '인증요청'), ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: isUpdating ? null : () => _cancelEditing(profile), + child: const Text('취소'), + ), ], ), if (_isCodeSent && !_isPhoneVerified) ...[ @@ -542,6 +658,13 @@ class _ProfilePageState extends ConsumerState { child: Focus( focusNode: _phoneCodeFocus, onFocusChange: (hasFocus) { + if (hasFocus) { + _phoneCodeTouched = true; + return; + } + if (!_phoneCodeTouched) { + return; + } if (!hasFocus) { _handlePhoneFocusChange(profile); } diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index d9ecdfd1..78434059 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -89,7 +89,7 @@ final _routerLogger = Logger('Router'); final _router = GoRouter( initialLocation: '/', - debugLogDiagnostics: true, // Enable diagnostic logs + debugLogDiagnostics: !kReleaseMode, refreshListenable: AuthNotifier.instance, routes: [ GoRoute( diff --git a/userfront/nginx.conf b/userfront/nginx.conf index 900d1b56..e57c9eb5 100644 --- a/userfront/nginx.conf +++ b/userfront/nginx.conf @@ -1,9 +1,9 @@ -# Map ISO8601 time to "YYYY-MM-DD HH:mm:ss" format +# ISO8601 시간을 "YYYY-MM-DD HH:mm:ss" 형식으로 변환 map $time_iso8601 $time_custom { "~^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})" "$1-$2-$3 $4:$5:$6"; } -# Custom JSON Log Format matching Go slog +# Go slog 포맷과 맞춘 JSON 액세스 로그 log_format json_combined escape=json '{' '"time":"$time_custom",' @@ -23,66 +23,10 @@ server { listen 5000; include /etc/nginx/mime.types; + error_log /dev/stderr warn; access_log /var/log/nginx/access.log json_combined; - # --- Backend API Proxy --- - location /api { - proxy_pass http://baron_backend:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # --- Ory Stack Proxy (via Oathkeeper) --- - # Kratos Public API - location /auth { - proxy_pass http://oathkeeper:4455; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Hydra Public API - location /oidc { - proxy_pass http://oathkeeper:4455; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # --- Internal Web Apps Proxy --- 초반에는 외부 오픈 없이 Private Net 내부에서만 운영 - # AdminFront (Vite Dev Server or Nginx) - # location /admin { - # proxy_pass http://baron_adminfront:5173; - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # proxy_set_header X-Forwarded-Proto $scheme; - - # # WebSocket support (for Vite HMR) - # proxy_http_version 1.1; - # proxy_set_header Upgrade $http_upgrade; - # proxy_set_header Connection "upgrade"; - # } - - # # DevFront (Vite Dev Server or Nginx) - # location /dev { - # proxy_pass http://baron_devfront:5173; - # proxy_set_header Host $host; - # proxy_set_header X-Real-IP $remote_addr; - # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # proxy_set_header X-Forwarded-Proto $scheme; - - # # WebSocket support (for Vite HMR) - # proxy_http_version 1.1; - # proxy_set_header Upgrade $http_upgrade; - # proxy_set_header Connection "upgrade"; - # } - - # --- UserFront Static Files --- + # --- UserFront 정적 파일 --- location / { root /usr/share/nginx/html; index index.html;