1
0
forked from baron/baron-sso

접근 이력 스크롤 조회 기능 추가

This commit is contained in:
Lectom C Han
2026-02-02 14:03:54 +09:00
parent 7e662c9878
commit 1c0a5ed272
15 changed files with 1265 additions and 231 deletions

View File

@@ -82,6 +82,13 @@ class AuditLogEntry {
}
}
class _AuditPage {
final List<AuditLogEntry> items;
final String? nextCursor;
const _AuditPage({required this.items, this.nextCursor});
}
class LinkedRp {
final String id;
final String name;
@@ -134,17 +141,30 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
static const _border = Color(0xFFE5E7EB);
static const _subtle = Color(0xFFF7F8FA);
Future<List<AuditLogEntry>>? _auditFuture;
final ScrollController _pageScrollController = ScrollController();
final List<AuditLogEntry> _auditLogs = [];
String? _auditNextCursor;
bool _auditLoading = false;
bool _auditLoadingMore = false;
String? _auditError;
Future<List<LinkedRp>>? _linkedRpsFuture;
bool _showAllActivities = false;
@override
void initState() {
super.initState();
_auditFuture = _fetchAuditLogs();
_pageScrollController.addListener(_onPageScroll);
_loadAuditLogs(reset: true);
_linkedRpsFuture = _fetchLinkedRps();
}
@override
void dispose() {
_pageScrollController.dispose();
super.dispose();
}
Future<void> _logout() async {
AuthTokenStore.clear();
AuthNotifier.instance.notify();
@@ -154,6 +174,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
context.push('/scan');
}
void _onPageScroll() {
if (!_pageScrollController.hasClients) {
return;
}
if (_pageScrollController.position.extentAfter < 240) {
_loadAuditLogs();
}
}
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
return SafeArea(
child: ListView(
@@ -208,13 +237,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Future<void> _refreshAll() async {
await ref.read(profileProvider.notifier).loadProfile();
await _loadAuditLogs(reset: true);
setState(() {
_auditFuture = _fetchAuditLogs();
_linkedRpsFuture = _fetchLinkedRps();
});
if (_auditFuture != null) {
await _auditFuture;
}
if (_linkedRpsFuture != null) {
await _linkedRpsFuture;
}
@@ -227,9 +253,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return dotenv.env[key] ?? fallback;
}
Future<List<AuditLogEntry>> _fetchAuditLogs() async {
Future<_AuditPage> _fetchAuditLogs({String? cursor}) async {
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline?limit=20');
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();
@@ -250,12 +283,53 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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 logs;
return _AuditPage(items: logs, nextCursor: nextCursor);
}
Future<void> _loadAuditLogs({bool reset = false}) async {
if (_auditLoading || _auditLoadingMore) {
return;
}
if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) {
return;
}
if (reset) {
setState(() {
_auditLogs.clear();
_auditNextCursor = null;
_auditError = null;
_auditLoading = true;
});
} else {
setState(() {
_auditLoadingMore = true;
});
}
try {
final page = await _fetchAuditLogs(cursor: _auditNextCursor);
setState(() {
_auditLogs.addAll(page.items);
_auditNextCursor = page.nextCursor;
_auditError = null;
});
} catch (_) {
setState(() {
_auditError = '접속이력을 불러오지 못했습니다.';
});
} finally {
setState(() {
_auditLoading = false;
_auditLoadingMore = false;
});
}
}
Future<List<LinkedRp>> _fetchLinkedRps() async {
@@ -320,6 +394,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return '$yyyy.$mm.$dd $hh:$min';
}
Widget _selectableText(String text, {TextStyle? style}) {
return SelectableText(text, style: style);
}
String _authMethodLabel() {
if (AuthTokenStore.usesCookie()) {
return 'Ory 세션';
@@ -360,7 +438,27 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
if (authMethod != 'QR') {
return Text(authMethod);
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText(authMethod);
}
final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent);
final tooltip = [
'승인 기기: $deviceLabel',
'승인 IP: ${approvedIp.isEmpty ? '-' : approvedIp}',
].join('\n');
return Tooltip(
message: tooltip,
child: _selectableText(
authMethod,
style: const TextStyle(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
),
),
);
}
final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? '';
final tooltip = approvedSessionId.isEmpty
@@ -393,7 +491,27 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
if (authMethod != 'QR') {
return Text('인증수단: $authMethod');
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText('인증수단: $authMethod');
}
final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent);
final tooltip = [
'승인 기기: $deviceLabel',
'승인 IP: ${approvedIp.isEmpty ? '-' : approvedIp}',
].join('\n');
return Tooltip(
message: tooltip,
child: _selectableText(
'인증수단: $authMethod',
style: const TextStyle(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
),
),
);
}
final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? '';
return InkWell(
@@ -496,6 +614,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final timelineWide = constraints.maxWidth >= 900;
final isMobile = constraints.maxWidth < 600;
return SingleChildScrollView(
controller: _pageScrollController,
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(24),
@@ -793,55 +912,45 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
Widget _buildAccessHistory(bool isWide) {
return FutureBuilder<List<AuditLogEntry>>(
future: _auditFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return _buildHistoryContainer(
child: const Center(child: CircularProgressIndicator()),
);
}
if (_auditLoading && _auditLogs.isEmpty) {
return _buildHistoryContainer(
child: const Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return _buildHistoryContainer(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('접속이력을 불러오지 못했습니다.'),
const SizedBox(height: 8),
TextButton(
onPressed: () {
setState(() {
_auditFuture = _fetchAuditLogs();
});
},
child: const Text('다시 시도'),
),
],
if (_auditError != null && _auditLogs.isEmpty) {
return _buildHistoryContainer(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('접속이력을 불러오지 못했습니다.'),
const SizedBox(height: 8),
TextButton(
onPressed: () => _loadAuditLogs(reset: true),
child: const Text('다시 시도'),
),
),
);
}
],
),
),
);
}
final logs = snapshot.data ?? [];
if (logs.isEmpty) {
return _buildHistoryContainer(
child: Center(
child: Text(
'최근 접속 이력이 없습니다.',
style: TextStyle(color: Colors.grey[600]),
),
),
);
}
if (_auditLogs.isEmpty) {
return _buildHistoryContainer(
child: Center(
child: Text(
'최근 접속 이력이 없습니다.',
style: TextStyle(color: Colors.grey[600]),
),
),
);
}
if (isWide) {
return _buildHistoryTable(logs);
}
return _buildHistoryList(logs);
},
);
if (isWide) {
return _buildHistoryTable(_auditLogs);
}
return _buildHistoryList(_auditLogs);
}
Widget _buildHistoryContainer({required Widget child}) {
@@ -859,46 +968,51 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildHistoryTable(List<AuditLogEntry> logs) {
return _buildHistoryContainer(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: DataTable(
columnSpacing: 16,
horizontalMargin: 12,
columns: const [
DataColumn(label: Text('Session ID')),
DataColumn(label: Text('접속일자')),
DataColumn(label: Text('애플리케이션')),
DataColumn(label: Text('IP')),
DataColumn(label: Text('접속환경')),
DataColumn(label: Text('인증수단')),
DataColumn(label: Text('인증결과')),
DataColumn(label: Text('현황')),
],
rows: logs.take(10).map((log) {
final statusLabel = log.status == 'success' ? '성공' : '실패';
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
final appLabel = _appLabelForPath(log.path);
final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel();
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
return DataRow(cells: [
DataCell(Text(log.sessionId.isEmpty ? '-' : log.sessionId)),
DataCell(Text(_formatDateTime(log.timestamp))),
DataCell(Text(appLabel)),
DataCell(Text(log.ipAddress.isEmpty ? '-' : log.ipAddress)),
DataCell(Text(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
DataCell(Text(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))),
const DataCell(Text('(준비중)', style: TextStyle(color: Colors.grey))),
]);
}).toList(),
),
),
);
},
child: Column(
children: [
LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: DataTable(
columnSpacing: 16,
horizontalMargin: 12,
columns: const [
DataColumn(label: Text('Session ID')),
DataColumn(label: Text('접속일자')),
DataColumn(label: Text('애플리케이션')),
DataColumn(label: Text('IP')),
DataColumn(label: Text('접속환경')),
DataColumn(label: Text('인증수단')),
DataColumn(label: Text('인증결과')),
DataColumn(label: Text('현황')),
],
rows: logs.map((log) {
final statusLabel = log.status == 'success' ? '성공' : '실패';
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
final appLabel = _appLabelForPath(log.path);
final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel();
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
return DataRow(cells: [
DataCell(_selectableText(log.sessionId.isEmpty ? '-' : log.sessionId)),
DataCell(_selectableText(_formatDateTime(log.timestamp))),
DataCell(_selectableText(appLabel)),
DataCell(_selectableText(log.ipAddress.isEmpty ? '-' : log.ipAddress)),
DataCell(_selectableText(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
DataCell(_selectableText(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))),
DataCell(_selectableText('(준비중)', style: const TextStyle(color: Colors.grey))),
]);
}).toList(),
),
),
);
},
),
_buildHistoryFooter(),
],
),
);
}
@@ -906,52 +1020,86 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildHistoryList(List<AuditLogEntry> logs) {
return _buildHistoryContainer(
child: Column(
children: logs.take(10).map((log) {
final statusLabel = log.status == 'success' ? '성공' : '실패';
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
final appLabel = _appLabelForPath(log.path);
final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel();
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
appLabel,
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
children: [
for (final log in logs)
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _selectableText(
_appLabelForPath(log.path),
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
),
),
),
Text(
statusLabel,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 6),
Text('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'),
Text('접속일자: ${_formatDateTime(log.timestamp)}'),
Text('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'),
Text('접속환경: $deviceLabel'),
_buildAuthMethodLine(log, authMethod),
Text('인증결과: $statusLabel'),
Text('현황: (준비중)', style: TextStyle(color: Colors.grey[600])),
],
_selectableText(
log.status == 'success' ? '성공' : '실패',
style: TextStyle(
color: log.status == 'success' ? Colors.green : Colors.redAccent,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 6),
_selectableText('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'),
_selectableText('접속일자: ${_formatDateTime(log.timestamp)}'),
_selectableText('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'),
_selectableText('접속환경: ${_deviceLabelFromUserAgent(log.userAgent)}'),
_buildAuthMethodLine(log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel()),
_selectableText('인증결과: ${log.status == 'success' ? '성공' : '실패'}'),
_selectableText('현황: (준비중)', style: TextStyle(color: Colors.grey[600])),
],
),
),
);
}).toList(),
_buildHistoryFooter(),
],
),
);
}
Widget _buildHistoryFooter() {
if (_auditLoadingMore) {
return const Padding(
padding: EdgeInsets.only(top: 8),
child: Center(child: CircularProgressIndicator()),
);
}
if (_auditError != null) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('더 불러오지 못했습니다.'),
TextButton(
onPressed: () => _loadAuditLogs(),
child: const Text('재시도'),
),
],
),
);
}
if (_auditNextCursor == null || _auditNextCursor!.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'더 이상 항목이 없습니다.',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
);
}
return const SizedBox.shrink();
}
}
class _ActivityItem {