import 'dart:convert'; 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:flutter_dotenv/flutter_dotenv.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; class AuditLogEntry { final String eventId; final DateTime timestamp; final String userId; final String eventType; final String status; final String authMethod; final String ipAddress; final String userAgent; final String sessionId; final String details; final String source; final String clientId; final String appName; final String parentSessionId; AuditLogEntry({ required this.eventId, required this.timestamp, required this.userId, required this.eventType, required this.status, required this.authMethod, required this.ipAddress, required this.userAgent, required this.sessionId, required this.details, required this.source, required this.clientId, required this.appName, required this.parentSessionId, }); factory AuditLogEntry.fromJson(Map json) { final timestampRaw = json['timestamp']?.toString() ?? ''; DateTime parsedTimestamp; try { parsedTimestamp = DateTime.parse(timestampRaw).toLocal(); } catch (_) { parsedTimestamp = DateTime.now(); } return AuditLogEntry( eventId: json['event_id'] ?? '', timestamp: parsedTimestamp, userId: json['user_id'] ?? '', eventType: json['event_type'] ?? '', status: json['status'] ?? '', authMethod: json['auth_method'] ?? '', ipAddress: json['ip_address'] ?? '', userAgent: json['user_agent'] ?? '', sessionId: json['session_id'] ?? '', details: json['details'] ?? '', source: json['source'] ?? '', clientId: json['client_id'] ?? '', appName: json['app_name'] ?? '', parentSessionId: json['parent_session_id'] ?? '', ); } Map get detailMap { if (details.isEmpty) { return {}; } try { return jsonDecode(details) as Map; } catch (_) { return {}; } } String get path { final detailPath = detailMap['path']?.toString(); if (detailPath != null && detailPath.isNotEmpty) { return detailPath; } final parts = eventType.split(' '); if (parts.length >= 2) { return parts.sublist(1).join(' '); } return '-'; } } class _AuditPage { final List items; final String? nextCursor; const _AuditPage({required this.items, this.nextCursor}); } class LinkedRp { final String id; final String name; final String logo; final String url; final String status; final List scopes; final DateTime? lastAuthenticatedAt; LinkedRp({ required this.id, required this.name, required this.logo, required this.url, required this.status, required this.scopes, required this.lastAuthenticatedAt, }); factory LinkedRp.fromJson(Map json) { final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? ''; DateTime? parsedLastAuth; if (rawLastAuth.isNotEmpty) { try { parsedLastAuth = DateTime.parse(rawLastAuth).toLocal(); } catch (_) { parsedLastAuth = null; } } return LinkedRp( id: json['id']?.toString() ?? '', name: json['name']?.toString() ?? '', logo: json['logo']?.toString() ?? '', url: json['url']?.toString() ?? '', status: json['status']?.toString() ?? '', scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], lastAuthenticatedAt: parsedLastAuth, ); } } class RpHistoryItem { final String clientId; final String clientName; final List scopes; final DateTime? lastApprovedAt; final DateTime? lastRevokedAt; final String status; RpHistoryItem({ required this.clientId, required this.clientName, required this.scopes, this.lastApprovedAt, this.lastRevokedAt, required this.status, }); factory RpHistoryItem.fromJson(Map json) { DateTime? parseDate(String? raw) { if (raw == null || raw.isEmpty) return null; try { return DateTime.parse(raw).toLocal(); } catch (_) { return null; } } return RpHistoryItem( clientId: json['client_id']?.toString() ?? '', clientName: json['client_name']?.toString() ?? '', scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], lastApprovedAt: parseDate(json['last_approved_at']?.toString()), lastRevokedAt: parseDate(json['last_revoked_at']?.toString()), status: json['status']?.toString() ?? 'unknown', ); } } 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); final ScrollController _pageScrollController = ScrollController(); final List _auditLogs = []; String? _auditNextCursor; bool _auditLoading = false; bool _auditLoadingMore = false; String? _auditError; bool _isRevoking = false; Future>? _linkedRpsFuture; Future>? _rpHistoryFuture; bool _showAllActivities = false; final Set _revokedClientIds = {}; @override void initState() { super.initState(); _pageScrollController.addListener(_onPageScroll); _loadAuditLogs(reset: true); _linkedRpsFuture = _fetchLinkedRps(); _rpHistoryFuture = _fetchRpHistory(); } @override void dispose() { _pageScrollController.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: const Text('연동 해지'), content: Text('$appName 앱과의 연동을 해지하시겠습니까?\n해지하면 다음 로그인 시 다시 동의가 필요합니다.'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('취소'), ), TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('해지하기'), ), ], ), ); if (confirmed != true) return; setState(() => _isRevoking = true); try { await AuthProxyService.revokeLinkedRp(clientId); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('$appName 연동이 해지되었습니다.')), ); setState(() { _revokedClientIds.add(clientId); }); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('해지 실패: $e')), ); } } finally { if (mounted) { setState(() => _isRevoking = false); } } } void _onScanQR() { context.push('/scan'); } void _onPageScroll() { if (!_pageScrollController.hasClients) { return; } if (_pageScrollController.position.extentAfter < 240) { _loadAuditLogs(); } } void _showRpDetails(_ActivityItem item) { showDialog( context: context, builder: (context) => AlertDialog( title: Text(item.appName), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('권한 (Scopes)', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), if (item.scopes.isEmpty) const Text('요청된 권한이 없습니다.', style: 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), const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), FutureBuilder>( future: _rpHistoryFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const SizedBox(height: 20, child: LinearProgressIndicator()); } if (snapshot.hasError || !snapshot.hasData) { return const Text('이력을 불러올 수 없습니다.', style: TextStyle(color: Colors.grey)); } final history = snapshot.data!.where((h) => h.clientId == item.clientId).toList(); if (history.isEmpty) { // Fallback to item data if no history found (e.g. fresh login) return Text('최근 인증: ${item.lastAuthAt}'); } final h = history.first; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (h.lastApprovedAt != null) Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( children: [ const Icon(Icons.check_circle_outline, size: 16, color: Colors.green), const SizedBox(width: 8), Text('승인: ${_formatDateTime(h.lastApprovedAt!)}'), ], ), ), if (h.lastRevokedAt != null) Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( children: [ const Icon(Icons.cancel_outlined, size: 16, color: Colors.redAccent), const SizedBox(width: 8), Text('해지: ${_formatDateTime(h.lastRevokedAt!)}'), ], ), ), const SizedBox(height: 4), Text('현재 상태: ${h.status == 'active' ? '활성' : '해지됨'}', style: TextStyle(color: h.status == 'active' ? Colors.green : Colors.grey)), ], ); }, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('닫기'), ), ], ), ); } Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) { return SafeArea( child: ListView( padding: const EdgeInsets.symmetric(vertical: 12), children: [ ListTile( leading: const Icon(Icons.home_outlined), title: const Text('대시보드'), selected: true, onTap: () { if (closeOnTap) { Navigator.of(context).pop(); } context.go('/'); }, ), ListTile( leading: const Icon(Icons.person_outline), title: const Text('내 정보'), onTap: () { if (closeOnTap) { Navigator.of(context).pop(); } context.push('/profile'); }, ), ListTile( leading: const Icon(Icons.qr_code_scanner), title: const Text('QR 스캔'), onTap: () { if (closeOnTap) { Navigator.of(context).pop(); } _onScanQR(); }, ), const Divider(), ListTile( leading: const Icon(Icons.logout), title: const Text('로그아웃'), onTap: () async { if (closeOnTap) { Navigator.of(context).pop(); } await _logout(); }, ), ], ), ); } Future _refreshAll() async { await ref.read(profileProvider.notifier).loadProfile(); await _loadAuditLogs(reset: true); setState(() { _revokedClientIds.clear(); _linkedRpsFuture = _fetchLinkedRps(); }); if (_linkedRpsFuture != null) { await _linkedRpsFuture; } } static String _envOrDefault(String key, String fallback) { if (!dotenv.isInitialized) { return fallback; } return dotenv.env[key] ?? fallback; } Future<_AuditPage> _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'; } final response = await client.get(url, headers: headers); client.close(); 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); } Future _loadAuditLogs({bool reset = false}) async { if (_auditLoading || _auditLoadingMore) { return; } if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) { return; } if (reset) { setState(() { _auditLogs.clear(); _auditNextCursor = null; _auditError = null; _auditLoading = true; }); } else { setState(() { _auditLoadingMore = true; }); } try { final page = await _fetchAuditLogs(cursor: _auditNextCursor); setState(() { _auditLogs.addAll(page.items); _auditNextCursor = page.nextCursor; _auditError = null; }); } catch (_) { setState(() { _auditError = '접속이력을 불러오지 못했습니다.'; }); } finally { setState(() { _auditLoading = false; _auditLoadingMore = false; }); } } Future> _fetchLinkedRps() async { final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); final url = Uri.parse('$baseUrl/api/v1/user/rp/linked'); 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'; } final response = await client.get(url, headers: headers); client.close(); if (response.statusCode != 200) { throw Exception('Failed to load linked rps'); } final body = jsonDecode(response.body) as Map; final items = (body['items'] as List?) ?? []; final linkedRps = items .whereType>() .map(LinkedRp.fromJson) .toList(); return linkedRps; } Future> _fetchRpHistory() async { final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); final url = Uri.parse('$baseUrl/api/v1/user/rp/history'); 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'; } final response = await client.get(url, headers: headers); client.close(); if (response.statusCode != 200) { throw Exception('Failed to load rp history'); } final body = jsonDecode(response.body) as Map; final items = (body['items'] as List?) ?? []; final history = items .whereType>() .map(RpHistoryItem.fromJson) .toList(); return history; } DateTime? _getJwtIssuedAt() { final token = AuthTokenStore.getToken(); if (token == null || token.isEmpty) { return null; } try { final parts = token.split('.'); if (parts.length != 3) { return null; } final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); final data = json.decode(payload) as Map; final iatValue = data['iat'] ?? data['auth_time']; if (iatValue is num) { return DateTime.fromMillisecondsSinceEpoch(iatValue.toInt() * 1000).toLocal(); } } catch (_) { return null; } return null; } 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 'Ory 세션'; } final provider = AuthTokenStore.getProvider(); if (provider == null || provider.isEmpty) { return '세션'; } final lower = provider.toLowerCase(); if (lower.contains('ory')) { return 'Ory 세션'; } return provider; } String _deviceLabelFromUserAgent(String userAgent) { if (userAgent.isEmpty) { return '-'; } final ua = userAgent.toLowerCase(); if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) { return 'Mobile(iOS)'; } if (ua.contains('android')) { return 'Mobile(Android)'; } if (ua.contains('windows')) { return 'Desktop(Windows)'; } if (ua.contains('mac os x') || ua.contains('macintosh')) { return 'Desktop(macOS)'; } if (ua.contains('linux')) { return 'Desktop(Linux)'; } return '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 = [ '승인 기기: $deviceLabel', '승인 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 ? '승인한 Userfront 세션 ID' : '승인한 세션 ID'; final tooltip = approvedSessionId.isEmpty ? '$tooltipLabel 없음' : '$tooltipLabel: $approvedSessionId\n클릭하면 복사됩니다.'; return InkWell( onTap: approvedSessionId.isEmpty ? null : () async { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('세션 ID가 복사되었습니다.')), ); } }, child: Tooltip( message: tooltip, child: Text( isOidc ? authMethod : '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('인증수단: $authMethod'); } final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent); final tooltip = [ '승인 기기: $deviceLabel', '승인 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 ? '승인한 Userfront 세션 ID' : '승인한 세션 ID'; return InkWell( onTap: approvedSessionId.isEmpty ? null : () async { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('세션 ID가 복사되었습니다.')), ); } }, child: Tooltip( message: approvedSessionId.isEmpty ? '$tooltipLabel 없음' : '$tooltipLabel: $approvedSessionId\n탭하면 복사됩니다.', child: Text( '인증수단: ${isOidc ? authMethod : '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 ? 'Client ID 없음' : 'Client 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 'Baron 통합로그인'; } if (path.startsWith('/api/v1/user')) { return 'Baron 통합로그인'; } if (path.startsWith('/api/v1/dev')) { return 'Dev Console'; } if (path.startsWith('/api/v1/admin')) { return 'Admin Console'; } return 'Baron 통합로그인'; } @override Widget build(BuildContext context) { final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; final profileState = ref.watch(profileProvider); final profile = profileState.value; final userName = profile?.name ?? profile?.email ?? profile?.phone ?? 'User'; final department = profile?.department.isNotEmpty == true ? profile!.department : '소속 정보 없음'; final sessionIssuedAt = _getJwtIssuedAt(); return Scaffold( backgroundColor: _subtle, appBar: AppBar( title: Text( 'Baron 통합로그인', style: TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, backgroundColor: _surface, foregroundColor: Colors.black, actions: [ IconButton( icon: const Icon(Icons.person_outline), tooltip: '내 정보', onPressed: () => context.push('/profile'), ), IconButton( icon: const Icon(Icons.qr_code_scanner), tooltip: 'QR 스캔', onPressed: _onScanQR, ), IconButton( icon: const Icon(Icons.logout), tooltip: '로그아웃', 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('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'), const SizedBox(height: 12), _buildActivitySection(isMobile), const SizedBox(height: 28), _buildSectionTitle('과거 연동 앱', '이전에 연동했던 앱 목록입니다.'), const SizedBox(height: 12), _buildPastRps(isMobile), const SizedBox(height: 28), _buildSectionTitle('접속이력', 'Baron 통합로그인 기준의 최근 접근 기록입니다.'), const SizedBox(height: 12), _buildAccessHistory(timelineWide), ], ), ), ); }, ), ), ), ], ), ); } Widget _buildHeaderCard(String userName, String department, DateTime? issuedAt) { final sessionLabel = issuedAt != null ? _formatDateTime(issuedAt) : '알 수 없음'; final infoColumn = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '안녕하세요, $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, '세션 활성'), _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.withOpacity(0.04), 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) { return FutureBuilder>( future: _linkedRpsFuture, builder: (context, snapshot) { final activities = _buildActivityItems(snapshot.data ?? []); final grid = _buildActivityGrid(activities, isMobile); if (snapshot.hasError) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ grid, const SizedBox(height: 8), Text( '연동 정보를 불러오지 못했습니다.', style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ); } if (activities.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '연동된 RP가 없습니다.', style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600), ), const SizedBox(height: 6), Text( 'RP를 연동하면 최근 활동과 상태가 표시됩니다.', style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ); } return grid; }, ); } Widget _buildPastRps(bool isMobile) { return FutureBuilder>( future: _rpHistoryFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())); } final pastItems = (snapshot.data ?? []).where((h) => h.status != 'active').toList(); if (pastItems.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '과거 연동 이력이 없습니다.', style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600), ), ], ); } final activities = pastItems.map((h) => _ActivityItem( clientId: h.clientId, appName: h.clientName.isNotEmpty ? h.clientName : h.clientId, lastAuthAt: h.lastRevokedAt != null ? '해지: ${_formatDateTime(h.lastRevokedAt!)}' : '해지됨', status: '해지됨', scopes: h.scopes, canLogout: false, isRevoked: true, onRevoke: null, )).toList(); return _buildActivityGrid(activities, isMobile); }, ); } List<_ActivityItem> _buildActivityItems(List linkedRps) { final items = <_ActivityItem>[]; for (final rp in linkedRps) { final isRevoked = _revokedClientIds.contains(rp.id); final lastAuthLabel = rp.lastAuthenticatedAt != null ? _formatDateTime(rp.lastAuthenticatedAt!) : '연동됨'; final normalizedStatus = rp.status.toLowerCase(); final statusLabel = isRevoked ? '비활성' : (normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성'); final name = rp.name.isNotEmpty ? rp.name : rp.id; items.add( _ActivityItem( clientId: rp.id, appName: name, lastAuthAt: lastAuthLabel, status: statusLabel, scopes: rp.scopes, canLogout: false, isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), url: rp.url, // URL 전달 ), ); } return items; } Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) { if (!isMobile) { return Wrap( spacing: 12, runSpacing: 12, children: activities.map(_buildActivityCard).toList(), ); } final visibleCount = _showAllActivities ? activities.length : 4; final visibleActivities = activities.take(visibleCount).toList(); final shouldShowToggle = activities.length > 4; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 1.05, ), itemCount: visibleActivities.length, itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]), ), if (shouldShowToggle) Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () { setState(() { _showAllActivities = !_showAllActivities; }); }, child: Text(_showAllActivities ? '접기' : '더보기'), ), ), ], ); } Widget _buildActivityCard(_ActivityItem item) { final isActive = item.status == '활성'; final statusColor = isActive ? Colors.green : Colors.grey; final borderColor = isActive ? Colors.green.withOpacity(0.5) : _border; final borderWidth = isActive ? 1.5 : 1.0; // 활성 상태면 클릭 가능 (URL 유무와 관계없이) final isClickable = isActive; // 카드 컨텐츠 final cardContent = Container( width: 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.withOpacity(0.05), 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.withOpacity(0.12), borderRadius: BorderRadius.circular(999), ), child: Text( item.status, style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600), ), ), ], ), const SizedBox(height: 12), Text( '최근 인증', 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: const Text('상세정보', style: TextStyle(fontSize: 13)), ), ), const SizedBox(width: 8), if (item.canLogout) Expanded( child: OutlinedButton( onPressed: item.onLogout, style: OutlinedButton.styleFrom( foregroundColor: _ink, side: const BorderSide(color: _border), padding: const EdgeInsets.symmetric(vertical: 8), ), child: const Text('로그아웃', style: TextStyle(fontSize: 13)), ), ), if (item.canLogout) const SizedBox(width: 8), Expanded( child: OutlinedButton( onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke, 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 ? '해지됨' : '연동 해지', 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 { if (item.url != null && item.url!.isNotEmpty) { final uri = Uri.parse(item.url!); if (await canLaunchUrl(uri)) { await launchUrl(uri); } else { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('해당 링크를 열 수 없습니다.')), ); } } } else { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('이동할 페이지 주소(Client URI)가 설정되지 않았습니다.')), ); } } }, child: opaqueCard, ), ); } return opaqueCard; } Widget _buildAccessHistory(bool isWide) { if (_auditLoading && _auditLogs.isEmpty) { return _buildHistoryContainer( child: const Center(child: CircularProgressIndicator()), ); } if (_auditError != null && _auditLogs.isEmpty) { return _buildHistoryContainer( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('접속이력을 불러오지 못했습니다.'), const SizedBox(height: 8), TextButton( onPressed: () => _loadAuditLogs(reset: true), child: const Text('다시 시도'), ), ], ), ), ); } if (_auditLogs.isEmpty) { return _buildHistoryContainer( child: Center( child: Text( '최근 접속 이력이 없습니다.', style: TextStyle(color: Colors.grey[600]), ), ), ); } if (isWide) { return _buildHistoryTable(_auditLogs); } return _buildHistoryList(_auditLogs); } 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(List logs) { return _buildHistoryContainer( child: Column( children: [ LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), child: DataTable( columnSpacing: 16, horizontalMargin: 12, columns: const [ DataColumn(label: Text('Session ID')), DataColumn(label: Text('접속일자')), DataColumn(label: Text('애플리케이션')), DataColumn(label: Text('IP')), DataColumn(label: Text('접속환경')), DataColumn(label: Text('인증수단')), DataColumn(label: Text('인증결과')), DataColumn(label: Text('현황')), ], rows: logs.map((log) { final statusLabel = log.status == 'success' ? '성공' : '실패'; final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); return DataRow(cells: [ DataCell(_selectableText(log.sessionId.isEmpty ? '-' : log.sessionId)), DataCell(_selectableText(_formatDateTime(log.timestamp))), DataCell(_buildAppCell(log)), DataCell(_selectableText(log.ipAddress.isEmpty ? '-' : log.ipAddress)), DataCell(_selectableText(deviceLabel)), DataCell(_buildAuthMethodCell(log, authMethod)), DataCell(_selectableText(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))), DataCell(_selectableText('(준비중)', style: const TextStyle(color: Colors.grey))), ]); }).toList(), ), ), ); }, ), _buildHistoryFooter(), ], ), ); } Widget _buildHistoryList(List logs) { return _buildHistoryContainer( child: Column( children: [ for (final log in logs) 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' ? '성공' : '실패', style: TextStyle( color: log.status == 'success' ? Colors.green : Colors.redAccent, fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 6), _selectableText('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'), _selectableText('접속일자: ${_formatDateTime(log.timestamp)}'), _selectableText('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'), _selectableText('접속환경: ${_deviceLabelFromUserAgent(log.userAgent)}'), _buildAuthMethodLine(log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel()), _selectableText('인증결과: ${log.status == 'success' ? '성공' : '실패'}'), _selectableText('현황: (준비중)', style: TextStyle(color: Colors.grey[600])), ], ), ), _buildHistoryFooter(), ], ), ); } Widget _buildHistoryFooter() { if (_auditLoadingMore) { return const Padding( padding: EdgeInsets.only(top: 8), child: Center(child: CircularProgressIndicator()), ); } if (_auditError != null) { return Padding( padding: const EdgeInsets.only(top: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('더 불러오지 못했습니다.'), TextButton( onPressed: () => _loadAuditLogs(), child: const Text('재시도'), ), ], ), ); } if (_auditNextCursor == null || _auditNextCursor!.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 8), child: Text( '더 이상 항목이 없습니다.', style: TextStyle(color: Colors.grey[600], fontSize: 12), ), ); } return const SizedBox.shrink(); } } class _ActivityItem { final String clientId; final String appName; final String lastAuthAt; final String status; final String? url; final List scopes; final bool canLogout; final bool isRevoked; final VoidCallback? onLogout; final VoidCallback? onRevoke; _ActivityItem({ required this.clientId, required this.appName, required this.lastAuthAt, required this.status, required this.scopes, required this.canLogout, this.url, this.isRevoked = false, this.onLogout, this.onRevoke, }); }