forked from baron/baron-sso
테스트 로직 강화, hydra 연동 데이터 용량 제한 완화.
This commit is contained in:
238
userfront/lib/features/dashboard/domain/dashboard_providers.dart
Normal file
238
userfront/lib/features/dashboard/domain/dashboard_providers.dart
Normal file
@@ -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<List<LinkedRp>> _fetchLinkedRps() async {
|
||||
final items = await AuthProxyService.fetchLinkedRps();
|
||||
final result = <LinkedRp>[];
|
||||
for (final item in items) {
|
||||
if (item is Map) {
|
||||
result.add(LinkedRp.fromJson(Map<String, dynamic>.from(item)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<RpHistoryItem>> _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 = <String, String>{
|
||||
'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<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final result = <RpHistoryItem>[];
|
||||
for (final item in items) {
|
||||
if (item is Map) {
|
||||
result.add(RpHistoryItem.fromJson(Map<String, dynamic>.from(item)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||
final queryParameters = <String, String>{
|
||||
'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 = <String, String>{
|
||||
'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<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final nextCursor = body['next_cursor']?.toString();
|
||||
final logs = <AuditLogEntry>[];
|
||||
for (final item in items) {
|
||||
if (item is Map) {
|
||||
logs.add(AuditLogEntry.fromJson(Map<String, dynamic>.from(item)));
|
||||
}
|
||||
}
|
||||
|
||||
return AuditPage(items: logs, nextCursor: nextCursor);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
final linkedRpsProvider = FutureProvider<List<LinkedRp>>((ref) async {
|
||||
return _fetchLinkedRps();
|
||||
});
|
||||
|
||||
final rpHistoryProvider = FutureProvider<List<RpHistoryItem>>((ref) async {
|
||||
return _fetchRpHistory();
|
||||
});
|
||||
|
||||
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
||||
|
||||
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
|
||||
return _fetchAuthTimelinePage;
|
||||
});
|
||||
|
||||
class AuthTimelineState {
|
||||
final List<AuditLogEntry> 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<AuditLogEntry>? 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<AuthTimelineState> {
|
||||
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<void> refresh() async {
|
||||
if (state.isLoading) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(
|
||||
items: const [],
|
||||
nextCursor: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
);
|
||||
await _loadPage(reset: true);
|
||||
}
|
||||
|
||||
Future<void> 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<void> _loadInitial() async {
|
||||
if (state.items.isNotEmpty || state.isLoading) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
await _loadPage(reset: true);
|
||||
}
|
||||
|
||||
Future<void> _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, AuthTimelineState>(
|
||||
AuthTimelineNotifier.new,
|
||||
);
|
||||
172
userfront/lib/features/dashboard/domain/models.dart
Normal file
172
userfront/lib/features/dashboard/domain/models.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> get detailMap {
|
||||
if (details.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return jsonDecode(details) as Map<String, dynamic>;
|
||||
} 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<AuditLogEntry> 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<String> 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<String, dynamic> 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<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RpHistoryItem {
|
||||
final String clientId;
|
||||
final String clientName;
|
||||
final List<String> 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<String, dynamic> 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<String>().toList() ?? [],
|
||||
lastApprovedAt: parseDate(json['last_approved_at']?.toString()),
|
||||
lastRevokedAt: parseDate(json['last_revoked_at']?.toString()),
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> get detailMap {
|
||||
if (details.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return jsonDecode(details) as Map<String, dynamic>;
|
||||
} 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<AuditLogEntry> 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<String> 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<String, dynamic> 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<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RpHistoryItem {
|
||||
final String clientId;
|
||||
final String clientName;
|
||||
final List<String> 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<String, dynamic> 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<String>().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<DashboardScreen> {
|
||||
static const _subtle = Color(0xFFF7F8FA);
|
||||
|
||||
final ScrollController _pageScrollController = ScrollController();
|
||||
final List<AuditLogEntry> _auditLogs = [];
|
||||
String? _auditNextCursor;
|
||||
bool _auditLoading = false;
|
||||
bool _auditLoadingMore = false;
|
||||
String? _auditError;
|
||||
bool _isRevoking = false;
|
||||
|
||||
Future<List<LinkedRp>>? _linkedRpsFuture;
|
||||
Future<List<RpHistoryItem>>? _rpHistoryFuture;
|
||||
bool _showAllActivities = false;
|
||||
final Set<String> _revokedClientIds = {};
|
||||
|
||||
@@ -213,9 +34,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageScrollController.addListener(_onPageScroll);
|
||||
_loadAuditLogs(reset: true);
|
||||
_linkedRpsFuture = _fetchLinkedRps();
|
||||
_rpHistoryFuture = _fetchRpHistory();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -261,6 +79,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
setState(() {
|
||||
_revokedClientIds.add(clientId);
|
||||
});
|
||||
ref.invalidate(linkedRpsProvider);
|
||||
ref.invalidate(rpHistoryProvider);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -284,94 +104,95 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
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<List<RpHistoryItem>>(
|
||||
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<DashboardScreen> {
|
||||
|
||||
Future<void> _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 = <String, String>{
|
||||
'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 = <String, String>{
|
||||
'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<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final nextCursor = body['next_cursor']?.toString();
|
||||
final logs = items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(AuditLogEntry.fromJson)
|
||||
.toList();
|
||||
|
||||
return _AuditPage(items: logs, nextCursor: nextCursor);
|
||||
}
|
||||
|
||||
Future<void> _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<List<LinkedRp>> _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 = <String, String>{
|
||||
'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<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final linkedRps = items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LinkedRp.fromJson)
|
||||
.toList();
|
||||
|
||||
return linkedRps;
|
||||
}
|
||||
|
||||
Future<List<RpHistoryItem>> _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 = <String, String>{
|
||||
'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<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final history = items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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<dynamic>(<Future<dynamic>>[
|
||||
linkedFuture,
|
||||
historyFuture,
|
||||
ref.read(authTimelineProvider.notifier).refresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt() {
|
||||
@@ -820,6 +497,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
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<DashboardScreen> {
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildAccessHistory(timelineWide),
|
||||
_buildAccessHistory(timelineState, timelineWide),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -992,26 +670,20 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
|
||||
Widget _buildActivitySection(bool isMobile) {
|
||||
return FutureBuilder<List<LinkedRp>>(
|
||||
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<DashboardScreen> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return grid;
|
||||
return _buildActivityGrid(activities, isMobile);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPastRps(bool isMobile) {
|
||||
return FutureBuilder<List<RpHistoryItem>>(
|
||||
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<DashboardScreen> {
|
||||
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<DashboardScreen> {
|
||||
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<DashboardScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_auditLogs.isEmpty) {
|
||||
if (state.items.isEmpty) {
|
||||
return _buildHistoryContainer(
|
||||
child: Center(
|
||||
child: Text(
|
||||
@@ -1331,9 +1008,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
|
||||
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<DashboardScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryTable(List<AuditLogEntry> logs) {
|
||||
Widget _buildHistoryTable(AuthTimelineState state) {
|
||||
return _buildHistoryContainer(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -1372,7 +1049,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
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<DashboardScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildHistoryFooter(),
|
||||
_buildHistoryFooter(state),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryList(List<AuditLogEntry> 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<DashboardScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
_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<DashboardScreen> {
|
||||
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(
|
||||
|
||||
109
userfront/test/dashboard_providers_test.dart
Normal file
109
userfront/test/dashboard_providers_test.dart
Normal file
@@ -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<void> _drainMicrotasks() async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('AuthTimelineNotifier는 초기 페이지를 로드한다', () async {
|
||||
final cursors = <String?>[];
|
||||
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 = <String?>[];
|
||||
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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user