import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.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 '../domain/linked_rp_launch.dart'; import '../domain/session_time_resolver.dart'; import '../domain/providers/linked_rps_provider.dart'; import '../domain/providers/user_sessions_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/logout_service.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/widgets/language_selector.dart'; import '../../../../core/widgets/theme_toggle_button.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 'audit_device_utils.dart'; import 'package:userfront/i18n.dart'; class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @override ConsumerState createState() => _DashboardScreenState(); } class _DashboardScreenState extends ConsumerState { static const double _dashboardCardSpacing = 12; static const double _dashboardCardMaxWidth = 228; static const double _activityDialogMaxWidth = 360; static const double _historySessionMinWidth = 92; static const double _historyOtherColumnsBaselineWidth = 780; static const int _historySessionMinVisibleChars = 8; static const double _historyDateColumnWidth = 132; static const double _historyAppColumnWidth = 132; static const double _historyIpColumnWidth = 118; static const double _historyDeviceColumnWidth = 128; static const double _historyBrowserColumnWidth = 112; static const double _historyAuthMethodColumnWidth = 108; static const double _historyResultColumnWidth = 88; static const double _historyStatusColumnWidth = 92; static const double _historyActionColumnWidth = 108; final ScrollController _pageScrollController = ScrollController(); final ScrollController _rpScrollController = ScrollController(); bool _isRevoking = false; String? _revokingSessionId; bool _redirectingToSignin = false; bool _authBootstrapInProgress = false; bool _showAllActivities = false; bool _showActiveSessionsOnly = false; bool _isDesktopSideMenuOpen = true; final Set _revokedClientIds = {}; Color get _ink => Theme.of(context).colorScheme.onSurface; Color get _surface => Theme.of(context).colorScheme.surface; Color get _border => Theme.of(context).colorScheme.outlineVariant; Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest; String _renderTranslatedText( String key, { String? fallback, Map values = const {}, }) { var text = tr(key, fallback: fallback); values.forEach((name, value) { text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value); }); return text; } @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 { await LogoutService().logout(); } 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); } } } Future _onRevokeSession(UserSessionSummary session) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')), content: Text( _renderTranslatedText( 'msg.userfront.dashboard.sessions.revoke.confirm', values: { 'target': session.isCurrent ? tr('ui.userfront.dashboard.sessions.current_badge') : _sessionDisplayLabel(session), }, ), ), 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.sessions.revoke.action')), ), ], ), ); if (confirmed != true) { return; } setState(() => _revokingSessionId = session.sessionId); try { await ref .read(userSessionsProvider.notifier) .revokeSession(session.sessionId); if (!mounted) { return; } ToastService.success( tr('msg.userfront.dashboard.sessions.revoke.success'), ); } catch (e) { if (!mounted) { return; } ToastService.error( tr( 'msg.userfront.dashboard.sessions.revoke.error', params: {'error': '$e'}, ), ); } finally { if (mounted) { setState(() => _revokingSessionId = null); } } } 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, _) { final dialogWidth = math.min( MediaQuery.sizeOf(context).width - 48, _activityDialogMaxWidth, ); final statusLabel = item.status == 'active' ? tr('ui.userfront.dashboard.activity.linked') : tr('ui.userfront.dashboard.status.revoked'); final statusColor = _activityStatusColor(item.status); return AlertDialog( backgroundColor: _surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), insetPadding: const EdgeInsets.symmetric( horizontal: 24, vertical: 24, ), contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 8), content: SizedBox( width: dialogWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _subtle, borderRadius: BorderRadius.circular(18), border: Border.all(color: _border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.appName, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: _ink, ), ), const SizedBox(height: 4), Text( tr('ui.common.details'), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey[600], ), ), ], ), ), const SizedBox(height: 16), _buildActivityDetailSection( title: tr('ui.userfront.dashboard.status_history'), child: Row( children: [ Expanded( child: _buildActivityDetailField( label: tr( 'ui.userfront.dashboard.link_status_label', ), value: statusLabel, valueColor: statusColor, ), ), const SizedBox(width: 10), Expanded( child: _buildActivityDetailField( label: tr('ui.userfront.dashboard.last_auth_label'), value: item.lastAuthAt, ), ), ], ), ), const SizedBox(height: 12), _buildActivityDetailSection( title: tr('ui.userfront.dashboard.scopes.title'), child: item.scopes.isEmpty ? Text( tr('msg.userfront.dashboard.scopes.empty'), style: TextStyle( fontSize: 13, color: Colors.grey[600], ), ) : Wrap( spacing: 8, runSpacing: 8, children: item.scopes .map( (scope) => Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), decoration: BoxDecoration( color: _subtle, borderRadius: BorderRadius.circular(12), border: Border.all(color: _border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.shield_outlined, size: 14, color: _ink, ), const SizedBox(width: 6), Text( scope, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: _ink, ), ), ], ), ), ) .toList(), ), ), ], ), ), actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), actions: [ SizedBox( width: double.infinity, child: TextButton( onPressed: () => Navigator.of(context).pop(), style: TextButton.styleFrom( foregroundColor: _ink, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), backgroundColor: _subtle, ), child: Text( tr('ui.common.close'), style: const TextStyle(fontWeight: FontWeight.w600), ), ), ), ], ); }, ), ); } Widget _buildActivityDetailSection({ required String title, required Widget child, }) { return Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: _surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: _border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: _ink, ), ), const SizedBox(height: 10), child, ], ), ); } Widget _buildActivityDetailField({ required String label, required String value, Color? valueColor, }) { return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: _subtle, borderRadius: BorderRadius.circular(14), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Colors.grey[600], ), ), const SizedBox(height: 6), Text( value, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: valueColor ?? _ink, ), ), ], ), ); } 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: Column( mainAxisSize: MainAxisSize.min, children: [ ThemeToggleButton(), SizedBox(height: 8), 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); ref.invalidate(userSessionsProvider); await Future.wait([ ref.read(linkedRpsProvider.future), ref.read(userSessionsProvider.future), ref.read(authTimelineProvider.notifier).refresh(), ]); await ref.read(linkedRpsProvider.notifier).refresh(); } 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); } Widget _singleLineText(String text, {TextStyle? style}) { return Text( text, style: style, maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ); } 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: '-'); } if (userAgent == headlessServerUserAgentSentinel) { return 'Headless(Server)'; } 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') : _renderTranslatedText( 'msg.userfront.dashboard.client_id', fallback: 'Client ID: {{id}}', values: {'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.trim().isNotEmpty ?? false) ? profile!.name : (profile?.email.trim().isNotEmpty ?? false) ? profile!.email : (profile?.phone.trim().isNotEmpty ?? false) ? 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( leading: isWide ? IconButton( icon: Icon( _isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu, ), tooltip: _isDesktopSideMenuOpen ? tr('ui.common.collapse') : '펼치기', onPressed: () { setState(() { _isDesktopSideMenuOpen = !_isDesktopSideMenuOpen; }); }, ) : Builder( builder: (context) => IconButton( icon: const Icon(Icons.menu), tooltip: MaterialLocalizations.of( context, ).openAppDrawerTooltip, onPressed: () => Scaffold.of(context).openDrawer(), ), ), title: Text( tr('ui.userfront.app_title'), style: const TextStyle(fontWeight: FontWeight.bold), ), actions: [ const ThemeToggleButton(compact: true), 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 && _isDesktopSideMenuOpen) 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( _renderTranslatedText( 'msg.userfront.greeting', fallback: 'Hello, {{name}}.', values: {'name': userName}, ), style: 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: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: _ink, ), ), const SizedBox(width: 12), Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])), ], ); } String _sessionDisplayLabel(UserSessionSummary session) { if (session.userAgent.trim().isNotEmpty) { return _sessionUserAgentLabel(session.userAgent); } return tr('ui.userfront.dashboard.sessions.unknown_device'); } String _sessionUserAgentLabel(String userAgent) { final lower = userAgent.toLowerCase(); if (lower.isEmpty) { return tr('ui.userfront.dashboard.sessions.unknown_device'); } if (_looksLikeInternalUserAgent(lower)) { return ''; } if (lower.contains('iphone') || lower.contains('ios')) { return tr('ui.userfront.device.ios'); } if (lower.contains('android')) { return tr('ui.userfront.device.android'); } if (lower.contains('windows')) { return tr('ui.userfront.device.windows', fallback: 'Desktop(Windows)'); } if (lower.contains('mac os') || lower.contains('macintosh')) { return tr('ui.userfront.device.macos', fallback: 'Desktop(macOS)'); } if (lower.contains('linux')) { return tr('ui.userfront.device.linux'); } return userAgent; } String _sessionBrowserLabel(String userAgent) { if (userAgent == headlessServerUserAgentSentinel) { return ''; } final lower = userAgent.toLowerCase(); if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) { return ''; } if (lower.contains('edg/')) { return 'Edge'; } if (lower.contains('chrome/') && !lower.contains('edg/')) { return 'Chrome'; } if (lower.contains('firefox/')) { return 'Firefox'; } if (lower.contains('safari/') && !lower.contains('chrome/')) { return 'Safari'; } if (lower.contains('samsungbrowser/')) { return 'Samsung Internet'; } if (lower.contains('flutter')) { return 'Flutter'; } return ''; } bool _looksLikeInternalUserAgent(String userAgent) { return userAgent.startsWith('go-http-client/') || userAgent.startsWith('fasthttp') || userAgent.startsWith('fiber'); } 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: 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, logo: rp.logo.trim(), lastAuthAt: lastAuthLabel, status: statusCode, scopes: rp.scopes, isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), url: rp.url, launchUrl: resolveLinkedRpLaunchUrl(rp), autoLoginSupported: rp.autoLoginSupported, 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; final crossAxisCount = _dashboardCardColumnCount(maxWidth); // 초기 표시 개수는 한 줄에 표시되는 개수와 동일하게 설정 (요청에 따라 유동적 조절 가능) final int initialVisibleCount = crossAxisCount; final shouldShowToggle = activities.length > initialVisibleCount; List<_ActivityItem> visibleActivities; if (_showAllActivities) { visibleActivities = activities; } else { visibleActivities = activities.take(initialVisibleCount).toList(); } final cardWidth = _dashboardCardWidth(maxWidth, crossAxisCount); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: _dashboardCardSpacing, runSpacing: _dashboardCardSpacing, 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 = _activityStatusColor(item.status); 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(14), decoration: BoxDecoration( color: _surface, borderRadius: BorderRadius.circular(12), 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: [ _buildActivityCardHeader(item, statusColor), const SizedBox(height: 10), Text( tr('ui.userfront.dashboard.last_auth_label'), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), const SizedBox(height: 4), Text( item.lastAuthAt, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: _ink, ), ), if (item.autoLoginSupported) ...[ const SizedBox(height: 8), Text( tr( 'msg.userfront.dashboard.auto_login_supported', fallback: '연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다.', ), style: TextStyle(fontSize: 12, color: Colors.green[700]), ), ], const SizedBox(height: 14), Row( children: [ Expanded( child: OutlinedButton( onPressed: () => _showRpDetails(item), style: OutlinedButton.styleFrom( foregroundColor: _ink, side: BorderSide(color: _border), padding: const EdgeInsets.symmetric(vertical: 7), ), 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: 7), ), 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.launchUrl; 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 _buildActivityCardHeader(_ActivityItem item, Color statusColor) { final statusBadge = 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, ), ), ); return SizedBox( height: 40, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ if (item.logo.isNotEmpty) ...[ _buildActivityLogo(item.logo), const SizedBox(width: 10), ], Expanded( child: Align( alignment: Alignment.centerLeft, child: Text( item.appName, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: _ink, height: 1.25, ), ), ), ), const SizedBox(width: 8), statusBadge, ], ), ); } Widget _buildActivityLogo(String logoUrl) { return SizedBox( width: 40, height: 40, child: _buildActivityLogoImage(logoUrl), ); } Widget _buildActivityLogoImage(String logoUrl) { final isSvg = _isSvgLogoUrl(logoUrl); return isSvg ? SvgPicture.network( logoUrl, fit: BoxFit.contain, placeholderBuilder: (context) => _buildActivityLogoLoading(), ) : Image.network( logoUrl, fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) { return _buildActivityLogoFallback(); }, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) { return child; } return _buildActivityLogoLoading(); }, ); } bool _isSvgLogoUrl(String logoUrl) { final normalized = logoUrl.trim().toLowerCase(); if (normalized.isEmpty) { return false; } final uri = Uri.tryParse(normalized); final path = uri?.path.toLowerCase() ?? normalized; return path.endsWith('.svg'); } Widget _buildActivityLogoLoading() { return Center( child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.grey[400], ), ), ); } Widget _buildActivityLogoFallback() { return Icon(Icons.apps_rounded, size: 20, color: Colors.grey[500]); } Widget _buildAccessHistory(AuthTimelineState state, bool isWide) { final sessionsState = ref.watch(userSessionsProvider); if (state.isLoading && state.items.isEmpty) { return _buildHistoryContainer( child: const SizedBox( height: 120, child: Center(child: CircularProgressIndicator()), ), ); } if (state.error != null && state.items.isEmpty) { return _buildHistoryContainer( child: SizedBox( height: 120, 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 (sessionsState.isLoading && !sessionsState.hasValue) { return _buildHistoryContainer( child: const SizedBox( height: 120, child: Center(child: CircularProgressIndicator()), ), ); } if (sessionsState.hasError && !sessionsState.hasValue) { return _buildHistoryContainer( child: SizedBox( height: 120, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(tr('msg.userfront.dashboard.sessions.error')), const SizedBox(height: 8), TextButton( onPressed: () => ref.read(userSessionsProvider.notifier).refresh(), child: Text(tr('ui.common.retry')), ), ], ), ), ), ); } final sessions = sessionsState is AsyncData> ? sessionsState.value : const []; final Map sessionById = { for (final session in sessions) session.sessionId.trim(): session, }; final filteredItems = state.items.where((log) { if (!_showActiveSessionsOnly) { return true; } final status = _historySessionStatusForLog(log, sessionById); return status != _HistorySessionStatus.inactive; }).toList(); if (filteredItems.isEmpty) { return _buildHistoryContainer( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildHistoryHeader(), const SizedBox(height: 20), Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Text( _showActiveSessionsOnly ? tr('msg.userfront.audit.filtered_empty') : tr('msg.userfront.dashboard.audit_empty'), style: TextStyle(color: Colors.grey[600]), textAlign: TextAlign.center, ), ), ), ], ), ); } if (isWide) { return _buildHistoryTable(state, filteredItems, sessionById); } return _buildHistoryList(state, filteredItems, sessionById); } Widget _buildHistoryHeader() { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr('ui.userfront.audit.filter.title'), style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: _ink, ), ), const SizedBox(height: 4), Text( tr('msg.userfront.audit.filter.description'), style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ), ), const SizedBox(width: 16), Row( mainAxisSize: MainAxisSize.min, children: [ Text( tr('ui.userfront.audit.filter.toggle_label'), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: _ink, ), ), const SizedBox(width: 2), Transform.scale( scale: 0.84, alignment: Alignment.centerRight, child: Switch( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, value: _showActiveSessionsOnly, onChanged: (value) { setState(() { _showActiveSessionsOnly = value; }); }, ), ), ], ), ], ); } 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, ); } _HistorySessionStatus _historySessionStatusForLog( AuditLogEntry log, Map sessionById, ) { final sessionId = log.sessionId.trim(); if (sessionId.isEmpty) { return _HistorySessionStatus.inactive; } final session = sessionById[sessionId]; if (session == null) { return _HistorySessionStatus.inactive; } if (session.isCurrent) { return _HistorySessionStatus.current; } if (session.isActive) { return _HistorySessionStatus.active; } return _HistorySessionStatus.inactive; } String _historySessionStatusLabel(_HistorySessionStatus status) { switch (status) { case _HistorySessionStatus.current: return tr('ui.userfront.dashboard.sessions.current_badge'); case _HistorySessionStatus.active: return tr('ui.userfront.dashboard.sessions.active_badge'); case _HistorySessionStatus.inactive: return tr('ui.common.status.inactive'); } } Color _historySessionStatusColor(_HistorySessionStatus status) { switch (status) { case _HistorySessionStatus.current: return Colors.blueGrey; case _HistorySessionStatus.active: return Colors.green; case _HistorySessionStatus.inactive: return Colors.grey; } } Widget _buildHistoryStatusBadge(_HistorySessionStatus status) { return SizedBox( width: _historyStatusColumnWidth, child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: _historySessionStatusColor(status), borderRadius: BorderRadius.circular(999), ), child: Text( _historySessionStatusLabel(status), style: const TextStyle( fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600, ), ), ), ), ); } Widget _buildHistorySessionActionCell(UserSessionSummary? session) { if (session == null) { return SizedBox( width: _historyActionColumnWidth, child: Center( child: _selectableText(tr('ui.common.hyphen', fallback: '-')), ), ); } final isCurrent = session.isCurrent; final canRevoke = !isCurrent && _revokingSessionId == null && session.isActive; return SizedBox( width: _historyActionColumnWidth, child: OutlinedButton( onPressed: canRevoke ? () => _onRevokeSession(session) : null, style: OutlinedButton.styleFrom( foregroundColor: canRevoke ? Colors.redAccent : Colors.grey, side: BorderSide( color: canRevoke ? Colors.redAccent : Colors.grey, width: 0.6, ), padding: const EdgeInsets.symmetric(vertical: 10), ), child: _revokingSessionId == session.sessionId ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.redAccent, ), ) : Text( isCurrent ? tr('ui.userfront.dashboard.sessions.current_disabled') : session.isActive ? tr('ui.userfront.dashboard.sessions.revoke.action') : tr('ui.common.hyphen', fallback: '-'), ), ), ); } int _dashboardCardColumnCount(double maxWidth) { if (maxWidth > 1200) { return 4; } if (maxWidth > 800) { return 3; } return 2; } double _dashboardCardWidth(double maxWidth, int crossAxisCount) { return math.min( (maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) / crossAxisCount, _dashboardCardMaxWidth, ); } Color _activityStatusColor(String status) { return status == 'active' ? Colors.green : Colors.grey; } Widget _buildCenteredHistoryHeader(String label, {double? width}) { return SizedBox( width: width, child: Center(child: Text(label, textAlign: TextAlign.center)), ); } Widget _buildCenteredHistoryCell(Widget child, {double? width}) { return SizedBox( width: width, child: Center(child: child), ); } Widget _buildHistoryTable( AuthTimelineState state, List items, Map sessionById, ) { return _buildHistoryContainer( child: Column( children: [ _buildHistoryHeader(), const SizedBox(height: 16), 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: _buildCenteredHistoryHeader( tr( 'ui.userfront.audit.table.session_id', fallback: 'Session ID', ), width: sessionColumnWidth, ), ), DataColumn( label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.date'), width: _historyDateColumnWidth, ), ), DataColumn( label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.app'), width: _historyAppColumnWidth, ), ), DataColumn( label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.ip', fallback: 'IP'), width: _historyIpColumnWidth, ), ), DataColumn( label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.device'), width: _historyDeviceColumnWidth, ), ), DataColumn( label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.browser'), width: _historyBrowserColumnWidth, ), ), DataColumn( label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.auth_method'), width: _historyAuthMethodColumnWidth, ), ), DataColumn( label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.result'), width: _historyResultColumnWidth, ), ), DataColumn( label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.status'), width: _historyStatusColumnWidth, ), ), DataColumn( label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.action'), width: _historyActionColumnWidth, ), ), ], rows: items.map((log) { final matchedSession = sessionById[log.sessionId.trim()]; final sessionStatus = _historySessionStatusForLog( log, sessionById, ); 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 preferredUserAgent = preferredAuditLogUserAgent( log, ); final deviceLabel = _deviceLabelFromUserAgent( preferredUserAgent, ); final browserLabel = _sessionBrowserLabel( preferredUserAgent, ); return DataRow( cells: [ DataCell( _buildCenteredHistoryCell( _buildHistorySessionIdCell( log.sessionId.isEmpty ? tr('ui.common.hyphen', fallback: '-') : log.sessionId, sessionColumnWidth, ), width: sessionColumnWidth, ), ), DataCell( _buildCenteredHistoryCell( _selectableText(_formatDateTime(log.timestamp)), width: _historyDateColumnWidth, ), ), DataCell( _buildCenteredHistoryCell( _buildAppCell(log), width: _historyAppColumnWidth, ), ), DataCell( _buildCenteredHistoryCell( _selectableText( log.ipAddress.isEmpty ? tr('ui.common.hyphen', fallback: '-') : log.ipAddress, ), width: _historyIpColumnWidth, ), ), DataCell( _buildCenteredHistoryCell( _singleLineText(deviceLabel), width: _historyDeviceColumnWidth, ), ), DataCell( _buildCenteredHistoryCell( _selectableText( browserLabel.isEmpty ? tr('ui.common.hyphen', fallback: '-') : browserLabel, ), width: _historyBrowserColumnWidth, ), ), DataCell( _buildCenteredHistoryCell( _buildAuthMethodCell(log, authMethod), width: _historyAuthMethodColumnWidth, ), ), DataCell( _buildCenteredHistoryCell( _selectableText( statusLabel, style: TextStyle( color: statusColor, fontWeight: FontWeight.w600, ), ), width: _historyResultColumnWidth, ), ), DataCell( _buildCenteredHistoryCell( _buildHistoryStatusBadge(sessionStatus), width: _historyStatusColumnWidth, ), ), DataCell( _buildCenteredHistoryCell( _buildHistorySessionActionCell(matchedSession), width: _historyActionColumnWidth, ), ), ], ); }).toList(), ), ), ); }, ), _buildHistoryFooter(state), ], ), ); } double _historySessionColumnWidth(double maxWidth) { return math.min( 200.0, math.max( _historySessionMinWidth, maxWidth - _historyOtherColumnsBaselineWidth, ), ); } String _compactSessionId(String sessionId) { final parts = sessionId.split('-'); if (parts.length >= 4) { return '${parts.take(3).join('-')}-...'; } if (sessionId.length <= _historySessionMinVisibleChars) { return sessionId; } return '${sessionId.substring(0, _historySessionMinVisibleChars)}...'; } Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) { final displayText = _compactSessionId(sessionId); final textWidget = Text( displayText, maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ); if (displayText == sessionId || sessionId.isEmpty) { return textWidget; } return Tooltip(message: sessionId, child: textWidget); } Widget _buildHistoryList( AuthTimelineState state, List items, Map sessionById, ) { return _buildHistoryContainer( child: Column( children: [ _buildHistoryHeader(), const SizedBox(height: 16), for (final log in 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: [ _buildHistoryStatusBadge( _historySessionStatusForLog(log, sessionById), ), const Spacer(), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: _buildAppCell( log, style: 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( _renderTranslatedText( 'msg.userfront.audit.session_id', fallback: 'Session ID: {{value}}', values: { '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( preferredAuditLogUserAgent(log), ), }, ), ), _selectableText( tr( 'msg.userfront.audit.browser', params: { 'value': _sessionBrowserLabel( preferredAuditLogUserAgent(log), ).isEmpty ? tr('ui.common.hyphen', fallback: '-') : _sessionBrowserLabel( preferredAuditLogUserAgent(log), ), }, ), ), _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', params: { 'value': _historySessionStatusLabel( _historySessionStatusForLog(log, sessionById), ), }, ), ), const SizedBox(height: 12), _buildHistorySessionActionCell( sessionById[log.sessionId.trim()], ), ], ), ), _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; } } 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; } } } enum _HistorySessionStatus { current, active, inactive } class _ActivityItem { final String clientId; final String appName; final String logo; final String lastAuthAt; final String status; final String? url; final String? launchUrl; final bool autoLoginSupported; final List scopes; final bool isRevoked; final VoidCallback? onRevoke; final DateTime? lastAuthDateTime; _ActivityItem({ required this.clientId, required this.appName, required this.logo, required this.lastAuthAt, required this.status, required this.scopes, this.url, this.launchUrl, this.autoLoginSupported = false, this.isRevoked = false, this.onRevoke, this.lastAuthDateTime, }); }