1
0
forked from baron/baron-sso

Merge commit '226de652e370dc06c34bdcd14753c3dade28bb2c'

This commit is contained in:
2026-02-06 17:32:44 +09:00
6 changed files with 708 additions and 467 deletions

View File

@@ -69,6 +69,45 @@ func TestAuditMiddleware(t *testing.T) {
mockRepo.AssertExpectations(t) mockRepo.AssertExpectations(t)
}) })
t.Run("POST request - Merge extra audit details", func(t *testing.T) {
app := fiber.New()
mockRepo := new(MockAuditRepository)
app.Use(AuditMiddleware(AuditConfig{
Repo: mockRepo,
}))
app.Post("/test", func(c *fiber.Ctx) error {
c.Locals("audit_details_extra", map[string]any{
"client_id": "rp-1",
"client_name": "Demo App",
})
c.Locals("auth_timeline_skip", true)
return c.SendStatus(fiber.StatusOK)
})
mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool {
var details map[string]any
if err := json.Unmarshal([]byte(log.Details), &details); err != nil {
return false
}
if details["client_id"] != "rp-1" {
return false
}
if details["client_name"] != "Demo App" {
return false
}
skip, ok := details["auth_timeline_skip"].(bool)
return ok && skip
})).Return(nil)
req := httptest.NewRequest("POST", "/test", nil)
resp, _ := app.Test(req)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
mockRepo.AssertExpectations(t)
})
t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) { t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) {
app := fiber.New() app := fiber.New()
mockRepo := new(MockAuditRepository) mockRepo := new(MockAuditRepository)

View File

@@ -240,14 +240,20 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode == http.StatusNoContent {
return []domain.HydraConsentSession{}, nil
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body)) return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body))
} }
if len(body) == 0 {
return []domain.HydraConsentSession{}, nil
}
var sessions []domain.HydraConsentSession var sessions []domain.HydraConsentSession
if err := json.Unmarshal(body, &sessions); err != nil { if err := json.Unmarshal(body, &sessions); err != nil {
return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err) return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w body=%s", err, string(body))
} }
return sessions, nil return sessions, nil
} }

View 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,
);

View 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',
);
}
}

View File

@@ -3,185 +3,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart';
import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/layout_breakpoints.dart';
import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart';
import '../domain/dashboard_providers.dart';
class AuditLogEntry { import '../domain/models.dart';
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',
);
}
}
class DashboardScreen extends ConsumerStatefulWidget { class DashboardScreen extends ConsumerStatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
@@ -197,15 +26,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
static const _subtle = Color(0xFFF7F8FA); static const _subtle = Color(0xFFF7F8FA);
final ScrollController _pageScrollController = ScrollController(); final ScrollController _pageScrollController = ScrollController();
final List<AuditLogEntry> _auditLogs = [];
String? _auditNextCursor;
bool _auditLoading = false;
bool _auditLoadingMore = false;
String? _auditError;
bool _isRevoking = false; bool _isRevoking = false;
Future<List<LinkedRp>>? _linkedRpsFuture;
Future<List<RpHistoryItem>>? _rpHistoryFuture;
bool _showAllActivities = false; bool _showAllActivities = false;
final Set<String> _revokedClientIds = {}; final Set<String> _revokedClientIds = {};
@@ -213,9 +34,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_pageScrollController.addListener(_onPageScroll); _pageScrollController.addListener(_onPageScroll);
_loadAuditLogs(reset: true);
_linkedRpsFuture = _fetchLinkedRps();
_rpHistoryFuture = _fetchRpHistory();
} }
@override @override
@@ -261,6 +79,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
setState(() { setState(() {
_revokedClientIds.add(clientId); _revokedClientIds.add(clientId);
}); });
ref.invalidate(linkedRpsProvider);
ref.invalidate(rpHistoryProvider);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -284,94 +104,95 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return; return;
} }
if (_pageScrollController.position.extentAfter < 240) { if (_pageScrollController.position.extentAfter < 240) {
_loadAuditLogs(); ref.read(authTimelineProvider.notifier).loadMore();
} }
} }
void _showRpDetails(_ActivityItem item) { void _showRpDetails(_ActivityItem item) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => Consumer(
title: Text(item.appName), builder: (context, ref, _) {
content: SizedBox( final historyState = ref.watch(rpHistoryProvider);
width: double.maxFinite, return AlertDialog(
child: Column( title: Text(item.appName),
mainAxisSize: MainAxisSize.min, content: SizedBox(
crossAxisAlignment: CrossAxisAlignment.start, width: double.maxFinite,
children: [ child: Column(
const Text('권한 (Scopes)', style: TextStyle(fontWeight: FontWeight.bold)), mainAxisSize: MainAxisSize.min,
const SizedBox(height: 8), crossAxisAlignment: CrossAxisAlignment.start,
if (item.scopes.isEmpty) children: [
const Text('요청된 권한이 없습니다.', style: TextStyle(color: Colors.grey)) const Text('권한 (Scopes)', style: TextStyle(fontWeight: FontWeight.bold)),
else const SizedBox(height: 8),
Wrap( if (item.scopes.isEmpty)
spacing: 8, const Text('요청된 권한이 없습니다.', style: TextStyle(color: Colors.grey))
runSpacing: 4, else
children: item.scopes.map((s) => Chip( Wrap(
label: Text(s, style: const TextStyle(fontSize: 12)), spacing: 8,
visualDensity: VisualDensity.compact, runSpacing: 4,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, children: item.scopes.map((s) => Chip(
)).toList(), label: Text(s, style: const TextStyle(fontSize: 12)),
), visualDensity: VisualDensity.compact,
const SizedBox(height: 24), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)), )).toList(),
const SizedBox(height: 8), ),
FutureBuilder<List<RpHistoryItem>>( const SizedBox(height: 24),
future: _rpHistoryFuture, const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)),
builder: (context, snapshot) { const SizedBox(height: 8),
if (snapshot.connectionState == ConnectionState.waiting) { historyState.when(
return const SizedBox(height: 20, child: LinearProgressIndicator()); loading: () => const SizedBox(height: 20, child: LinearProgressIndicator()),
} error: (_, __) => const Text('이력을 불러올 수 없습니다.', style: TextStyle(color: Colors.grey)),
if (snapshot.hasError || !snapshot.hasData) { data: (history) {
return const Text('이력을 불러올 수 없습니다.', style: TextStyle(color: Colors.grey)); final filtered = history.where((h) => h.clientId == item.clientId).toList();
} if (filtered.isEmpty) {
final history = snapshot.data!.where((h) => h.clientId == item.clientId).toList(); return Text('최근 인증: ${item.lastAuthAt}');
if (history.isEmpty) { }
// Fallback to item data if no history found (e.g. fresh login) final h = filtered.first;
return Text('최근 인증: ${item.lastAuthAt}'); return Column(
} crossAxisAlignment: CrossAxisAlignment.start,
final h = history.first; children: [
return Column( if (h.lastApprovedAt != null)
crossAxisAlignment: CrossAxisAlignment.start, Padding(
children: [ padding: const EdgeInsets.only(bottom: 4),
if (h.lastApprovedAt != null) child: Row(
Padding( children: [
padding: const EdgeInsets.only(bottom: 4), const Icon(Icons.check_circle_outline, size: 16, color: Colors.green),
child: Row( const SizedBox(width: 8),
children: [ Text('승인: ${_formatDateTime(h.lastApprovedAt!)}'),
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), actions: [
Text('해지: ${_formatDateTime(h.lastRevokedAt!)}'), TextButton(
], onPressed: () => Navigator.of(context).pop(),
), child: const Text('닫기'),
),
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('닫기'),
),
],
), ),
); );
} }
@@ -430,162 +251,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Future<void> _refreshAll() async { Future<void> _refreshAll() async {
await ref.read(profileProvider.notifier).loadProfile(); await ref.read(profileProvider.notifier).loadProfile();
await _loadAuditLogs(reset: true);
setState(() { setState(() {
_revokedClientIds.clear(); _revokedClientIds.clear();
_linkedRpsFuture = _fetchLinkedRps();
}); });
if (_linkedRpsFuture != null) { ref.invalidate(linkedRpsProvider);
await _linkedRpsFuture; ref.invalidate(rpHistoryProvider);
} final linkedFuture = ref.read(linkedRpsProvider.future);
} final historyFuture = ref.read(rpHistoryProvider.future);
await Future.wait<dynamic>(<Future<dynamic>>[
static String _envOrDefault(String key, String fallback) { linkedFuture,
if (!dotenv.isInitialized) { historyFuture,
return fallback; ref.read(authTimelineProvider.notifier).refresh(),
} ]);
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;
} }
DateTime? _getJwtIssuedAt() { DateTime? _getJwtIssuedAt() {
@@ -820,6 +497,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
final profileState = ref.watch(profileProvider); final profileState = ref.watch(profileProvider);
final profile = profileState.value; final profile = profileState.value;
final timelineState = ref.watch(authTimelineProvider);
final userName = profile?.name ?? final userName = profile?.name ??
profile?.email ?? profile?.email ??
profile?.phone ?? profile?.phone ??
@@ -892,7 +570,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(height: 28), const SizedBox(height: 28),
_buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'), _buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildAccessHistory(timelineWide), _buildAccessHistory(timelineState, timelineWide),
], ],
), ),
), ),
@@ -992,26 +670,20 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
} }
Widget _buildActivitySection(bool isMobile) { Widget _buildActivitySection(bool isMobile) {
return FutureBuilder<List<LinkedRp>>( final linkedState = ref.watch(linkedRpsProvider);
future: _linkedRpsFuture, return linkedState.when(
builder: (context, snapshot) { loading: () => const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())),
final activities = _buildActivityItems(snapshot.data ?? []); error: (_, __) => Column(
final grid = _buildActivityGrid(activities, isMobile); crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (snapshot.hasError) { Text(
return Column( '연동 정보를 불러오지 못했습니다.',
crossAxisAlignment: CrossAxisAlignment.start, style: TextStyle(fontSize: 12, color: Colors.grey[600]),
children: [ ),
grid, ],
const SizedBox(height: 8), ),
Text( data: (linkedRps) {
'연동 정보를 불러오지 못했습니다.', final activities = _buildActivityItems(linkedRps);
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
);
}
if (activities.isEmpty) { if (activities.isEmpty) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1028,21 +700,26 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
], ],
); );
} }
return _buildActivityGrid(activities, isMobile);
return grid;
}, },
); );
} }
Widget _buildPastRps(bool isMobile) { Widget _buildPastRps(bool isMobile) {
return FutureBuilder<List<RpHistoryItem>>( final historyState = ref.watch(rpHistoryProvider);
future: _rpHistoryFuture, return historyState.when(
builder: (context, snapshot) { loading: () => const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())),
if (snapshot.connectionState == ConnectionState.waiting) { error: (_, __) => Column(
return const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())); crossAxisAlignment: CrossAxisAlignment.start,
} children: [
Text(
final pastItems = (snapshot.data ?? []).where((h) => h.status != 'active').toList(); '과거 연동 정보를 불러오지 못했습니다.',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
data: (history) {
final pastItems = history.where((h) => h.status != 'active').toList();
if (pastItems.isEmpty) { if (pastItems.isEmpty) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1294,14 +971,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return opaqueCard; return opaqueCard;
} }
Widget _buildAccessHistory(bool isWide) { Widget _buildAccessHistory(AuthTimelineState state, bool isWide) {
if (_auditLoading && _auditLogs.isEmpty) { if (state.isLoading && state.items.isEmpty) {
return _buildHistoryContainer( return _buildHistoryContainer(
child: const Center(child: CircularProgressIndicator()), child: const Center(child: CircularProgressIndicator()),
); );
} }
if (_auditError != null && _auditLogs.isEmpty) { if (state.error != null && state.items.isEmpty) {
return _buildHistoryContainer( return _buildHistoryContainer(
child: Center( child: Center(
child: Column( child: Column(
@@ -1310,7 +987,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const Text('접속이력을 불러오지 못했습니다.'), const Text('접속이력을 불러오지 못했습니다.'),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: () => _loadAuditLogs(reset: true), onPressed: () => ref.read(authTimelineProvider.notifier).refresh(),
child: const Text('다시 시도'), child: const Text('다시 시도'),
), ),
], ],
@@ -1319,7 +996,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
); );
} }
if (_auditLogs.isEmpty) { if (state.items.isEmpty) {
return _buildHistoryContainer( return _buildHistoryContainer(
child: Center( child: Center(
child: Text( child: Text(
@@ -1331,9 +1008,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
} }
if (isWide) { if (isWide) {
return _buildHistoryTable(_auditLogs); return _buildHistoryTable(state);
} }
return _buildHistoryList(_auditLogs); return _buildHistoryList(state);
} }
Widget _buildHistoryContainer({required Widget child}) { 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( return _buildHistoryContainer(
child: Column( child: Column(
children: [ children: [
@@ -1372,7 +1049,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
DataColumn(label: Text('인증결과')), DataColumn(label: Text('인증결과')),
DataColumn(label: Text('현황')), DataColumn(label: Text('현황')),
], ],
rows: logs.map((log) { rows: state.items.map((log) {
final statusLabel = log.status == 'success' ? '성공' : '실패'; final statusLabel = log.status == 'success' ? '성공' : '실패';
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); 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( return _buildHistoryContainer(
child: Column( child: Column(
children: [ children: [
for (final log in logs) for (final log in state.items)
Container( Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -1443,20 +1120,20 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
], ],
), ),
), ),
_buildHistoryFooter(), _buildHistoryFooter(state),
], ],
), ),
); );
} }
Widget _buildHistoryFooter() { Widget _buildHistoryFooter(AuthTimelineState state) {
if (_auditLoadingMore) { if (state.isLoadingMore) {
return const Padding( return const Padding(
padding: EdgeInsets.only(top: 8), padding: EdgeInsets.only(top: 8),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
if (_auditError != null) { if (state.error != null) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Row( child: Row(
@@ -1464,14 +1141,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [ children: [
const Text('더 불러오지 못했습니다.'), const Text('더 불러오지 못했습니다.'),
TextButton( TextButton(
onPressed: () => _loadAuditLogs(), onPressed: () => ref.read(authTimelineProvider.notifier).loadMore(),
child: const Text('재시도'), child: const Text('재시도'),
), ),
], ],
), ),
); );
} }
if (_auditNextCursor == null || _auditNextCursor!.isEmpty) { if (state.nextCursor == null || state.nextCursor!.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: Text(

View 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();
});
}