forked from baron/baron-sso
접근 이력 스크롤 조회 기능 추가
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user