diff --git a/userfront/lib/features/dashboard/domain/dashboard_providers.dart b/userfront/lib/features/dashboard/domain/dashboard_providers.dart index 7d5a9427..61ad25f5 100644 --- a/userfront/lib/features/dashboard/domain/dashboard_providers.dart +++ b/userfront/lib/features/dashboard/domain/dashboard_providers.dart @@ -28,38 +28,7 @@ Future> _fetchLinkedRps() async { return result; } -Future> _fetchRpHistory() async { - 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'; - } - - try { - final response = await client.get(url, headers: headers); - 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 result = []; - for (final item in items) { - if (item is Map) { - result.add(RpHistoryItem.fromJson(Map.from(item))); - } - } - return result; - } finally { - client.close(); - } -} Future _fetchAuthTimelinePage({String? cursor}) async { final queryParameters = { @@ -104,13 +73,9 @@ Future _fetchAuthTimelinePage({String? cursor}) async { } } -final linkedRpsProvider = FutureProvider>((ref) async { - return _fetchLinkedRps(); -}); -final rpHistoryProvider = FutureProvider>((ref) async { - return _fetchRpHistory(); -}); + + typedef AuthTimelineFetcher = Future Function({String? cursor}); diff --git a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart new file mode 100644 index 00000000..83f20971 --- /dev/null +++ b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:userfront/core/services/auth_proxy_service.dart'; +import 'package:userfront/core/services/auth_token_store.dart'; +import 'package:userfront/core/services/http_client.dart'; + +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() ?? 'unknown', + scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], + lastAuthenticatedAt: parsedLastAuth, + ); + } +} + +class LinkedRpsNotifier extends AsyncNotifier> { + @override + Future> build() async { + return _fetchLinkedRps(); + } + + String _envOrDefault(String key, String fallback) { + if (!dotenv.isInitialized) { + return fallback; + } + return dotenv.env[key] ?? fallback; + } + + Future> _fetchLinkedRps() async { + try { + 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: ${response.statusCode}'); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? []; + + return items + .whereType>() + .map(LinkedRp.fromJson) + .toList(); + } catch (e) { + rethrow; + } + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => _fetchLinkedRps()); + } + + Future revokeRp(String clientId) async { + await AuthProxyService.revokeLinkedRp(clientId); + await refresh(); + } +} + +final linkedRpsProvider = AsyncNotifierProvider>(() { + return LinkedRpsNotifier(); +}); \ No newline at end of file diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 6d41a21b..6d87dff4 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -4,105 +4,16 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../domain/providers/linked_rps_provider.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'; import '../domain/dashboard_providers.dart'; -import '../domain/models.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}); -} +import '../domain/models.dart' hide LinkedRp; class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @@ -181,7 +92,6 @@ class _DashboardScreenState extends ConsumerState { _revokedClientIds.add(clientId); }); ref.invalidate(linkedRpsProvider); - ref.invalidate(rpHistoryProvider); } } catch (e) { if (mounted) { @@ -214,7 +124,6 @@ class _DashboardScreenState extends ConsumerState { context: context, builder: (context) => Consumer( builder: (context, ref, _) { - final historyState = ref.watch(rpHistoryProvider); return AlertDialog( title: Text(item.appName), content: SizedBox( @@ -240,48 +149,16 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(height: 24), const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), - historyState.when( - loading: () => const SizedBox(height: 20, child: LinearProgressIndicator()), - error: (_, __) => const Text('이력을 불러올 수 없습니다.', style: TextStyle(color: Colors.grey)), - data: (history) { - final filtered = history.where((h) => h.clientId == item.clientId).toList(); - if (filtered.isEmpty) { - return Text('최근 인증: ${item.lastAuthAt}'); - } - final h = filtered.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), - ), - ], - ); - }, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('최근 인증: ${item.lastAuthAt}'), + const SizedBox(height: 4), + Text( + '현재 상태: ${item.status}', + style: TextStyle(color: item.status == '활성' ? Colors.green : Colors.grey), + ), + ], ), ], ), @@ -356,15 +233,12 @@ class _DashboardScreenState extends ConsumerState { _revokedClientIds.clear(); }); ref.invalidate(linkedRpsProvider); - ref.invalidate(rpHistoryProvider); - final linkedFuture = ref.read(linkedRpsProvider.future); - final historyFuture = ref.read(rpHistoryProvider.future); - await Future.wait(>[ - linkedFuture, - historyFuture, + + await Future.wait([ + ref.read(linkedRpsProvider.future), ref.read(authTimelineProvider.notifier).refresh(), ]); - } + await _loadAuditLogs(reset: true); await ref.read(linkedRpsProvider.notifier).refresh(); } @@ -376,7 +250,7 @@ class _DashboardScreenState extends ConsumerState { return dotenv.env[key] ?? fallback; } - Future<_AuditPage> _fetchAuditLogs({String? cursor}) async { + Future _fetchAuditLogs({String? cursor}) async { final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); final queryParameters = { 'limit': '20', @@ -397,22 +271,24 @@ class _DashboardScreenState extends ConsumerState { headers['Authorization'] = 'Bearer $token'; } - final response = await client.get(url, headers: headers); - client.close(); + try { + final response = await client.get(url, headers: headers); + if (response.statusCode != 200) { + throw Exception('Failed to load audit logs'); + } - if (response.statusCode != 200) { - throw Exception('Failed to load audit logs'); + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? []; + final nextCursor = body['next_cursor']?.toString(); + final logs = items + .whereType>() + .map(AuditLogEntry.fromJson) + .toList(); + + return AuditPage(items: logs, nextCursor: nextCursor); + } finally { + client.close(); } - - 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 { @@ -902,48 +778,7 @@ class _DashboardScreenState extends ConsumerState { ); } - Widget _buildPastRps(bool isMobile) { - final historyState = ref.watch(rpHistoryProvider); - return historyState.when( - loading: () => const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())), - error: (_, __) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '과거 연동 정보를 불러오지 못했습니다.', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - data: (history) { - final pastItems = history.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>[]; @@ -1000,11 +835,11 @@ class _DashboardScreenState extends ConsumerState { Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) { if (activities.isEmpty) return const SizedBox.shrink(); - final shouldShowToggle = activities.length > 5; + final shouldShowToggle = activities.length > 4; - // 더보기를 누르지 않은 경우: 최대 5개 노출 (Grid/Wrap) + // 더보기를 누르지 않은 경우: 최대 4개 노출 (Grid/Wrap) if (!_showAllActivities) { - final visibleActivities = activities.take(5).toList(); + final visibleActivities = activities.take(4).toList(); Widget grid; if (isMobile) { grid = GridView.builder( @@ -1494,4 +1329,4 @@ class _ActivityItem { this.onRevoke, this.lastAuthDateTime, }); -} +} \ No newline at end of file