From 3a6ae4948ab146d85abe3fa01d0a7a35096a417a Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Fri, 6 Feb 2026 17:21:53 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=95=ED=99=94,=20hydra=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9A=A9=EB=9F=89=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=99=84=ED=99=94.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/domain/dashboard_providers.dart | 238 +++++++ .../lib/features/dashboard/domain/models.dart | 172 +++++ .../presentation/dashboard_screen.dart | 607 ++++-------------- userfront/test/dashboard_providers_test.dart | 109 ++++ 4 files changed, 661 insertions(+), 465 deletions(-) create mode 100644 userfront/lib/features/dashboard/domain/dashboard_providers.dart create mode 100644 userfront/lib/features/dashboard/domain/models.dart create mode 100644 userfront/test/dashboard_providers_test.dart diff --git a/userfront/lib/features/dashboard/domain/dashboard_providers.dart b/userfront/lib/features/dashboard/domain/dashboard_providers.dart new file mode 100644 index 00000000..7d5a9427 --- /dev/null +++ b/userfront/lib/features/dashboard/domain/dashboard_providers.dart @@ -0,0 +1,238 @@ +import 'dart:convert'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/http_client.dart'; +import 'models.dart'; + +String _envOrDefault(String key, String fallback) { + if (!dotenv.isInitialized) { + return fallback; + } + return dotenv.env[key] ?? fallback; +} + +String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); + +Future> _fetchLinkedRps() async { + final items = await AuthProxyService.fetchLinkedRps(); + final result = []; + for (final item in items) { + if (item is Map) { + result.add(LinkedRp.fromJson(Map.from(item))); + } + } + 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 = { + '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'; + } + + try { + final response = await client.get(url, headers: headers); + 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 = []; + for (final item in items) { + if (item is Map) { + logs.add(AuditLogEntry.fromJson(Map.from(item))); + } + } + + return AuditPage(items: logs, nextCursor: nextCursor); + } finally { + client.close(); + } +} + +final linkedRpsProvider = FutureProvider>((ref) async { + return _fetchLinkedRps(); +}); + +final rpHistoryProvider = FutureProvider>((ref) async { + return _fetchRpHistory(); +}); + +typedef AuthTimelineFetcher = Future Function({String? cursor}); + +final authTimelineFetcherProvider = Provider((ref) { + return _fetchAuthTimelinePage; +}); + +class AuthTimelineState { + final List items; + final String? nextCursor; + final bool isLoading; + final bool isLoadingMore; + final String? error; + + const AuthTimelineState({ + required this.items, + this.nextCursor, + this.isLoading = false, + this.isLoadingMore = false, + this.error, + }); + + const AuthTimelineState.initial() + : items = const [], + nextCursor = null, + isLoading = false, + isLoadingMore = false, + error = null; + + AuthTimelineState copyWith({ + List? items, + String? nextCursor, + bool? isLoading, + bool? isLoadingMore, + String? error, + }) { + return AuthTimelineState( + items: items ?? this.items, + nextCursor: nextCursor ?? this.nextCursor, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + error: error, + ); + } +} + +class AuthTimelineNotifier extends Notifier { + late final AuthTimelineFetcher _fetchPage; + bool _hasLoaded = false; + + @override + AuthTimelineState build() { + _fetchPage = ref.watch(authTimelineFetcherProvider); + if (!_hasLoaded) { + _hasLoaded = true; + Future.microtask(_loadInitial); + } + return const AuthTimelineState.initial(); + } + + Future refresh() async { + if (state.isLoading) { + return; + } + state = state.copyWith( + items: const [], + nextCursor: null, + isLoading: true, + error: null, + ); + await _loadPage(reset: true); + } + + Future loadMore() async { + if (state.isLoading || state.isLoadingMore) { + return; + } + if (state.nextCursor == null || state.nextCursor!.isEmpty) { + return; + } + state = state.copyWith(isLoadingMore: true, error: null); + await _loadPage(reset: false); + } + + Future _loadInitial() async { + if (state.items.isNotEmpty || state.isLoading) { + return; + } + state = state.copyWith(isLoading: true, error: null); + await _loadPage(reset: true); + } + + Future _loadPage({required bool reset}) async { + try { + final page = await _fetchPage(cursor: reset ? null : state.nextCursor); + if (reset) { + state = state.copyWith( + items: page.items, + nextCursor: page.nextCursor, + isLoading: false, + isLoadingMore: false, + error: null, + ); + } else { + state = state.copyWith( + items: [...state.items, ...page.items], + nextCursor: page.nextCursor, + isLoading: false, + isLoadingMore: false, + error: null, + ); + } + } catch (e) { + state = state.copyWith( + isLoading: false, + isLoadingMore: false, + error: '접속이력을 불러오지 못했습니다.', + ); + } + } +} + +final authTimelineProvider = NotifierProvider( + AuthTimelineNotifier.new, +); diff --git a/userfront/lib/features/dashboard/domain/models.dart b/userfront/lib/features/dashboard/domain/models.dart new file mode 100644 index 00000000..f45c858f --- /dev/null +++ b/userfront/lib/features/dashboard/domain/models.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +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, + this.lastAuthenticatedAt, + }); + + factory LinkedRp.fromJson(Map json) { + DateTime? parsedLastAuth; + final rawLastAuth = json['lastAuthenticatedAt']?.toString(); + if (rawLastAuth != null && 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', + ); + } +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 10b34727..855b2aa6 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -3,185 +3,14 @@ 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', - ); - } -} +import '../domain/dashboard_providers.dart'; +import '../domain/models.dart'; class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @@ -197,15 +26,7 @@ class _DashboardScreenState extends ConsumerState { 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 = {}; @@ -213,9 +34,6 @@ class _DashboardScreenState extends ConsumerState { void initState() { super.initState(); _pageScrollController.addListener(_onPageScroll); - _loadAuditLogs(reset: true); - _linkedRpsFuture = _fetchLinkedRps(); - _rpHistoryFuture = _fetchRpHistory(); } @override @@ -261,6 +79,8 @@ class _DashboardScreenState extends ConsumerState { setState(() { _revokedClientIds.add(clientId); }); + ref.invalidate(linkedRpsProvider); + ref.invalidate(rpHistoryProvider); } } catch (e) { if (mounted) { @@ -284,94 +104,95 @@ class _DashboardScreenState extends ConsumerState { return; } if (_pageScrollController.position.extentAfter < 240) { - _loadAuditLogs(); + ref.read(authTimelineProvider.notifier).loadMore(); } } 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!)}'), - ], + builder: (context) => Consumer( + builder: (context, ref, _) { + final historyState = ref.watch(rpHistoryProvider); + return 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), + 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), ), - ), - 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('닫기'), ), ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('닫기'), - ), - ], + ); + }, ), ); } @@ -430,162 +251,18 @@ class _DashboardScreenState extends ConsumerState { 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; + ref.invalidate(linkedRpsProvider); + ref.invalidate(rpHistoryProvider); + final linkedFuture = ref.read(linkedRpsProvider.future); + final historyFuture = ref.read(rpHistoryProvider.future); + await Future.wait(>[ + linkedFuture, + historyFuture, + ref.read(authTimelineProvider.notifier).refresh(), + ]); } DateTime? _getJwtIssuedAt() { @@ -820,6 +497,7 @@ class _DashboardScreenState extends ConsumerState { 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 ?? profile?.email ?? profile?.phone ?? @@ -892,7 +570,7 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(height: 28), _buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'), const SizedBox(height: 12), - _buildAccessHistory(timelineWide), + _buildAccessHistory(timelineState, timelineWide), ], ), ), @@ -992,26 +670,20 @@ class _DashboardScreenState extends ConsumerState { } 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]), - ), - ], - ); - } - + final linkedState = ref.watch(linkedRpsProvider); + return linkedState.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: (linkedRps) { + final activities = _buildActivityItems(linkedRps); if (activities.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1028,21 +700,26 @@ class _DashboardScreenState extends ConsumerState { ], ); } - - return grid; + return _buildActivityGrid(activities, isMobile); }, ); } 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(); + 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, @@ -1294,14 +971,14 @@ class _DashboardScreenState extends ConsumerState { return opaqueCard; } - Widget _buildAccessHistory(bool isWide) { - if (_auditLoading && _auditLogs.isEmpty) { + Widget _buildAccessHistory(AuthTimelineState state, bool isWide) { + if (state.isLoading && state.items.isEmpty) { return _buildHistoryContainer( child: const Center(child: CircularProgressIndicator()), ); } - if (_auditError != null && _auditLogs.isEmpty) { + if (state.error != null && state.items.isEmpty) { return _buildHistoryContainer( child: Center( child: Column( @@ -1310,7 +987,7 @@ class _DashboardScreenState extends ConsumerState { const Text('접속이력을 불러오지 못했습니다.'), const SizedBox(height: 8), TextButton( - onPressed: () => _loadAuditLogs(reset: true), + onPressed: () => ref.read(authTimelineProvider.notifier).refresh(), child: const Text('다시 시도'), ), ], @@ -1319,7 +996,7 @@ class _DashboardScreenState extends ConsumerState { ); } - if (_auditLogs.isEmpty) { + if (state.items.isEmpty) { return _buildHistoryContainer( child: Center( child: Text( @@ -1331,9 +1008,9 @@ class _DashboardScreenState extends ConsumerState { } if (isWide) { - return _buildHistoryTable(_auditLogs); + return _buildHistoryTable(state); } - return _buildHistoryList(_auditLogs); + return _buildHistoryList(state); } Widget _buildHistoryContainer({required Widget child}) { @@ -1349,7 +1026,7 @@ class _DashboardScreenState extends ConsumerState { ); } - Widget _buildHistoryTable(List logs) { + Widget _buildHistoryTable(AuthTimelineState state) { return _buildHistoryContainer( child: Column( children: [ @@ -1372,7 +1049,7 @@ class _DashboardScreenState extends ConsumerState { DataColumn(label: Text('인증결과')), DataColumn(label: Text('현황')), ], - rows: logs.map((log) { + rows: state.items.map((log) { final statusLabel = log.status == 'success' ? '성공' : '실패'; final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); @@ -1393,17 +1070,17 @@ class _DashboardScreenState extends ConsumerState { ); }, ), - _buildHistoryFooter(), + _buildHistoryFooter(state), ], ), ); } - Widget _buildHistoryList(List logs) { + Widget _buildHistoryList(AuthTimelineState state) { return _buildHistoryContainer( child: Column( children: [ - for (final log in logs) + for (final log in state.items) Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), @@ -1443,20 +1120,20 @@ class _DashboardScreenState extends ConsumerState { ], ), ), - _buildHistoryFooter(), + _buildHistoryFooter(state), ], ), ); } - Widget _buildHistoryFooter() { - if (_auditLoadingMore) { + Widget _buildHistoryFooter(AuthTimelineState state) { + if (state.isLoadingMore) { return const Padding( padding: EdgeInsets.only(top: 8), child: Center(child: CircularProgressIndicator()), ); } - if (_auditError != null) { + if (state.error != null) { return Padding( padding: const EdgeInsets.only(top: 8), child: Row( @@ -1464,14 +1141,14 @@ class _DashboardScreenState extends ConsumerState { children: [ const Text('더 불러오지 못했습니다.'), TextButton( - onPressed: () => _loadAuditLogs(), + onPressed: () => ref.read(authTimelineProvider.notifier).loadMore(), child: const Text('재시도'), ), ], ), ); } - if (_auditNextCursor == null || _auditNextCursor!.isEmpty) { + if (state.nextCursor == null || state.nextCursor!.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 8), child: Text( diff --git a/userfront/test/dashboard_providers_test.dart b/userfront/test/dashboard_providers_test.dart new file mode 100644 index 00000000..2e46a640 --- /dev/null +++ b/userfront/test/dashboard_providers_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:userfront/features/dashboard/domain/dashboard_providers.dart'; +import 'package:userfront/features/dashboard/domain/models.dart'; + +AuditLogEntry _log(String id) { + return AuditLogEntry.fromJson({ + 'event_id': id, + 'timestamp': '2026-02-06T00:00:00Z', + 'status': 'success', + 'session_id': 's-$id', + }); +} + +Future _drainMicrotasks() async { + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('AuthTimelineNotifier는 초기 페이지를 로드한다', () async { + final cursors = []; + final container = ProviderContainer( + overrides: [ + authTimelineFetcherProvider.overrideWithValue(({String? cursor}) async { + cursors.add(cursor); + return AuditPage(items: [_log('1')], nextCursor: 'next'); + }), + ], + ); + + container.read(authTimelineProvider.notifier); + + await _drainMicrotasks(); + + final state = container.read(authTimelineProvider); + expect(state.items.length, 1); + expect(state.nextCursor, 'next'); + expect(cursors, [null]); + container.dispose(); + }); + + test('AuthTimelineNotifier는 다음 커서를 사용해 추가 로드한다', () async { + final cursors = []; + final container = ProviderContainer( + overrides: [ + authTimelineFetcherProvider.overrideWithValue(({String? cursor}) async { + cursors.add(cursor); + if (cursor == null) { + return AuditPage(items: [_log('1')], nextCursor: 'next'); + } + return AuditPage(items: [_log('2')], nextCursor: ''); + }), + ], + ); + + final notifier = container.read(authTimelineProvider.notifier); + await _drainMicrotasks(); + await notifier.loadMore(); + + final state = container.read(authTimelineProvider); + expect(state.items.map((e) => e.eventId).toList(), ['1', '2']); + expect(cursors, [null, 'next']); + container.dispose(); + }); + + test('AuthTimelineNotifier는 커서가 없으면 추가 로드를 하지 않는다', () async { + var callCount = 0; + final container = ProviderContainer( + overrides: [ + authTimelineFetcherProvider.overrideWithValue(({String? cursor}) async { + callCount += 1; + return AuditPage(items: [_log('1')], nextCursor: ''); + }), + ], + ); + + final notifier = container.read(authTimelineProvider.notifier); + await _drainMicrotasks(); + await notifier.loadMore(); + + expect(callCount, 1); + expect(container.read(authTimelineProvider).items.length, 1); + container.dispose(); + }); + + test('AuthTimelineNotifier는 실패 시 오류 메시지를 보관한다', () async { + final container = ProviderContainer( + overrides: [ + authTimelineFetcherProvider.overrideWithValue(({String? cursor}) async { + throw Exception('fail'); + }), + ], + ); + + container.read(authTimelineProvider.notifier); + + await _drainMicrotasks(); + + final state = container.read(authTimelineProvider); + expect(state.items.isEmpty, true); + expect(state.error, '접속이력을 불러오지 못했습니다.'); + container.dispose(); + }); +}