forked from baron/baron-sso
865 lines
27 KiB
Dart
865 lines
27 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:descope/descope.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:google_fonts/google_fonts.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;
|
|
|
|
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,
|
|
});
|
|
|
|
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'] ?? '',
|
|
);
|
|
}
|
|
|
|
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 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);
|
|
|
|
Future<List<AuditLogEntry>>? _auditFuture;
|
|
bool _showAllActivities = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_auditFuture = _fetchAuditLogs();
|
|
}
|
|
|
|
Future<void> _logout() async {
|
|
Descope.sessionManager.clearSession();
|
|
AuthTokenStore.clear();
|
|
AuthNotifier.instance.notify();
|
|
}
|
|
|
|
void _onScanQR() {
|
|
context.push('/scan');
|
|
}
|
|
|
|
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();
|
|
setState(() {
|
|
_auditFuture = _fetchAuditLogs();
|
|
});
|
|
if (_auditFuture != null) {
|
|
await _auditFuture;
|
|
}
|
|
}
|
|
|
|
static String _envOrDefault(String key, String fallback) {
|
|
if (!dotenv.isInitialized) {
|
|
return fallback;
|
|
}
|
|
return dotenv.env[key] ?? fallback;
|
|
}
|
|
|
|
Future<List<AuditLogEntry>> _fetchAuditLogs() async {
|
|
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
|
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline?limit=20');
|
|
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 logs = items
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(AuditLogEntry.fromJson)
|
|
.toList();
|
|
|
|
return logs;
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
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 세션';
|
|
}
|
|
if (lower.contains('descope')) {
|
|
return 'Descope';
|
|
}
|
|
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) {
|
|
if (authMethod != 'QR') {
|
|
return Text(authMethod);
|
|
}
|
|
final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? '';
|
|
final tooltip = approvedSessionId.isEmpty
|
|
? '승인한 세션 ID 없음'
|
|
: '승인한 세션 ID: $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(
|
|
'QR',
|
|
style: TextStyle(
|
|
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
|
|
decoration:
|
|
approvedSessionId.isEmpty ? null : TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
|
|
if (authMethod != 'QR') {
|
|
return Text('인증수단: $authMethod');
|
|
}
|
|
final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? '';
|
|
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
|
|
? '승인한 세션 ID 없음'
|
|
: '승인한 세션 ID: $approvedSessionId\n탭하면 복사됩니다.',
|
|
child: Text(
|
|
'인증수단: QR',
|
|
style: TextStyle(
|
|
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
|
|
decoration: approvedSessionId.isEmpty
|
|
? null
|
|
: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 user = Descope.sessionManager.session?.user;
|
|
final userName = user?.name ??
|
|
user?.email ??
|
|
user?.phone ??
|
|
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: GoogleFonts.outfit(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(
|
|
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),
|
|
_buildActivityGrid(sessionIssuedAt, 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 _buildActivityGrid(DateTime? signupAt, bool isMobile) {
|
|
final signupLabel = signupAt != null ? _formatDateTime(signupAt) : '확인 필요';
|
|
final activities = [
|
|
_ActivityItem(
|
|
appName: 'Baron 통합로그인',
|
|
lastAuthAt: signupLabel,
|
|
status: '활성',
|
|
canLogout: true,
|
|
onLogout: _logout,
|
|
),
|
|
_ActivityItem(
|
|
appName: 'BEPs',
|
|
lastAuthAt: '연동 필요',
|
|
status: '미연동',
|
|
canLogout: false,
|
|
),
|
|
_ActivityItem(
|
|
appName: 'KNGIL',
|
|
lastAuthAt: '연동 필요',
|
|
status: '미연동',
|
|
canLogout: false,
|
|
),
|
|
_ActivityItem(
|
|
appName: 'C.E.L',
|
|
lastAuthAt: '연동 필요',
|
|
status: '미연동',
|
|
canLogout: false,
|
|
),
|
|
_ActivityItem(
|
|
appName: 'EG-BIM',
|
|
lastAuthAt: '연동 필요',
|
|
status: '미연동',
|
|
canLogout: false,
|
|
),
|
|
];
|
|
|
|
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) {
|
|
return FutureBuilder<List<AuditLogEntry>>(
|
|
future: _auditFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
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('다시 시도'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final logs = snapshot.data ?? [];
|
|
if (logs.isEmpty) {
|
|
return _buildHistoryContainer(
|
|
child: Center(
|
|
child: Text(
|
|
'최근 접속 이력이 없습니다.',
|
|
style: TextStyle(color: Colors.grey[600]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (isWide) {
|
|
return _buildHistoryTable(logs);
|
|
}
|
|
return _buildHistoryList(logs);
|
|
},
|
|
);
|
|
}
|
|
|
|
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: 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(),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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),
|
|
),
|
|
),
|
|
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])),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|