1
0
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:
2026-02-09 12:32:30 +09:00
7 changed files with 547 additions and 177 deletions

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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"
)

View File

@@ -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});

View File

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

View File

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