diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 737b534a..468469dc 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -145,6 +145,44 @@ class LinkedRp { } } +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}); @@ -167,6 +205,7 @@ class _DashboardScreenState extends ConsumerState { bool _isRevoking = false; Future>? _linkedRpsFuture; + Future>? _rpHistoryFuture; bool _showAllActivities = false; final Set _revokedClientIds = {}; @@ -176,6 +215,7 @@ class _DashboardScreenState extends ConsumerState { _pageScrollController.addListener(_onPageScroll); _loadAuditLogs(reset: true); _linkedRpsFuture = _fetchLinkedRps(); + _rpHistoryFuture = _fetchRpHistory(); } @override @@ -248,6 +288,94 @@ class _DashboardScreenState extends ConsumerState { } } + 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( @@ -429,6 +557,37 @@ class _DashboardScreenState extends ConsumerState { 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) { @@ -727,6 +886,10 @@ class _DashboardScreenState extends ConsumerState { 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), @@ -871,6 +1034,43 @@ class _DashboardScreenState extends ConsumerState { ); } + 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) { @@ -888,6 +1088,7 @@ class _DashboardScreenState extends ConsumerState { appName: name, lastAuthAt: lastAuthLabel, status: statusLabel, + scopes: rp.scopes, canLogout: false, isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), @@ -1006,6 +1207,18 @@ class _DashboardScreenState extends ConsumerState { 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( @@ -1277,6 +1490,7 @@ class _ActivityItem { final String lastAuthAt; final String status; final String? url; + final List scopes; final bool canLogout; final bool isRevoked; final VoidCallback? onLogout; @@ -1287,6 +1501,7 @@ class _ActivityItem { required this.appName, required this.lastAuthAt, required this.status, + required this.scopes, required this.canLogout, this.url, this.isRevoked = false,