1
0
forked from baron/baron-sso
Files
baron-sso/userfront/lib/features/dashboard/presentation/dashboard_screen.dart
2026-02-03 18:10:31 +09:00

1153 lines
36 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../profile/domain/notifiers/profile_notifier.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 LinkedRp {
final String id;
final String name;
final String logo;
final String status;
final List<String> scopes;
final DateTime? lastAuthenticatedAt;
LinkedRp({
required this.id,
required this.name,
required this.logo,
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() ?? '',
status: json['status']?.toString() ?? '',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth,
);
}
}
class DashboardScreen extends ConsumerStatefulWidget {
const DashboardScreen({super.key});
@override
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
static const _ink = Color(0xFF1A1F2C);
static const _surface = Colors.white;
static const _border = Color(0xFFE5E7EB);
static const _subtle = Color(0xFFF7F8FA);
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();
_pageScrollController.addListener(_onPageScroll);
_loadAuditLogs(reset: true);
_linkedRpsFuture = _fetchLinkedRps();
}
@override
void dispose() {
_pageScrollController.dispose();
super.dispose();
}
Future<void> _logout() async {
AuthTokenStore.clear();
AuthNotifier.instance.notify();
}
void _onScanQR() {
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(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
ListTile(
leading: const Icon(Icons.home_outlined),
title: const Text('대시보드'),
selected: true,
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
}
context.go('/');
},
),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('내 정보'),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
}
context.push('/profile');
},
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: const Text('QR 스캔'),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
}
_onScanQR();
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('로그아웃'),
onTap: () async {
if (closeOnTap) {
Navigator.of(context).pop();
}
await _logout();
},
),
],
),
);
}
Future<void> _refreshAll() async {
await ref.read(profileProvider.notifier).loadProfile();
await _loadAuditLogs(reset: true);
setState(() {
_linkedRpsFuture = _fetchLinkedRps();
});
if (_linkedRpsFuture != null) {
await _linkedRpsFuture;
}
}
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;
});
}
}
Future<List<LinkedRp>> _fetchLinkedRps() async {
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');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? [];
final linkedRps = items
.whereType<Map<String, dynamic>>()
.map(LinkedRp.fromJson)
.toList();
return linkedRps;
}
DateTime? _getJwtIssuedAt() {
final token = AuthTokenStore.getToken();
if (token == null || token.isEmpty) {
return null;
}
try {
final parts = token.split('.');
if (parts.length != 3) {
return null;
}
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final data = json.decode(payload) as Map<String, dynamic>;
final iatValue = data['iat'] ?? data['auth_time'];
if (iatValue is num) {
return DateTime.fromMillisecondsSinceEpoch(iatValue.toInt() * 1000).toLocal();
}
} catch (_) {
return null;
}
return null;
}
String _formatDateTime(DateTime dateTime) {
final yyyy = dateTime.year.toString().padLeft(4, '0');
final mm = dateTime.month.toString().padLeft(2, '0');
final dd = dateTime.day.toString().padLeft(2, '0');
final hh = dateTime.hour.toString().padLeft(2, '0');
final min = dateTime.minute.toString().padLeft(2, '0');
return '$yyyy.$mm.$dd $hh:$min';
}
Widget _selectableText(String text, {TextStyle? style}) {
return SelectableText(text, style: style);
}
String _authMethodLabel() {
if (AuthTokenStore.usesCookie()) {
return 'Ory 세션';
}
final provider = AuthTokenStore.getProvider();
if (provider == null || provider.isEmpty) {
return '세션';
}
final lower = provider.toLowerCase();
if (lower.contains('ory')) {
return 'Ory 세션';
}
return provider;
}
String _deviceLabelFromUserAgent(String userAgent) {
if (userAgent.isEmpty) {
return '-';
}
final ua = userAgent.toLowerCase();
if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) {
return 'Mobile(iOS)';
}
if (ua.contains('android')) {
return 'Mobile(Android)';
}
if (ua.contains('windows')) {
return 'Desktop(Windows)';
}
if (ua.contains('mac os x') || ua.contains('macintosh')) {
return 'Desktop(macOS)';
}
if (ua.contains('linux')) {
return 'Desktop(Linux)';
}
return 'Unknown';
}
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
final isOidc = authMethod.contains('OIDC');
if (authMethod != 'QR' && !isOidc) {
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().trim().isNotEmpty ?? false)
? log.detailMap['approved_session_id'].toString()
: log.sessionId;
final tooltipLabel = isOidc ? '승인한 Userfront 세션 ID' : '승인한 세션 ID';
final tooltip = approvedSessionId.isEmpty
? '$tooltipLabel 없음'
: '$tooltipLabel: $approvedSessionId\n클릭하면 복사됩니다.';
return InkWell(
onTap: approvedSessionId.isEmpty
? null
: () async {
await Clipboard.setData(ClipboardData(text: approvedSessionId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('세션 ID가 복사되었습니다.')),
);
}
},
child: Tooltip(
message: tooltip,
child: Text(
isOidc ? authMethod : 'QR',
style: TextStyle(
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
decoration:
approvedSessionId.isEmpty ? null : TextDecoration.underline,
),
),
),
);
}
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
final isOidc = authMethod.contains('OIDC');
if (authMethod != 'QR' && !isOidc) {
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().trim().isNotEmpty ?? false)
? log.detailMap['approved_session_id'].toString()
: log.sessionId;
final tooltipLabel = isOidc ? '승인한 Userfront 세션 ID' : '승인한 세션 ID';
return InkWell(
onTap: approvedSessionId.isEmpty
? null
: () async {
await Clipboard.setData(ClipboardData(text: approvedSessionId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('세션 ID가 복사되었습니다.')),
);
}
},
child: Tooltip(
message: approvedSessionId.isEmpty
? '$tooltipLabel 없음'
: '$tooltipLabel: $approvedSessionId\n탭하면 복사됩니다.',
child: Text(
'인증수단: ${isOidc ? authMethod : 'QR'}',
style: TextStyle(
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
decoration: approvedSessionId.isEmpty
? null
: TextDecoration.underline,
),
),
),
);
}
String _appLabelForLog(AuditLogEntry log) {
if (log.appName.isNotEmpty) {
return log.appName;
}
return _appLabelForPath(log.path);
}
Widget _buildAppCell(AuditLogEntry log, {TextStyle? style}) {
final label = _appLabelForLog(log);
final clientId = log.clientId;
final tooltip = clientId.isEmpty ? 'Client ID 없음' : 'Client ID: $clientId';
final baseStyle = style ?? const TextStyle();
final emphasisStyle = clientId.isEmpty
? baseStyle
: baseStyle.copyWith(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
);
return Tooltip(
message: tooltip,
child: _selectableText(label, style: emphasisStyle),
);
}
String _appLabelForPath(String path) {
if (path.startsWith('/api/v1/auth')) {
return 'Baron 통합로그인';
}
if (path.startsWith('/api/v1/user')) {
return 'Baron 통합로그인';
}
if (path.startsWith('/api/v1/dev')) {
return 'Dev Console';
}
if (path.startsWith('/api/v1/admin')) {
return 'Admin Console';
}
return 'Baron 통합로그인';
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
final profileState = ref.watch(profileProvider);
final profile = profileState.value;
final userName = profile?.name ??
profile?.email ??
profile?.phone ??
'User';
final department = profile?.department.isNotEmpty == true ? profile!.department : '소속 정보 없음';
final sessionIssuedAt = _getJwtIssuedAt();
return Scaffold(
backgroundColor: _subtle,
appBar: AppBar(
title: Text(
'Baron 통합로그인',
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: _surface,
foregroundColor: Colors.black,
actions: [
IconButton(
icon: const Icon(Icons.person_outline),
tooltip: '내 정보',
onPressed: () => context.push('/profile'),
),
IconButton(
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'QR 스캔',
onPressed: _onScanQR,
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: '로그아웃',
onPressed: _logout,
),
],
),
drawer: isWide ? null : Drawer(child: _buildSideMenu(context, closeOnTap: true)),
body: Row(
children: [
if (isWide)
SizedBox(
width: 240,
child: _buildSideMenu(context, closeOnTap: false),
),
Expanded(
child: RefreshIndicator(
onRefresh: _refreshAll,
child: LayoutBuilder(
builder: (context, constraints) {
final timelineWide = constraints.maxWidth >= 900;
final isMobile = constraints.maxWidth < 600;
return SingleChildScrollView(
controller: _pageScrollController,
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMobile) ...[
_buildHeaderCard(userName, department, sessionIssuedAt),
const SizedBox(height: 28),
],
_buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'),
const SizedBox(height: 12),
_buildActivitySection(isMobile),
const SizedBox(height: 28),
_buildSectionTitle('접속이력', 'Baron 통합로그인 기준의 최근 접근 기록입니다.'),
const SizedBox(height: 12),
_buildAccessHistory(timelineWide),
],
),
),
);
},
),
),
),
],
),
);
}
Widget _buildHeaderCard(String userName, String department, DateTime? issuedAt) {
final sessionLabel = issuedAt != null ? _formatDateTime(issuedAt) : '알 수 없음';
final infoColumn = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'안녕하세요, $userName님',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: _ink),
),
const SizedBox(height: 6),
Text(
department,
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildInfoChip(Icons.verified_user, '세션 활성'),
_buildInfoChip(Icons.lock_outline, _authMethodLabel()),
_buildInfoChip(Icons.access_time, sessionLabel),
],
),
],
);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _border),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: infoColumn,
);
}
Widget _buildSectionTitle(String title, String subtitle) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink),
),
const SizedBox(width: 12),
Text(
subtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
],
);
}
Widget _buildInfoChip(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: _ink),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600),
),
],
),
);
}
Widget _buildActivitySection(bool isMobile) {
return FutureBuilder<List<LinkedRp>>(
future: _linkedRpsFuture,
builder: (context, snapshot) {
final activities = _buildActivityItems(snapshot.data ?? []);
final grid = _buildActivityGrid(activities, isMobile);
if (snapshot.hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
grid,
const SizedBox(height: 8),
Text(
'연동 정보를 불러오지 못했습니다.',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
);
}
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]),
),
],
);
}
return grid;
},
);
}
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
final items = <_ActivityItem>[];
for (final rp in linkedRps) {
final lastAuthLabel = rp.lastAuthenticatedAt != null
? _formatDateTime(rp.lastAuthenticatedAt!)
: '연동됨';
final normalizedStatus = rp.status.toLowerCase();
final statusLabel = normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성';
final name = rp.name.isNotEmpty ? rp.name : rp.id;
items.add(
_ActivityItem(
appName: name,
lastAuthAt: lastAuthLabel,
status: statusLabel,
canLogout: false,
),
);
}
return items;
}
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
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(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.05,
),
itemCount: visibleActivities.length,
itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]),
),
if (shouldShowToggle)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
setState(() {
_showAllActivities = !_showAllActivities;
});
},
child: Text(_showAllActivities ? '접기' : '더보기'),
),
),
],
);
}
Widget _buildActivityCard(_ActivityItem item) {
final statusColor = item.status == '활성' ? Colors.green : Colors.grey;
return Container(
width: 260,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.appName,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _ink),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
item.status,
style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: 12),
Text(
'최근 인증',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
item.lastAuthAt,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: item.canLogout ? item.onLogout : null,
style: OutlinedButton.styleFrom(
foregroundColor: _ink,
side: const BorderSide(color: _border),
),
child: const Text('로그아웃'),
),
),
],
),
);
}
Widget _buildAccessHistory(bool isWide) {
if (_auditLoading && _auditLogs.isEmpty) {
return _buildHistoryContainer(
child: const Center(child: CircularProgressIndicator()),
);
}
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('다시 시도'),
),
],
),
),
);
}
if (_auditLogs.isEmpty) {
return _buildHistoryContainer(
child: Center(
child: Text(
'최근 접속 이력이 없습니다.',
style: TextStyle(color: Colors.grey[600]),
),
),
);
}
if (isWide) {
return _buildHistoryTable(_auditLogs);
}
return _buildHistoryList(_auditLogs);
}
Widget _buildHistoryContainer({required Widget child}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _border),
),
child: child,
);
}
Widget _buildHistoryTable(List<AuditLogEntry> logs) {
return _buildHistoryContainer(
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 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(_buildAppCell(log)),
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(),
],
),
);
}
Widget _buildHistoryList(List<AuditLogEntry> logs) {
return _buildHistoryContainer(
child: Column(
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: _buildAppCell(
log,
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
),
),
_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])),
],
),
),
_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 {
final String appName;
final String lastAuthAt;
final String status;
final bool canLogout;
final VoidCallback? onLogout;
_ActivityItem({
required this.appName,
required this.lastAuthAt,
required this.status,
required this.canLogout,
this.onLogout,
});
}