import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.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; 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, }); 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'] ?? '', ); } 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 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); Future>? _auditFuture; bool _showAllActivities = false; @override void initState() { super.initState(); _auditFuture = _fetchAuditLogs(); } Future _logout() async { Descope.sessionManager.clearSession(); AuthTokenStore.clear(); AuthNotifier.instance.notify(); } void _onScanQR() { context.push('/scan'); } 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(); setState(() { _auditFuture = _fetchAuditLogs(); }); if (_auditFuture != null) { await _auditFuture; } } static String _envOrDefault(String key, String fallback) { if (!dotenv.isInitialized) { return fallback; } return dotenv.env[key] ?? fallback; } Future> _fetchAuditLogs() async { final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline?limit=20'); 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 logs = items .whereType>() .map(AuditLogEntry.fromJson) .toList(); return logs; } 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'; } 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 세션'; } if (lower.contains('descope')) { return 'Descope'; } 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) { if (authMethod != 'QR') { return Text(authMethod); } final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? ''; final tooltip = approvedSessionId.isEmpty ? '승인한 세션 ID 없음' : '승인한 세션 ID: $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( 'QR', style: TextStyle( color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, decoration: approvedSessionId.isEmpty ? null : TextDecoration.underline, ), ), ), ); } Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) { if (authMethod != 'QR') { return Text('인증수단: $authMethod'); } final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? ''; 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 ? '승인한 세션 ID 없음' : '승인한 세션 ID: $approvedSessionId\n탭하면 복사됩니다.', child: Text( '인증수단: QR', style: TextStyle( color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, decoration: approvedSessionId.isEmpty ? null : TextDecoration.underline, ), ), ), ); } 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 user = Descope.sessionManager.session?.user; final userName = user?.name ?? user?.email ?? user?.phone ?? 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: GoogleFonts.outfit(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( 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), _buildActivityGrid(sessionIssuedAt, 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 _buildActivityGrid(DateTime? signupAt, bool isMobile) { final signupLabel = signupAt != null ? _formatDateTime(signupAt) : '확인 필요'; final activities = [ _ActivityItem( appName: 'Baron 통합로그인', lastAuthAt: signupLabel, status: '활성', canLogout: true, onLogout: _logout, ), _ActivityItem( appName: 'BEPs', lastAuthAt: '연동 필요', status: '미연동', canLogout: false, ), _ActivityItem( appName: 'KNGIL', lastAuthAt: '연동 필요', status: '미연동', canLogout: false, ), _ActivityItem( appName: 'C.E.L', lastAuthAt: '연동 필요', status: '미연동', canLogout: false, ), _ActivityItem( appName: 'EG-BIM', lastAuthAt: '연동 필요', status: '미연동', canLogout: false, ), ]; 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 statusColor = item.status == '활성' ? Colors.green : Colors.grey; return Container( width: 260, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: _border), ), 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), SizedBox( width: double.infinity, child: OutlinedButton( onPressed: item.canLogout ? item.onLogout : null, style: OutlinedButton.styleFrom( foregroundColor: _ink, side: const BorderSide(color: _border), ), child: const Text('로그아웃'), ), ), ], ), ); } Widget _buildAccessHistory(bool isWide) { return FutureBuilder>( future: _auditFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return _buildHistoryContainer( child: const Center(child: CircularProgressIndicator()), ); } if (snapshot.hasError) { return _buildHistoryContainer( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('접속이력을 불러오지 못했습니다.'), const SizedBox(height: 8), TextButton( onPressed: () { setState(() { _auditFuture = _fetchAuditLogs(); }); }, child: const Text('다시 시도'), ), ], ), ), ); } final logs = snapshot.data ?? []; if (logs.isEmpty) { return _buildHistoryContainer( child: Center( child: Text( '최근 접속 이력이 없습니다.', style: TextStyle(color: Colors.grey[600]), ), ), ); } if (isWide) { return _buildHistoryTable(logs); } return _buildHistoryList(logs); }, ); } 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: 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.take(10).map((log) { final statusLabel = log.status == 'success' ? '성공' : '실패'; final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; final appLabel = _appLabelForPath(log.path); final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); return DataRow(cells: [ DataCell(Text(log.sessionId.isEmpty ? '-' : log.sessionId)), DataCell(Text(_formatDateTime(log.timestamp))), DataCell(Text(appLabel)), DataCell(Text(log.ipAddress.isEmpty ? '-' : log.ipAddress)), DataCell(Text(deviceLabel)), DataCell(_buildAuthMethodCell(log, authMethod)), DataCell(Text(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))), const DataCell(Text('(준비중)', style: TextStyle(color: Colors.grey))), ]); }).toList(), ), ), ); }, ), ); } Widget _buildHistoryList(List logs) { return _buildHistoryContainer( child: Column( children: logs.take(10).map((log) { final statusLabel = log.status == 'success' ? '성공' : '실패'; final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; final appLabel = _appLabelForPath(log.path); final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); return 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: Text( appLabel, style: const TextStyle(fontWeight: FontWeight.w600, color: _ink), ), ), Text( statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600), ), ], ), const SizedBox(height: 6), Text('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'), Text('접속일자: ${_formatDateTime(log.timestamp)}'), Text('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'), Text('접속환경: $deviceLabel'), _buildAuthMethodLine(log, authMethod), Text('인증결과: $statusLabel'), Text('현황: (준비중)', style: TextStyle(color: Colors.grey[600])), ], ), ); }).toList(), ), ); } } class _ActivityItem { final String appName; final String lastAuthAt; final String status; final bool canLogout; final VoidCallback? onLogout; _ActivityItem({ required this.appName, required this.lastAuthAt, required this.status, required this.canLogout, this.onLogout, }); }