forked from baron/baron-sso
Apply stashed changes after rebase
This commit is contained in:
@@ -3304,7 +3304,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
Name: name,
|
Name: name,
|
||||||
Logo: extractHydraClientLogo(client.Metadata),
|
Logo: extractHydraClientLogo(client.Metadata),
|
||||||
URL: clientURL,
|
URL: clientURL,
|
||||||
Status: hydraClientStatus(client.Metadata),
|
Status: "active", // Hydra 세션이 있으면 활성
|
||||||
Scopes: scopes,
|
Scopes: scopes,
|
||||||
},
|
},
|
||||||
lastAuth: lastAuth,
|
lastAuth: lastAuth,
|
||||||
@@ -3327,6 +3327,65 @@ 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ordered := make([]*linkedRpRecord, 0, len(records))
|
ordered := make([]*linkedRpRecord, 0, len(records))
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
ordered = append(ordered, record)
|
ordered = append(ordered, record)
|
||||||
@@ -3336,7 +3395,10 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
items := make([]linkedRpSummary, 0, len(ordered))
|
items := make([]linkedRpSummary, 0, len(ordered))
|
||||||
for _, record := range ordered {
|
for i, record := range ordered {
|
||||||
|
if i >= 100 {
|
||||||
|
break
|
||||||
|
}
|
||||||
if !record.lastAuth.IsZero() {
|
if !record.lastAuth.IsZero() {
|
||||||
record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
|
record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type ClientConsentRepository interface {
|
|||||||
Delete(ctx context.Context, subject, clientID string) error
|
Delete(ctx context.Context, subject, clientID string) error
|
||||||
List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, 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)
|
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 {
|
type clientConsentRepo struct {
|
||||||
@@ -90,3 +91,12 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID
|
|||||||
|
|
||||||
return consents, total, err
|
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"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../domain/providers/linked_rps_provider.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/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
@@ -12,6 +13,97 @@ import '../../profile/domain/notifiers/profile_notifier.dart';
|
|||||||
import '../domain/dashboard_providers.dart';
|
import '../domain/dashboard_providers.dart';
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart';
|
||||||
|
|
||||||
|
class AuditLogEntry {
|
||||||
|
final String eventId;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String userId;
|
||||||
|
final String eventType;
|
||||||
|
final String status;
|
||||||
|
final String authMethod;
|
||||||
|
final String ipAddress;
|
||||||
|
final String userAgent;
|
||||||
|
final String sessionId;
|
||||||
|
final String details;
|
||||||
|
final String source;
|
||||||
|
final String clientId;
|
||||||
|
final String appName;
|
||||||
|
final String parentSessionId;
|
||||||
|
|
||||||
|
AuditLogEntry({
|
||||||
|
required this.eventId,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.userId,
|
||||||
|
required this.eventType,
|
||||||
|
required this.status,
|
||||||
|
required this.authMethod,
|
||||||
|
required this.ipAddress,
|
||||||
|
required this.userAgent,
|
||||||
|
required this.sessionId,
|
||||||
|
required this.details,
|
||||||
|
required this.source,
|
||||||
|
required this.clientId,
|
||||||
|
required this.appName,
|
||||||
|
required this.parentSessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuditLogEntry.fromJson(Map<String, dynamic> json) {
|
||||||
|
final timestampRaw = json['timestamp']?.toString() ?? '';
|
||||||
|
DateTime parsedTimestamp;
|
||||||
|
try {
|
||||||
|
parsedTimestamp = DateTime.parse(timestampRaw).toLocal();
|
||||||
|
} catch (_) {
|
||||||
|
parsedTimestamp = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuditLogEntry(
|
||||||
|
eventId: json['event_id'] ?? '',
|
||||||
|
timestamp: parsedTimestamp,
|
||||||
|
userId: json['user_id'] ?? '',
|
||||||
|
eventType: json['event_type'] ?? '',
|
||||||
|
status: json['status'] ?? '',
|
||||||
|
authMethod: json['auth_method'] ?? '',
|
||||||
|
ipAddress: json['ip_address'] ?? '',
|
||||||
|
userAgent: json['user_agent'] ?? '',
|
||||||
|
sessionId: json['session_id'] ?? '',
|
||||||
|
details: json['details'] ?? '',
|
||||||
|
source: json['source'] ?? '',
|
||||||
|
clientId: json['client_id'] ?? '',
|
||||||
|
appName: json['app_name'] ?? '',
|
||||||
|
parentSessionId: json['parent_session_id'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> get detailMap {
|
||||||
|
if (details.isEmpty) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return jsonDecode(details) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get path {
|
||||||
|
final detailPath = detailMap['path']?.toString();
|
||||||
|
if (detailPath != null && detailPath.isNotEmpty) {
|
||||||
|
return detailPath;
|
||||||
|
}
|
||||||
|
final parts = eventType.split(' ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return parts.sublist(1).join(' ');
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuditPage {
|
||||||
|
final List<AuditLogEntry> items;
|
||||||
|
final String? nextCursor;
|
||||||
|
|
||||||
|
const _AuditPage({required this.items, this.nextCursor});
|
||||||
|
}
|
||||||
|
|
||||||
class DashboardScreen extends ConsumerStatefulWidget {
|
class DashboardScreen extends ConsumerStatefulWidget {
|
||||||
const DashboardScreen({super.key});
|
const DashboardScreen({super.key});
|
||||||
|
|
||||||
@@ -26,7 +118,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
static const _subtle = Color(0xFFF7F8FA);
|
static const _subtle = Color(0xFFF7F8FA);
|
||||||
|
|
||||||
final ScrollController _pageScrollController = ScrollController();
|
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 _isRevoking = false;
|
||||||
|
|
||||||
bool _showAllActivities = false;
|
bool _showAllActivities = false;
|
||||||
final Set<String> _revokedClientIds = {};
|
final Set<String> _revokedClientIds = {};
|
||||||
|
|
||||||
@@ -34,11 +133,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageScrollController.addListener(_onPageScroll);
|
_pageScrollController.addListener(_onPageScroll);
|
||||||
|
_loadAuditLogs(reset: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pageScrollController.dispose();
|
_pageScrollController.dispose();
|
||||||
|
_rpScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +172,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
setState(() => _isRevoking = true);
|
setState(() => _isRevoking = true);
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.revokeLinkedRp(clientId);
|
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('$appName 연동이 해지되었습니다.')),
|
SnackBar(content: Text('$appName 연동이 해지되었습니다.')),
|
||||||
@@ -264,6 +365,95 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
ref.read(authTimelineProvider.notifier).refresh(),
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DateTime? _getJwtIssuedAt() {
|
DateTime? _getJwtIssuedAt() {
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
@@ -560,14 +750,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
_buildHeaderCard(userName, department, sessionIssuedAt),
|
_buildHeaderCard(userName, department, sessionIssuedAt),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
],
|
],
|
||||||
_buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'),
|
_buildSectionTitle('나의 App 현황', '현재 연결된 앱과 최근 인증 상태입니다.'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildActivitySection(isMobile),
|
_buildActivitySection(isMobile),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
_buildSectionTitle('과거 연동 앱', '이전에 연동했던 앱 목록입니다.'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildPastRps(isMobile),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
_buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'),
|
_buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildAccessHistory(timelineState, timelineWide),
|
_buildAccessHistory(timelineState, timelineWide),
|
||||||
@@ -670,31 +856,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActivitySection(bool isMobile) {
|
Widget _buildActivitySection(bool isMobile) {
|
||||||
final linkedState = ref.watch(linkedRpsProvider);
|
final linkedRpsState = ref.watch(linkedRpsProvider);
|
||||||
return linkedState.when(
|
|
||||||
loading: () => const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())),
|
return linkedRpsState.when(
|
||||||
error: (_, __) => Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'연동 정보를 불러오지 못했습니다.',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
data: (linkedRps) {
|
data: (linkedRps) {
|
||||||
final activities = _buildActivityItems(linkedRps);
|
final activities = _buildActivityItems(linkedRps);
|
||||||
|
final grid = _buildActivityGrid(activities, isMobile);
|
||||||
|
|
||||||
if (activities.isEmpty) {
|
if (activities.isEmpty) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'연동된 RP가 없습니다.',
|
'연동된 앱이 없습니다.',
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
|
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'RP를 연동하면 최근 활동과 상태가 표시됩니다.',
|
'앱을 연동하면 최근 활동과 상태가 표시됩니다.',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -702,6 +881,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
return _buildActivityGrid(activities, isMobile);
|
return _buildActivityGrid(activities, isMobile);
|
||||||
},
|
},
|
||||||
|
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('다시 시도'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,14 +948,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
||||||
final items = <_ActivityItem>[];
|
final items = <_ActivityItem>[];
|
||||||
for (final rp in linkedRps) {
|
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
|
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
||||||
? _formatDateTime(rp.lastAuthenticatedAt!)
|
? _formatDateTime(rp.lastAuthenticatedAt!)
|
||||||
: '연동됨';
|
: '연동됨';
|
||||||
|
|
||||||
final normalizedStatus = rp.status.toLowerCase();
|
final statusLabel = isRevoked ? '해지됨' : '활성';
|
||||||
final statusLabel = isRevoked ? '비활성' : (normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성');
|
|
||||||
final name = rp.name.isNotEmpty ? rp.name : rp.id;
|
final name = rp.name.isNotEmpty ? rp.name : rp.id;
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
_ActivityItem(
|
_ActivityItem(
|
||||||
clientId: rp.id,
|
clientId: rp.id,
|
||||||
@@ -769,32 +970,44 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
canLogout: false,
|
canLogout: false,
|
||||||
isRevoked: isRevoked,
|
isRevoked: isRevoked,
|
||||||
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
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;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
||||||
|
if (activities.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
if (!isMobile) {
|
final shouldShowToggle = activities.length > 5;
|
||||||
return Wrap(
|
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: activities.map(_buildActivityCard).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final visibleCount = _showAllActivities ? activities.length : 4;
|
// 더보기를 누르지 않은 경우: 최대 5개 노출 (Grid/Wrap)
|
||||||
final visibleActivities = activities.take(visibleCount).toList();
|
if (!_showAllActivities) {
|
||||||
final shouldShowToggle = activities.length > 4;
|
final visibleActivities = activities.take(5).toList();
|
||||||
|
Widget grid;
|
||||||
return Column(
|
if (isMobile) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
grid = GridView.builder(
|
||||||
children: [
|
|
||||||
GridView.builder(
|
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
@@ -805,23 +1018,117 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
itemCount: visibleActivities.length,
|
itemCount: visibleActivities.length,
|
||||||
itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]),
|
itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]),
|
||||||
),
|
);
|
||||||
if (shouldShowToggle)
|
} else {
|
||||||
Align(
|
grid = Wrap(
|
||||||
alignment: Alignment.centerRight,
|
spacing: 12,
|
||||||
child: TextButton(
|
runSpacing: 12,
|
||||||
onPressed: () {
|
children: visibleActivities.map(_buildActivityCard).toList(),
|
||||||
setState(() {
|
);
|
||||||
_showAllActivities = !_showAllActivities;
|
}
|
||||||
});
|
|
||||||
},
|
return Column(
|
||||||
child: Text(_showAllActivities ? '접기' : '더보기'),
|
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) {
|
Widget _buildActivityCard(_ActivityItem item) {
|
||||||
final isActive = item.status == '활성';
|
final isActive = item.status == '활성';
|
||||||
final statusColor = isActive ? Colors.green : Colors.grey;
|
final statusColor = isActive ? Colors.green : Colors.grey;
|
||||||
@@ -1172,6 +1479,7 @@ class _ActivityItem {
|
|||||||
final bool isRevoked;
|
final bool isRevoked;
|
||||||
final VoidCallback? onLogout;
|
final VoidCallback? onLogout;
|
||||||
final VoidCallback? onRevoke;
|
final VoidCallback? onRevoke;
|
||||||
|
final DateTime? lastAuthDateTime;
|
||||||
|
|
||||||
_ActivityItem({
|
_ActivityItem({
|
||||||
required this.clientId,
|
required this.clientId,
|
||||||
@@ -1184,5 +1492,6 @@ class _ActivityItem {
|
|||||||
this.isRevoked = false,
|
this.isRevoked = false,
|
||||||
this.onLogout,
|
this.onLogout,
|
||||||
this.onRevoke,
|
this.onRevoke,
|
||||||
|
this.lastAuthDateTime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user