forked from baron/baron-sso
Merge commit '226de652e370dc06c34bdcd14753c3dade28bb2c'
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
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/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(
|
||||||
|
|||||||
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