forked from baron/baron-sso
활동상황 UX 확장 기능 구현 (스코프, 이력, 과거 앱)
This commit is contained in:
@@ -145,6 +145,44 @@ class LinkedRp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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});
|
||||||
|
|
||||||
@@ -167,6 +205,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
bool _isRevoking = false;
|
bool _isRevoking = false;
|
||||||
|
|
||||||
Future<List<LinkedRp>>? _linkedRpsFuture;
|
Future<List<LinkedRp>>? _linkedRpsFuture;
|
||||||
|
Future<List<RpHistoryItem>>? _rpHistoryFuture;
|
||||||
bool _showAllActivities = false;
|
bool _showAllActivities = false;
|
||||||
final Set<String> _revokedClientIds = {};
|
final Set<String> _revokedClientIds = {};
|
||||||
|
|
||||||
@@ -176,6 +215,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
_pageScrollController.addListener(_onPageScroll);
|
_pageScrollController.addListener(_onPageScroll);
|
||||||
_loadAuditLogs(reset: true);
|
_loadAuditLogs(reset: true);
|
||||||
_linkedRpsFuture = _fetchLinkedRps();
|
_linkedRpsFuture = _fetchLinkedRps();
|
||||||
|
_rpHistoryFuture = _fetchRpHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -248,6 +288,94 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showRpDetails(_ActivityItem item) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(item.appName),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('권한 (Scopes)', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (item.scopes.isEmpty)
|
||||||
|
const Text('요청된 권한이 없습니다.', style: TextStyle(color: Colors.grey))
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: item.scopes.map((s) => Chip(
|
||||||
|
label: Text(s, style: const TextStyle(fontSize: 12)),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
)).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FutureBuilder<List<RpHistoryItem>>(
|
||||||
|
future: _rpHistoryFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const SizedBox(height: 20, child: LinearProgressIndicator());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError || !snapshot.hasData) {
|
||||||
|
return const Text('이력을 불러올 수 없습니다.', style: TextStyle(color: Colors.grey));
|
||||||
|
}
|
||||||
|
final history = snapshot.data!.where((h) => h.clientId == item.clientId).toList();
|
||||||
|
if (history.isEmpty) {
|
||||||
|
// Fallback to item data if no history found (e.g. fresh login)
|
||||||
|
return Text('최근 인증: ${item.lastAuthAt}');
|
||||||
|
}
|
||||||
|
final h = history.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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('닫기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
|
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
@@ -429,6 +557,37 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
return linkedRps;
|
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() {
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
@@ -727,6 +886,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
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(timelineWide),
|
_buildAccessHistory(timelineWide),
|
||||||
@@ -871,6 +1034,43 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPastRps(bool isMobile) {
|
||||||
|
return FutureBuilder<List<RpHistoryItem>>(
|
||||||
|
future: _rpHistoryFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const SizedBox(height: 40, child: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final pastItems = (snapshot.data ?? []).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) {
|
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
||||||
final items = <_ActivityItem>[];
|
final items = <_ActivityItem>[];
|
||||||
for (final rp in linkedRps) {
|
for (final rp in linkedRps) {
|
||||||
@@ -888,6 +1088,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
appName: name,
|
appName: name,
|
||||||
lastAuthAt: lastAuthLabel,
|
lastAuthAt: lastAuthLabel,
|
||||||
status: statusLabel,
|
status: statusLabel,
|
||||||
|
scopes: rp.scopes,
|
||||||
canLogout: false,
|
canLogout: false,
|
||||||
isRevoked: isRevoked,
|
isRevoked: isRevoked,
|
||||||
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
||||||
@@ -1006,6 +1207,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => _showRpDetails(item),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: _ink,
|
||||||
|
side: const BorderSide(color: _border),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
|
child: const Text('상세정보', style: TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
if (item.canLogout)
|
if (item.canLogout)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
@@ -1277,6 +1490,7 @@ class _ActivityItem {
|
|||||||
final String lastAuthAt;
|
final String lastAuthAt;
|
||||||
final String status;
|
final String status;
|
||||||
final String? url;
|
final String? url;
|
||||||
|
final List<String> scopes;
|
||||||
final bool canLogout;
|
final bool canLogout;
|
||||||
final bool isRevoked;
|
final bool isRevoked;
|
||||||
final VoidCallback? onLogout;
|
final VoidCallback? onLogout;
|
||||||
@@ -1287,6 +1501,7 @@ class _ActivityItem {
|
|||||||
required this.appName,
|
required this.appName,
|
||||||
required this.lastAuthAt,
|
required this.lastAuthAt,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
required this.scopes,
|
||||||
required this.canLogout,
|
required this.canLogout,
|
||||||
this.url,
|
this.url,
|
||||||
this.isRevoked = false,
|
this.isRevoked = false,
|
||||||
|
|||||||
Reference in New Issue
Block a user