import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../domain/session_time_resolver.dart'; import '../domain/providers/linked_rps_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/widgets/language_selector.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.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}); @override ConsumerState createState() => _DashboardScreenState(); } class _DashboardScreenState extends ConsumerState { static const _ink = Color(0xFF1A1F2C); static const _surface = Colors.white; static const _border = Color(0xFFE5E7EB); static const _subtle = Color(0xFFF7F8FA); static const double _historySessionMinWidth = 92; static const double _historyOtherColumnsBaselineWidth = 780; static const int _historySessionMinVisibleChars = 8; final ScrollController _pageScrollController = ScrollController(); final ScrollController _rpScrollController = ScrollController(); final List _auditLogs = []; String? _auditNextCursor; bool _auditLoading = false; bool _auditLoadingMore = false; bool _isRevoking = false; bool _redirectingToSignin = false; bool _authBootstrapInProgress = false; bool _showAllActivities = false; final Set _revokedClientIds = {}; @override void initState() { super.initState(); _pageScrollController.addListener(_onPageScroll); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } unawaited(_bootstrapAuthAndLoad()); }); } @override void dispose() { _pageScrollController.dispose(); _rpScrollController.dispose(); super.dispose(); } Future _logout() async { AuthTokenStore.clear(); AuthNotifier.instance.notify(); } Future _onRevokeLink(String clientId, String appName) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(tr('ui.userfront.dashboard.revoke.title')), content: Text( tr( 'msg.userfront.dashboard.revoke.confirm', params: {'app': appName}, ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(tr('ui.common.cancel')), ), TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: Text(tr('ui.userfront.dashboard.revoke.confirm_button')), ), ], ), ); if (confirmed != true) return; setState(() => _isRevoking = true); try { await ref.read(linkedRpsProvider.notifier).revokeRp(clientId); if (mounted) { ToastService.success( tr( 'msg.userfront.dashboard.revoke.success', params: {'app': appName}, ), ); setState(() { _revokedClientIds.add(clientId); }); ref.invalidate(linkedRpsProvider); } } catch (e) { if (mounted) { ToastService.error( tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}), ); } } finally { if (mounted) { setState(() => _isRevoking = false); } } } void _onScanQR() { context.push('/scan'); } void _onPageScroll() { if (!_pageScrollController.hasClients) { return; } if (_pageScrollController.position.extentAfter < 240) { ref.read(authTimelineProvider.notifier).loadMore(); } } void _showRpDetails(_ActivityItem item) { showDialog( context: context, builder: (context) => Consumer( builder: (context, ref, _) { return AlertDialog( title: Text(item.appName), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr('ui.userfront.dashboard.scopes.title'), style: const TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), if (item.scopes.isEmpty) Text( tr('msg.userfront.dashboard.scopes.empty'), style: const TextStyle(color: Colors.grey), ) else Wrap( spacing: 8, runSpacing: 4, children: item.scopes .map( (s) => Chip( label: Text( s, style: const TextStyle(fontSize: 12), ), visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ) .toList(), ), const SizedBox(height: 24), Text( tr('ui.userfront.dashboard.status_history'), style: const TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr( 'msg.userfront.dashboard.last_auth', params: {'value': item.lastAuthAt}, ), ), const SizedBox(height: 4), Builder( builder: (context) { final statusLabel = item.status == 'active' ? tr('ui.common.status.active') : tr('ui.userfront.dashboard.status.revoked'); return Text( tr( 'msg.userfront.dashboard.current_status', params: {'status': statusLabel}, ), style: TextStyle( color: item.status == 'active' ? Colors.green : Colors.grey, ), ); }, ), ], ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(tr('ui.common.close')), ), ], ); }, ), ); } Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) { return SafeArea( child: Column( children: [ Expanded( child: ListView( padding: const EdgeInsets.symmetric(vertical: 12), children: [ ListTile( leading: const Icon(Icons.home_outlined), title: Text(tr('ui.userfront.nav.dashboard')), selected: true, onTap: () { if (closeOnTap) { Navigator.of(context).pop(); } context.go(buildLocalizedHomePath(Uri.base)); }, ), ListTile( leading: const Icon(Icons.person_outline), title: Text(tr('ui.userfront.nav.profile')), onTap: () { if (closeOnTap) { Navigator.of(context).pop(); } context.push('/profile'); }, ), ListTile( leading: const Icon(Icons.qr_code_scanner), title: Text(tr('ui.userfront.nav.qr_scan')), onTap: () { if (closeOnTap) { Navigator.of(context).pop(); } _onScanQR(); }, ), const Divider(), ListTile( leading: const Icon(Icons.logout), title: Text(tr('ui.userfront.nav.logout')), onTap: () async { if (closeOnTap) { Navigator.of(context).pop(); } await _logout(); }, ), ], ), ), const Padding( padding: EdgeInsets.only(bottom: 16), child: LanguageSelector(compact: true), ), ], ), ); } Future _refreshAll() async { if (!_isLoggedIn()) { final recovered = await _recoverSessionFromCookie(); if (!recovered) { _redirectToSignin(); return; } } await ref.read(profileProvider.notifier).loadProfile(); setState(() { _revokedClientIds.clear(); }); ref.invalidate(linkedRpsProvider); await Future.wait([ ref.read(linkedRpsProvider.future), ref.read(authTimelineProvider.notifier).refresh(), ]); await _loadAuditLogs(reset: true); await ref.read(linkedRpsProvider.notifier).refresh(); } static String _envOrDefault(String key, String fallback) { if (!dotenv.isInitialized) { return fallback; } return dotenv.env[key] ?? fallback; } Future _fetchAuditLogs({String? cursor}) async { final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); final queryParameters = {'limit': '20'}; if (cursor != null && cursor.isNotEmpty) { queryParameters['cursor'] = cursor; } final url = Uri.parse( '$baseUrl/api/v1/audit/auth/timeline', ).replace(queryParameters: queryParameters); final useCookie = AuthTokenStore.usesCookie(); final token = AuthTokenStore.getToken(); final client = createHttpClient(withCredentials: useCookie); final headers = {'Content-Type': 'application/json'}; if (!useCookie && token != null) { headers['Authorization'] = 'Bearer $token'; } try { final response = await client.get(url, headers: headers); if (response.statusCode != 200) { throw Exception('Failed to load audit logs'); } final body = jsonDecode(response.body) as Map; final items = (body['items'] as List?) ?? []; final nextCursor = body['next_cursor']?.toString(); final logs = items .whereType>() .map(AuditLogEntry.fromJson) .toList(); return AuditPage(items: logs, nextCursor: nextCursor); } finally { client.close(); } } Future _loadAuditLogs({bool reset = false}) async { if (!_isLoggedIn()) { return; } if (_auditLoading || _auditLoadingMore) { return; } final nextCursor = _auditNextCursor; if (!reset && (nextCursor == null || nextCursor.isEmpty)) { return; } if (reset) { setState(() { _auditLogs.clear(); _auditNextCursor = null; _auditLoading = true; }); } else { setState(() { _auditLoadingMore = true; }); } try { final page = await _fetchAuditLogs(cursor: _auditNextCursor); setState(() { _auditLogs.addAll(page.items); _auditNextCursor = page.nextCursor; }); } catch (_) { // 에러는 상위 UI에서 재시도 UX로 처리합니다. } finally { setState(() { _auditLoading = false; _auditLoadingMore = false; }); } } String _formatDateTime(DateTime dateTime) { final yyyy = dateTime.year.toString().padLeft(4, '0'); final mm = dateTime.month.toString().padLeft(2, '0'); final dd = dateTime.day.toString().padLeft(2, '0'); final hh = dateTime.hour.toString().padLeft(2, '0'); final min = dateTime.minute.toString().padLeft(2, '0'); return '$yyyy.$mm.$dd $hh:$min'; } Widget _selectableText(String text, {TextStyle? style}) { return SelectableText(text, style: style); } String _authMethodLabel() { if (AuthTokenStore.usesCookie()) { return tr('ui.userfront.auth_method.ory'); } final provider = AuthTokenStore.getProvider(); if (provider == null || provider.isEmpty) { return tr('ui.userfront.auth_method.session'); } final lower = provider.toLowerCase(); if (lower.contains('ory')) { return tr('ui.userfront.auth_method.ory'); } return provider; } String _deviceLabelFromUserAgent(String userAgent) { if (userAgent.isEmpty) { return tr('ui.common.hyphen', fallback: '-'); } final ua = userAgent.toLowerCase(); if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) { return tr('ui.userfront.device.ios', fallback: 'Mobile(iOS)'); } if (ua.contains('android')) { return tr('ui.userfront.device.android', fallback: 'Mobile(Android)'); } if (ua.contains('windows')) { return tr('ui.userfront.device.windows', fallback: 'Desktop(Windows)'); } if (ua.contains('mac os x') || ua.contains('macintosh')) { return tr('ui.userfront.device.macos', fallback: 'Desktop(macOS)'); } if (ua.contains('linux')) { return tr('ui.userfront.device.linux', fallback: 'Desktop(Linux)'); } return tr('ui.common.unknown', fallback: 'Unknown'); } Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) { final isOidc = authMethod.contains('OIDC'); if (authMethod != 'QR' && !isOidc) { final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? ''; final approvedIp = log.detailMap['approved_ip']?.toString() ?? ''; final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty; if (!authMethod.startsWith('링크') || !hasApproverMeta) { return _selectableText(authMethod); } final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent); final tooltip = [ tr( 'msg.userfront.dashboard.approved_device', params: {'device': deviceLabel}, ), tr( 'msg.userfront.dashboard.approved_ip', params: {'ip': approvedIp.isEmpty ? '-' : approvedIp}, ), ].join('\n'); return Tooltip( message: tooltip, child: _selectableText( authMethod, style: const TextStyle( color: Colors.blueAccent, decoration: TextDecoration.underline, ), ), ); } final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false) ? log.detailMap['approved_session_id'].toString() : log.sessionId; final tooltipLabel = isOidc ? tr('ui.userfront.dashboard.approved_session.userfront') : tr('ui.userfront.dashboard.approved_session.default'); final tooltip = approvedSessionId.isEmpty ? tr( 'msg.userfront.dashboard.approved_session.none', params: {'label': tooltipLabel}, ) : tr( 'msg.userfront.dashboard.approved_session.copy_click', params: {'label': tooltipLabel, 'id': approvedSessionId}, ); return InkWell( onTap: approvedSessionId.isEmpty ? null : () async { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { ToastService.info( tr('msg.userfront.dashboard.session_id_copied'), ); } }, child: Tooltip( message: tooltip, child: Text( isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'), style: TextStyle( color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, decoration: approvedSessionId.isEmpty ? null : TextDecoration.underline, ), ), ), ); } Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) { final isOidc = authMethod.contains('OIDC'); if (authMethod != 'QR' && !isOidc) { final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? ''; final approvedIp = log.detailMap['approved_ip']?.toString() ?? ''; final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty; if (!authMethod.startsWith('링크') || !hasApproverMeta) { return _selectableText( tr( 'msg.userfront.dashboard.auth_method', params: {'method': authMethod}, ), ); } final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent); final tooltip = [ tr( 'msg.userfront.dashboard.approved_device', params: {'device': deviceLabel}, ), tr( 'msg.userfront.dashboard.approved_ip', params: {'ip': approvedIp.isEmpty ? '-' : approvedIp}, ), ].join('\n'); return Tooltip( message: tooltip, child: _selectableText( tr( 'msg.userfront.dashboard.auth_method', params: {'method': authMethod}, ), style: const TextStyle( color: Colors.blueAccent, decoration: TextDecoration.underline, ), ), ); } final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false) ? log.detailMap['approved_session_id'].toString() : log.sessionId; final tooltipLabel = isOidc ? tr('ui.userfront.dashboard.approved_session.userfront') : tr('ui.userfront.dashboard.approved_session.default'); return InkWell( onTap: approvedSessionId.isEmpty ? null : () async { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { ToastService.info( tr('msg.userfront.dashboard.session_id_copied'), ); } }, child: Tooltip( message: approvedSessionId.isEmpty ? tr( 'msg.userfront.dashboard.approved_session.none', params: {'label': tooltipLabel}, ) : tr( 'msg.userfront.dashboard.approved_session.copy_tap', params: {'label': tooltipLabel, 'id': approvedSessionId}, ), child: Text( tr( 'msg.userfront.dashboard.auth_method', params: { 'method': isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'), }, ), style: TextStyle( color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, decoration: approvedSessionId.isEmpty ? null : TextDecoration.underline, ), ), ), ); } String _appLabelForLog(AuditLogEntry log) { if (log.appName.isNotEmpty) { return log.appName; } return _appLabelForPath(log.path); } Widget _buildAppCell(AuditLogEntry log, {TextStyle? style}) { final label = _appLabelForLog(log); final clientId = log.clientId; final tooltip = clientId.isEmpty ? tr('msg.userfront.dashboard.client_id_missing') : tr( 'msg.userfront.dashboard.client_id', fallback: 'Client ID: {{id}}', params: {'id': clientId}, ); final baseStyle = style ?? const TextStyle(); final emphasisStyle = clientId.isEmpty ? baseStyle : baseStyle.copyWith( color: Colors.blueAccent, decoration: TextDecoration.underline, ); return Tooltip( message: tooltip, child: _selectableText(label, style: emphasisStyle), ); } String _appLabelForPath(String path) { if (path.startsWith('/api/v1/auth')) { return tr('ui.userfront.app_label.baron'); } if (path.startsWith('/api/v1/user')) { return tr('ui.userfront.app_label.baron'); } if (path.startsWith('/api/v1/dev')) { return tr('ui.userfront.app_label.dev_console', fallback: 'Dev Console'); } if (path.startsWith('/api/v1/admin')) { return tr( 'ui.userfront.app_label.admin_console', fallback: 'Admin Console', ); } return tr('ui.userfront.app_label.baron'); } @override Widget build(BuildContext context) { try { if (!_isLoggedIn()) { _redirectToSignin(); return const SizedBox.shrink(); } final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; final profileState = ref.watch(profileProvider); final profile = profileState.value; final timelineState = ref.watch(authTimelineProvider); final userName = profile?.name ?? profile?.email ?? profile?.phone ?? tr('ui.userfront.profile.user_fallback', fallback: 'User'); final departmentValue = profile?.tenant?.name ?? profile?.department ?? ''; final department = departmentValue.isNotEmpty ? departmentValue : tr('ui.userfront.profile.department_empty'); final sessionIssuedAt = resolveDashboardSessionIssuedAt( token: AuthTokenStore.getToken(), profile: profile, ); return Scaffold( backgroundColor: _subtle, appBar: AppBar( title: Text( tr('ui.userfront.app_title'), style: const TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, backgroundColor: _surface, foregroundColor: Colors.black, actions: [ IconButton( icon: const Icon(Icons.person_outline), tooltip: tr('ui.userfront.nav.profile'), onPressed: () => context.push('/profile'), ), IconButton( icon: const Icon(Icons.qr_code_scanner), tooltip: tr('ui.userfront.nav.qr_scan'), onPressed: _onScanQR, ), IconButton( icon: const Icon(Icons.logout), tooltip: tr('ui.userfront.nav.logout'), onPressed: _logout, ), ], ), drawer: isWide ? null : Drawer(child: _buildSideMenu(context, closeOnTap: true)), body: Row( children: [ if (isWide) SizedBox( width: 240, child: _buildSideMenu(context, closeOnTap: false), ), Expanded( child: RefreshIndicator( onRefresh: _refreshAll, child: LayoutBuilder( builder: (context, constraints) { final timelineWide = constraints.maxWidth >= 900; final isMobile = constraints.maxWidth < 600; return SingleChildScrollView( controller: _pageScrollController, physics: const AlwaysScrollableScrollPhysics(), child: Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isMobile) ...[ _buildHeaderCard( userName, department, sessionIssuedAt, ), const SizedBox(height: 28), ], _buildSectionTitle( tr('ui.userfront.sections.apps'), tr('msg.userfront.sections.apps_subtitle'), ), const SizedBox(height: 12), _buildActivitySection(isMobile), const SizedBox(height: 28), _buildSectionTitle( tr('ui.userfront.sections.audit'), tr('msg.userfront.sections.audit_subtitle'), ), const SizedBox(height: 12), _buildAccessHistory(timelineState, timelineWide), ], ), ), ); }, ), ), ), ], ), ); } catch (error, stackTrace) { AuthProxyService.logError( 'DASHBOARD_RENDER_ERROR: $error\nuri=${Uri.base}', error: error, stackTrace: stackTrace, ); return Scaffold( backgroundColor: _subtle, body: Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( tr( 'msg.userfront.dashboard.render_error', fallback: '대시보드 렌더링 중 오류가 발생했습니다. 다시 시도해 주세요.', ), textAlign: TextAlign.center, ), ), ), ); } } Widget _buildHeaderCard( String userName, String department, DateTime? issuedAt, ) { final sessionLabel = issuedAt != null ? _formatDateTime(issuedAt) : tr('ui.userfront.session.unknown'); final infoColumn = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr('msg.userfront.greeting', params: {'name': userName}), style: const TextStyle( fontSize: 22, fontWeight: FontWeight.bold, color: _ink, ), ), const SizedBox(height: 6), Text( department, style: TextStyle(color: Colors.grey[600], fontSize: 14), ), const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, children: [ _buildInfoChip( Icons.verified_user, tr('ui.userfront.session.active'), ), _buildInfoChip(Icons.lock_outline, _authMethodLabel()), _buildInfoChip(Icons.access_time, sessionLabel), ], ), ], ); return Container( width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: _surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: _border), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 10), blurRadius: 18, offset: const Offset(0, 8), ), ], ), child: infoColumn, ); } Widget _buildSectionTitle(String title, String subtitle) { return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( title, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: _ink, ), ), const SizedBox(width: 12), Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])), ], ); } Widget _buildInfoChip(IconData icon, String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: _subtle, borderRadius: BorderRadius.circular(999), border: Border.all(color: _border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: _ink), const SizedBox(width: 6), Text( label, style: const TextStyle( fontSize: 12, color: _ink, fontWeight: FontWeight.w600, ), ), ], ), ); } Widget _buildActivitySection(bool isMobile) { final linkedRpsState = ref.watch(linkedRpsProvider); return linkedRpsState.when( data: (linkedRps) { final activities = _buildActivityItems(linkedRps); if (activities.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr('msg.userfront.dashboard.activities.empty'), style: TextStyle( fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600, ), ), const SizedBox(height: 6), Text( tr('msg.userfront.dashboard.activities.empty_detail'), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ); } return _buildActivityGrid(activities, isMobile); }, loading: () => const SizedBox( height: 100, child: Center(child: CircularProgressIndicator()), ), error: (error, stack) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr('msg.userfront.dashboard.activities.error'), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), const SizedBox(height: 8), TextButton( onPressed: () => ref.read(linkedRpsProvider.notifier).refresh(), child: Text(tr('ui.common.retry')), ), ], ), ); } List<_ActivityItem> _buildActivityItems(List linkedRps) { final items = <_ActivityItem>[]; for (final rp in linkedRps) { final normalizedStatus = rp.status.toLowerCase(); // status가 'inactive'로 내려올 수 있으므로 이를 반영 final isActiveInApi = normalizedStatus == 'active' || normalizedStatus == ''; final isRevoked = !isActiveInApi; final lastAuthAt = rp.lastAuthenticatedAt; final lastAuthLabel = lastAuthAt != null ? _formatDateTime(lastAuthAt) : tr('ui.userfront.dashboard.activity.linked'); final statusCode = isRevoked ? 'revoked' : 'active'; final name = rp.name.isNotEmpty ? rp.name : rp.id; items.add( _ActivityItem( clientId: rp.id, appName: name, lastAuthAt: lastAuthLabel, status: statusCode, scopes: rp.scopes, isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), url: rp.url, lastAuthDateTime: rp.lastAuthenticatedAt, ), ); } // 정렬 로직 적용: 활성 우선 -> 최근 인증 최신순 -> 비활성 items.sort((a, b) { final aActive = a.status == 'active'; final bActive = b.status == 'active'; if (aActive && !bActive) return -1; if (!aActive && bActive) return 1; // 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순 final aLastAuth = a.lastAuthDateTime; final bLastAuth = b.lastAuthDateTime; if (aLastAuth != null && bLastAuth != null) { return bLastAuth.compareTo(aLastAuth); } if (a.lastAuthDateTime != null) return -1; if (b.lastAuthDateTime != null) return 1; return 0; }); return items; } Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) { if (activities.isEmpty) return const SizedBox.shrink(); return LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth; // 화면 너비에 따른 컬럼 수 및 초기 표시 개수 결정 int crossAxisCount; if (maxWidth > 1200) { crossAxisCount = 4; } else if (maxWidth > 800) { crossAxisCount = 3; } else { crossAxisCount = 2; } // 초기 표시 개수는 한 줄에 표시되는 개수와 동일하게 설정 (요청에 따라 유동적 조절 가능) final int initialVisibleCount = crossAxisCount; final shouldShowToggle = activities.length > initialVisibleCount; List<_ActivityItem> visibleActivities; if (_showAllActivities) { visibleActivities = activities; } else { visibleActivities = activities.take(initialVisibleCount).toList(); } // 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려) const spacing = 12.0; final double cardWidth = (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: spacing, runSpacing: spacing, children: visibleActivities.map((item) { return SizedBox( width: cardWidth, child: _buildActivityCard(item, cardWidth: cardWidth), ); }).toList(), ), if (shouldShowToggle) Padding( padding: const EdgeInsets.only(top: 16), child: Align( alignment: Alignment.centerRight, child: TextButton.icon( onPressed: () => setState( () => _showAllActivities = !_showAllActivities, ), icon: Icon( _showAllActivities ? Icons.keyboard_arrow_up : Icons.add, size: 18, color: _showAllActivities ? Colors.grey : Colors.blueAccent, ), label: Text( _showAllActivities ? tr('ui.common.collapse') : tr('ui.common.show_more'), style: TextStyle( color: _showAllActivities ? Colors.grey : Colors.blueAccent, fontWeight: FontWeight.bold, ), ), ), ), ), ], ); }, ); } Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) { final isActive = item.status == 'active'; final statusColor = isActive ? Colors.green : Colors.grey; final borderColor = isActive ? Colors.green.withValues(alpha: 128) : _border; final borderWidth = isActive ? 1.5 : 1.0; // 활성 상태면 클릭 가능 (URL 유무와 관계없이) final isClickable = isActive; // 카드 컨텐츠 final cardContent = Container( width: cardWidth ?? 260, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: borderColor, width: borderWidth), boxShadow: isActive ? [ BoxShadow( color: Colors.green.withValues(alpha: 13), blurRadius: 10, offset: const Offset(0, 4), ), ] : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( item.appName, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: _ink, ), ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: statusColor, borderRadius: BorderRadius.circular(999), ), child: Text( item.status == 'active' ? tr('ui.userfront.dashboard.activity.linked') : tr('ui.userfront.dashboard.status.revoked'), style: const TextStyle( fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 12), Text( tr('ui.userfront.dashboard.last_auth_label'), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), const SizedBox(height: 4), Text( item.lastAuthAt, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: _ink, ), ), const SizedBox(height: 16), Row( children: [ Expanded( child: OutlinedButton( onPressed: () => _showRpDetails(item), style: OutlinedButton.styleFrom( foregroundColor: _ink, side: const BorderSide(color: _border), padding: const EdgeInsets.symmetric(vertical: 8), ), child: Text( tr('ui.common.details'), style: const TextStyle(fontSize: 13), ), ), ), const SizedBox(width: 8), Expanded( child: OutlinedButton( onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke, style: OutlinedButton.styleFrom( foregroundColor: item.isRevoked ? Colors.grey : Colors.redAccent, side: BorderSide( color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5, ), padding: const EdgeInsets.symmetric(vertical: 8), ), child: _isRevoking && !item.isRevoked ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.redAccent, ), ) : Text( item.isRevoked ? tr('ui.userfront.dashboard.status.revoked') : tr('ui.userfront.dashboard.revoke.title'), style: const TextStyle(fontSize: 13), ), ), ), ], ), ], ), ); // Opacity 적용 final opaqueCard = Opacity( opacity: item.isRevoked ? 0.6 : 1.0, child: cardContent, ); // 클릭 가능한 경우 InkWell로 감싸기 if (isClickable) { return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () async { final itemUrl = item.url; if (itemUrl != null && itemUrl.isNotEmpty) { final uri = Uri.parse(itemUrl); final canOpen = await canLaunchUrl(uri); if (!mounted) return; if (canOpen) { await launchUrl(uri); return; } ToastService.error(tr('msg.userfront.dashboard.link_open_error')); } else { if (!mounted) return; ToastService.info(tr('msg.userfront.dashboard.link_missing')); } }, child: opaqueCard, ), ); } return opaqueCard; } Widget _buildAccessHistory(AuthTimelineState state, bool isWide) { if (state.isLoading && state.items.isEmpty) { return _buildHistoryContainer( child: const Center(child: CircularProgressIndicator()), ); } if (state.error != null && state.items.isEmpty) { return _buildHistoryContainer( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(tr('msg.userfront.dashboard.audit_load_error')), const SizedBox(height: 8), TextButton( onPressed: () => ref.read(authTimelineProvider.notifier).refresh(), child: Text(tr('ui.common.retry')), ), ], ), ), ); } if (state.items.isEmpty) { return _buildHistoryContainer( child: Center( child: Text( tr('msg.userfront.dashboard.audit_empty'), style: TextStyle(color: Colors.grey[600]), ), ), ); } if (isWide) { return _buildHistoryTable(state); } return _buildHistoryList(state); } Widget _buildHistoryContainer({required Widget child}) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: _border), ), child: child, ); } Widget _buildHistoryTable(AuthTimelineState state) { return _buildHistoryContainer( child: Column( children: [ LayoutBuilder( builder: (context, constraints) { final sessionColumnWidth = _historySessionColumnWidth( constraints.maxWidth, ); return SingleChildScrollView( scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), child: DataTable( columnSpacing: 16, horizontalMargin: 12, columns: [ DataColumn( label: SizedBox( width: sessionColumnWidth, child: Text( tr( 'ui.userfront.audit.table.session_id', fallback: 'Session ID', ), ), ), ), DataColumn( label: Text(tr('ui.userfront.audit.table.date')), ), DataColumn( label: Text(tr('ui.userfront.audit.table.app')), ), DataColumn( label: Text( tr('ui.userfront.audit.table.ip', fallback: 'IP'), ), ), DataColumn( label: Text(tr('ui.userfront.audit.table.device')), ), DataColumn( label: Text(tr('ui.userfront.audit.table.auth_method')), ), DataColumn( label: Text(tr('ui.userfront.audit.table.result')), ), DataColumn( label: Text(tr('ui.userfront.audit.table.status')), ), ], rows: state.items.map((log) { final statusLabel = log.status == 'success' ? tr('ui.common.status.success') : tr('ui.common.status.failure'); 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( SizedBox( width: sessionColumnWidth, child: _buildHistorySessionIdCell( log.sessionId.isEmpty ? tr('ui.common.hyphen', fallback: '-') : log.sessionId, sessionColumnWidth, ), ), ), DataCell( _selectableText(_formatDateTime(log.timestamp)), ), DataCell(_buildAppCell(log)), 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( tr('ui.userfront.audit.table.pending'), style: const TextStyle(color: Colors.grey), ), ), ], ); }).toList(), ), ), ); }, ), _buildHistoryFooter(state), ], ), ); } double _historySessionColumnWidth(double maxWidth) { return math.min( 200.0, math.max( _historySessionMinWidth, maxWidth - _historyOtherColumnsBaselineWidth, ), ); } String _compactSessionId(String sessionId) { if (sessionId.length <= _historySessionMinVisibleChars) { return sessionId; } return '${sessionId.substring(0, _historySessionMinVisibleChars)}...'; } Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) { final compactMode = columnWidth <= _historySessionMinWidth + 0.5; final displayText = compactMode ? _compactSessionId(sessionId) : sessionId; final textWidget = Text( displayText, maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, ); if (displayText == sessionId) { return textWidget; } return Tooltip(message: sessionId, child: textWidget); } Widget _buildHistoryList(AuthTimelineState state) { return _buildHistoryContainer( child: Column( children: [ for (final log in state.items) Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: _subtle, borderRadius: BorderRadius.circular(12), border: Border.all(color: _border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: _buildAppCell( log, style: const TextStyle( fontWeight: FontWeight.w600, color: _ink, ), ), ), _selectableText( log.status == 'success' ? tr('ui.common.status.success') : tr('ui.common.status.failure'), style: TextStyle( color: log.status == 'success' ? Colors.green : Colors.redAccent, fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 6), _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', params: {'value': _formatDateTime(log.timestamp)}, ), ), _selectableText( tr( 'msg.userfront.audit.ip', params: { 'value': log.ipAddress.isEmpty ? tr('ui.common.hyphen', fallback: '-') : log.ipAddress, }, ), ), _selectableText( tr( 'msg.userfront.audit.device', params: { 'value': _deviceLabelFromUserAgent(log.userAgent), }, ), ), _buildAuthMethodLine( log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(), ), _selectableText( tr( 'msg.userfront.audit.result', params: { 'value': log.status == 'success' ? tr('ui.common.status.success') : tr('ui.common.status.failure'), }, ), ), _selectableText( tr('msg.userfront.audit.status'), style: TextStyle(color: Colors.grey[600]), ), ], ), ), _buildHistoryFooter(state), ], ), ); } Widget _buildHistoryFooter(AuthTimelineState state) { if (state.isLoadingMore) { return const Padding( padding: EdgeInsets.only(top: 8), child: Center(child: CircularProgressIndicator()), ); } if (state.error != null) { return Padding( padding: const EdgeInsets.only(top: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(tr('msg.userfront.audit.load_more_error')), TextButton( onPressed: () => ref.read(authTimelineProvider.notifier).loadMore(), child: Text(tr('ui.common.retry')), ), ], ), ); } final nextCursor = state.nextCursor; if (nextCursor == null || nextCursor.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 8), child: Text( tr('msg.userfront.audit.end'), style: TextStyle(color: Colors.grey[600], fontSize: 12), ), ); } return const SizedBox.shrink(); } bool _isLoggedIn() { final token = AuthTokenStore.getToken(); return (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie(); } void _redirectToSignin() { if (!mounted || _redirectingToSignin) { return; } _redirectingToSignin = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } Uri uri; try { uri = GoRouterState.of(context).uri; } catch (_) { uri = Uri.base; } final localeCode = extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode(); context.go('/$localeCode/signin'); _redirectingToSignin = false; }); } Future _bootstrapAuthAndLoad() async { if (!mounted || _authBootstrapInProgress) { return; } _authBootstrapInProgress = true; try { var authenticated = _isLoggedIn(); if (!authenticated) { authenticated = await _recoverSessionFromCookie(); } if (!mounted) { return; } if (!authenticated) { _redirectToSignin(); return; } await _loadAuditLogs(reset: true); } finally { _authBootstrapInProgress = false; } } Future _recoverSessionFromCookie() async { try { await AuthProxyService.checkCookieSession(); final provider = AuthTokenStore.getProvider() ?? AuthTokenStore.getPendingProvider() ?? 'ory'; AuthTokenStore.setCookieMode(provider: provider); AuthTokenStore.clearPendingProvider(); AuthNotifier.instance.notify(); try { await ref.read(profileProvider.notifier).loadProfile(); } catch (_) {} return true; } catch (_) { return false; } } } class _ActivityItem { final String clientId; final String appName; final String lastAuthAt; final String status; final String? url; final List scopes; final bool isRevoked; final VoidCallback? onRevoke; final DateTime? lastAuthDateTime; _ActivityItem({ required this.clientId, required this.appName, required this.lastAuthAt, required this.status, required this.scopes, this.url, this.isRevoked = false, this.onRevoke, this.lastAuthDateTime, }); }