forked from baron/baron-sso
Merge pull request 'feature/hydra-content' (#221) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#221
This commit is contained in:
@@ -3304,7 +3304,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
Name: name,
|
||||
Logo: extractHydraClientLogo(client.Metadata),
|
||||
URL: clientURL,
|
||||
Status: hydraClientStatus(client.Metadata),
|
||||
Status: "active", // Hydra 세션이 있으면 활성
|
||||
Scopes: scopes,
|
||||
},
|
||||
lastAuth: lastAuth,
|
||||
@@ -3327,6 +3327,143 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함)
|
||||
if h.ConsentRepo != nil {
|
||||
for _, subject := range subjects {
|
||||
dbConsents, err := h.ConsentRepo.ListBySubject(c.Context(), subject)
|
||||
if err != nil {
|
||||
slog.Error("failed to list db consents for subject", "subject", subject, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, dc := range dbConsents {
|
||||
if _, exists := records[dc.ClientID]; exists {
|
||||
// 이미 Hydra 세션으로 존재하면 skip (active 우선)
|
||||
continue
|
||||
}
|
||||
|
||||
// Hydra에서 클라이언트 정보 조회 (메타데이터용)
|
||||
client, err := h.Hydra.GetClient(c.Context(), dc.ClientID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get client info from hydra for inactive rp", "client_id", dc.ClientID, "error", err)
|
||||
// Hydra에 정보가 없더라도 기본 정보로 추가
|
||||
records[dc.ClientID] = &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
ID: dc.ClientID,
|
||||
Name: dc.ClientID,
|
||||
Status: "inactive",
|
||||
Scopes: dc.GrantedScopes,
|
||||
},
|
||||
lastAuth: dc.UpdatedAt,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(client.ClientName)
|
||||
if name == "" {
|
||||
name = client.ClientID
|
||||
}
|
||||
|
||||
clientURL := strings.TrimSpace(client.ClientURI)
|
||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
|
||||
records[dc.ClientID] = &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
ID: dc.ClientID,
|
||||
Name: name,
|
||||
Logo: extractHydraClientLogo(client.Metadata),
|
||||
URL: clientURL,
|
||||
Status: "inactive",
|
||||
Scopes: dc.GrantedScopes,
|
||||
},
|
||||
lastAuth: dc.UpdatedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [New] Audit Log Scan for recent history fallback (timeline 200 items)
|
||||
// Hydra 세션이나 로컬 DB(ConsentRepo)에 없지만 최근 활동 이력이 있는 앱을 보강
|
||||
if h.AuditRepo != nil {
|
||||
for _, subject := range subjects {
|
||||
auditLogs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 200)
|
||||
if err != nil {
|
||||
slog.Error("failed to scan audit logs for linked rps", "error", err, "subject", subject)
|
||||
continue
|
||||
}
|
||||
for _, log := range auditLogs {
|
||||
var details struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name"`
|
||||
Scopes interface{} `json:"scopes"`
|
||||
}
|
||||
// 로그 Details 파싱
|
||||
if err := json.Unmarshal([]byte(log.Details), &details); err != nil {
|
||||
continue
|
||||
}
|
||||
if details.ClientID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 이미 records에 있으면(Active or ConsentRepo) 패스
|
||||
if _, exists := records[details.ClientID]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// 스코프 추출 (consent.granted인 경우)
|
||||
scopes := []string{}
|
||||
if sList, ok := details.Scopes.([]interface{}); ok {
|
||||
for _, s := range sList {
|
||||
if str, ok := s.(string); ok {
|
||||
scopes = append(scopes, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 레코드 생성
|
||||
record := &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
ID: details.ClientID,
|
||||
Name: details.ClientName, // revoked 로그일 경우 비어있을 수 있음
|
||||
Status: "inactive",
|
||||
Scopes: scopes,
|
||||
},
|
||||
lastAuth: log.Timestamp,
|
||||
}
|
||||
|
||||
// Hydra에서 최신 메타데이터 조회 시도
|
||||
client, err := h.Hydra.GetClient(c.Context(), details.ClientID)
|
||||
if err == nil {
|
||||
name := strings.TrimSpace(client.ClientName)
|
||||
if name == "" {
|
||||
name = client.ClientID
|
||||
}
|
||||
record.Name = name
|
||||
record.Logo = extractHydraClientLogo(client.Metadata)
|
||||
|
||||
clientURL := strings.TrimSpace(client.ClientURI)
|
||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
record.URL = clientURL
|
||||
} else {
|
||||
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
|
||||
if record.Name == "" {
|
||||
record.Name = details.ClientID
|
||||
}
|
||||
}
|
||||
|
||||
records[details.ClientID] = record
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ordered := make([]*linkedRpRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
ordered = append(ordered, record)
|
||||
@@ -3336,7 +3473,10 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
})
|
||||
|
||||
items := make([]linkedRpSummary, 0, len(ordered))
|
||||
for _, record := range ordered {
|
||||
for i, record := range ordered {
|
||||
if i >= 100 {
|
||||
break
|
||||
}
|
||||
if !record.lastAuth.IsZero() {
|
||||
record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(req)
|
||||
}
|
||||
|
||||
// --- Deprecated Tenant-based IdP Config Methods ---
|
||||
|
||||
// ListIdpConfigsForTenant handles listing all IdP configurations for a tenant.
|
||||
|
||||
@@ -12,6 +12,7 @@ type ClientConsentRepository interface {
|
||||
Delete(ctx context.Context, subject, clientID string) error
|
||||
List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
|
||||
ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
|
||||
ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error)
|
||||
}
|
||||
|
||||
type clientConsentRepo struct {
|
||||
@@ -90,3 +91,12 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID
|
||||
|
||||
return consents, total, err
|
||||
}
|
||||
|
||||
func (r *clientConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) {
|
||||
var consents []domain.ClientConsent
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("subject = ?", subject).
|
||||
Order("updated_at DESC").
|
||||
Find(&consents).Error
|
||||
return consents, err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -28,38 +28,7 @@ Future<List<LinkedRp>> _fetchLinkedRps() async {
|
||||
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>{
|
||||
@@ -104,13 +73,9 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||
}
|
||||
}
|
||||
|
||||
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});
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/auth_token_store.dart';
|
||||
import 'package:userfront/core/services/http_client.dart';
|
||||
|
||||
class LinkedRp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String logo;
|
||||
final String url;
|
||||
final String status;
|
||||
final List<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() ?? 'unknown',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||
@override
|
||||
Future<List<LinkedRp>> build() async {
|
||||
return _fetchLinkedRps();
|
||||
}
|
||||
|
||||
String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
Future<List<LinkedRp>> _fetchLinkedRps() async {
|
||||
try {
|
||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
|
||||
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <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: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LinkedRp.fromJson)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => _fetchLinkedRps());
|
||||
}
|
||||
|
||||
Future<void> revokeRp(String clientId) async {
|
||||
await AuthProxyService.revokeLinkedRp(clientId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
||||
return LinkedRpsNotifier();
|
||||
});
|
||||
@@ -4,13 +4,16 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../domain/providers/linked_rps_provider.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/ui/layout_breakpoints.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
import '../domain/dashboard_providers.dart';
|
||||
import '../domain/models.dart';
|
||||
import '../domain/models.dart' hide LinkedRp;
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
@@ -26,7 +29,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
static const _subtle = Color(0xFFF7F8FA);
|
||||
|
||||
final ScrollController _pageScrollController = ScrollController();
|
||||
final ScrollController _rpScrollController = ScrollController();
|
||||
final List<AuditLogEntry> _auditLogs = [];
|
||||
String? _auditNextCursor;
|
||||
bool _auditLoading = false;
|
||||
bool _auditLoadingMore = false;
|
||||
String? _auditError;
|
||||
bool _isRevoking = false;
|
||||
|
||||
bool _showAllActivities = false;
|
||||
final Set<String> _revokedClientIds = {};
|
||||
|
||||
@@ -34,11 +44,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageScrollController.addListener(_onPageScroll);
|
||||
_loadAuditLogs(reset: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageScrollController.dispose();
|
||||
_rpScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -71,7 +83,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
|
||||
setState(() => _isRevoking = true);
|
||||
try {
|
||||
await AuthProxyService.revokeLinkedRp(clientId);
|
||||
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$appName 연동이 해지되었습니다.')),
|
||||
@@ -80,7 +92,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
_revokedClientIds.add(clientId);
|
||||
});
|
||||
ref.invalidate(linkedRpsProvider);
|
||||
ref.invalidate(rpHistoryProvider);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -113,7 +124,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
context: context,
|
||||
builder: (context) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final historyState = ref.watch(rpHistoryProvider);
|
||||
return AlertDialog(
|
||||
title: Text(item.appName),
|
||||
content: SizedBox(
|
||||
@@ -139,48 +149,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
const SizedBox(height: 24),
|
||||
const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
historyState.when(
|
||||
loading: () => const SizedBox(height: 20, child: LinearProgressIndicator()),
|
||||
error: (_, __) => const Text('이력을 불러올 수 없습니다.', style: TextStyle(color: Colors.grey)),
|
||||
data: (history) {
|
||||
final filtered = history.where((h) => h.clientId == item.clientId).toList();
|
||||
if (filtered.isEmpty) {
|
||||
return Text('최근 인증: ${item.lastAuthAt}');
|
||||
}
|
||||
final h = filtered.first;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (h.lastApprovedAt != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, size: 16, color: Colors.green),
|
||||
const SizedBox(width: 8),
|
||||
Text('승인: ${_formatDateTime(h.lastApprovedAt!)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (h.lastRevokedAt != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cancel_outlined, size: 16, color: Colors.redAccent),
|
||||
const SizedBox(width: 8),
|
||||
Text('해지: ${_formatDateTime(h.lastRevokedAt!)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'현재 상태: ${h.status == 'active' ? '활성' : '해지됨'}',
|
||||
style: TextStyle(color: h.status == 'active' ? Colors.green : Colors.grey),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('최근 인증: ${item.lastAuthAt}'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'현재 상태: ${item.status}',
|
||||
style: TextStyle(color: item.status == '활성' ? Colors.green : Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -255,14 +233,102 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
_revokedClientIds.clear();
|
||||
});
|
||||
ref.invalidate(linkedRpsProvider);
|
||||
ref.invalidate(rpHistoryProvider);
|
||||
final linkedFuture = ref.read(linkedRpsProvider.future);
|
||||
final historyFuture = ref.read(rpHistoryProvider.future);
|
||||
await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
linkedFuture,
|
||||
historyFuture,
|
||||
|
||||
await Future.wait([
|
||||
ref.read(linkedRpsProvider.future),
|
||||
ref.read(authTimelineProvider.notifier).refresh(),
|
||||
]);
|
||||
|
||||
await _loadAuditLogs(reset: true);
|
||||
await ref.read(linkedRpsProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
|
||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
final queryParameters = <String, String>{
|
||||
'limit': '20',
|
||||
};
|
||||
if (cursor != null && cursor.isNotEmpty) {
|
||||
queryParameters['cursor'] = cursor;
|
||||
}
|
||||
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline')
|
||||
.replace(queryParameters: queryParameters);
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
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 = items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(AuditLogEntry.fromJson)
|
||||
.toList();
|
||||
|
||||
return AuditPage(items: logs, nextCursor: nextCursor);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt() {
|
||||
@@ -560,14 +626,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
_buildHeaderCard(userName, department, sessionIssuedAt),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
_buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'),
|
||||
_buildSectionTitle('나의 App 현황', '현재 연결된 앱과 최근 인증 상태입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildActivitySection(isMobile),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('과거 연동 앱', '이전에 연동했던 앱 목록입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildPastRps(isMobile),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildAccessHistory(timelineState, timelineWide),
|
||||
@@ -670,31 +732,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
|
||||
Widget _buildActivitySection(bool isMobile) {
|
||||
final linkedState = ref.watch(linkedRpsProvider);
|
||||
return linkedState.when(
|
||||
loading: () => const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())),
|
||||
error: (_, __) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'연동 정보를 불러오지 못했습니다.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
final linkedRpsState = ref.watch(linkedRpsProvider);
|
||||
|
||||
return linkedRpsState.when(
|
||||
data: (linkedRps) {
|
||||
final activities = _buildActivityItems(linkedRps);
|
||||
final grid = _buildActivityGrid(activities, isMobile);
|
||||
|
||||
if (activities.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'연동된 RP가 없습니다.',
|
||||
'연동된 앱이 없습니다.',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'RP를 연동하면 최근 활동과 상태가 표시됩니다.',
|
||||
'앱을 연동하면 최근 활동과 상태가 표시됩니다.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
@@ -702,63 +757,44 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
return _buildActivityGrid(activities, isMobile);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPastRps(bool isMobile) {
|
||||
final historyState = ref.watch(rpHistoryProvider);
|
||||
return historyState.when(
|
||||
loading: () => const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())),
|
||||
error: (_, __) => Column(
|
||||
loading: () => const SizedBox(
|
||||
height: 100,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stack) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'과거 연동 정보를 불러오지 못했습니다.',
|
||||
'연동 정보를 불러오지 못했습니다.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => ref.read(linkedRpsProvider.notifier).refresh(),
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
],
|
||||
),
|
||||
data: (history) {
|
||||
final pastItems = history.where((h) => h.status != 'active').toList();
|
||||
if (pastItems.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'과거 연동 이력이 없습니다.',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final activities = pastItems.map((h) => _ActivityItem(
|
||||
clientId: h.clientId,
|
||||
appName: h.clientName.isNotEmpty ? h.clientName : h.clientId,
|
||||
lastAuthAt: h.lastRevokedAt != null ? '해지: ${_formatDateTime(h.lastRevokedAt!)}' : '해지됨',
|
||||
status: '해지됨',
|
||||
scopes: h.scopes,
|
||||
canLogout: false,
|
||||
isRevoked: true,
|
||||
onRevoke: null,
|
||||
)).toList();
|
||||
|
||||
return _buildActivityGrid(activities, isMobile);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
||||
final items = <_ActivityItem>[];
|
||||
for (final rp in linkedRps) {
|
||||
final isRevoked = _revokedClientIds.contains(rp.id);
|
||||
final normalizedStatus = rp.status.toLowerCase();
|
||||
// status가 'inactive'로 내려올 수 있으므로 이를 반영
|
||||
final isActiveInApi = normalizedStatus == 'active' || normalizedStatus == '';
|
||||
final isRevoked = !isActiveInApi;
|
||||
|
||||
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
||||
? _formatDateTime(rp.lastAuthenticatedAt!)
|
||||
: '연동됨';
|
||||
|
||||
final normalizedStatus = rp.status.toLowerCase();
|
||||
final statusLabel = isRevoked ? '비활성' : (normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성');
|
||||
final statusLabel = isRevoked ? '해지됨' : '활성';
|
||||
final name = rp.name.isNotEmpty ? rp.name : rp.id;
|
||||
|
||||
items.add(
|
||||
_ActivityItem(
|
||||
clientId: rp.id,
|
||||
@@ -769,32 +805,44 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
canLogout: false,
|
||||
isRevoked: isRevoked,
|
||||
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
||||
url: rp.url, // URL 전달
|
||||
url: rp.url,
|
||||
lastAuthDateTime: rp.lastAuthenticatedAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬 로직 적용: 활성 우선 -> 최근 인증 최신순 -> 비활성
|
||||
items.sort((a, b) {
|
||||
final aActive = a.status == '활성';
|
||||
final bActive = b.status == '활성';
|
||||
|
||||
if (aActive && !bActive) return -1;
|
||||
if (!aActive && bActive) return 1;
|
||||
|
||||
// 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순
|
||||
if (a.lastAuthDateTime != null && b.lastAuthDateTime != null) {
|
||||
return b.lastAuthDateTime!.compareTo(a.lastAuthDateTime!);
|
||||
}
|
||||
if (a.lastAuthDateTime != null) return -1;
|
||||
if (b.lastAuthDateTime != null) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
||||
if (activities.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
if (!isMobile) {
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: activities.map(_buildActivityCard).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
final visibleCount = _showAllActivities ? activities.length : 4;
|
||||
final visibleActivities = activities.take(visibleCount).toList();
|
||||
final shouldShowToggle = activities.length > 4;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GridView.builder(
|
||||
// 더보기를 누르지 않은 경우: 최대 4개 노출 (Grid/Wrap)
|
||||
if (!_showAllActivities) {
|
||||
final visibleActivities = activities.take(4).toList();
|
||||
Widget grid;
|
||||
if (isMobile) {
|
||||
grid = GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
@@ -805,23 +853,117 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
),
|
||||
itemCount: visibleActivities.length,
|
||||
itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]),
|
||||
),
|
||||
if (shouldShowToggle)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showAllActivities = !_showAllActivities;
|
||||
});
|
||||
},
|
||||
child: Text(_showAllActivities ? '접기' : '더보기'),
|
||||
);
|
||||
} else {
|
||||
grid = Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: visibleActivities.map(_buildActivityCard).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
grid,
|
||||
if (shouldShowToggle)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _showAllActivities = true),
|
||||
icon: const Icon(Icons.add, size: 18, color: Colors.blueAccent),
|
||||
label: const Text('더보기', style: TextStyle(color: Colors.blueAccent, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 더보기를 누른 경우: 가로 슬라이더/캐러셀 전환
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: ListView.separated(
|
||||
controller: _rpScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: activities.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => UnconstrainedBox(
|
||||
alignment: Alignment.topCenter,
|
||||
child: _buildActivityCard(activities[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 왼쪽 이동 버튼
|
||||
Positioned(
|
||||
left: 0,
|
||||
child: _buildScrollButton(
|
||||
icon: Icons.chevron_left,
|
||||
onPressed: () => _rpScrollController.animateTo(
|
||||
(_rpScrollController.offset - 300).clamp(0, _rpScrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 오른쪽 이동 버튼
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: _buildScrollButton(
|
||||
icon: Icons.chevron_right,
|
||||
onPressed: () => _rpScrollController.animateTo(
|
||||
(_rpScrollController.offset + 300).clamp(0, _rpScrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() {
|
||||
_showAllActivities = false;
|
||||
_rpScrollController.jumpTo(0); // 접을 때 위치 초기화
|
||||
}),
|
||||
icon: const Icon(Icons.close, size: 18, color: Colors.grey),
|
||||
label: const Text('접기', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollButton({required IconData icon, required VoidCallback onPressed}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, color: _ink),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivityCard(_ActivityItem item) {
|
||||
final isActive = item.status == '활성';
|
||||
final statusColor = isActive ? Colors.green : Colors.grey;
|
||||
@@ -1172,6 +1314,7 @@ class _ActivityItem {
|
||||
final bool isRevoked;
|
||||
final VoidCallback? onLogout;
|
||||
final VoidCallback? onRevoke;
|
||||
final DateTime? lastAuthDateTime;
|
||||
|
||||
_ActivityItem({
|
||||
required this.clientId,
|
||||
@@ -1184,5 +1327,6 @@ class _ActivityItem {
|
||||
this.isRevoked = false,
|
||||
this.onLogout,
|
||||
this.onRevoke,
|
||||
this.lastAuthDateTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user