1
0
forked from baron/baron-sso

userfront 이력 session ID기반 작업 완료.

This commit is contained in:
Lectom C Han
2026-01-30 11:16:09 +09:00
parent c58572b7cd
commit 60df7ba904
12 changed files with 1389 additions and 398 deletions

View File

@@ -1,5 +1,6 @@
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';
@@ -8,6 +9,7 @@ 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 {
@@ -16,7 +18,10 @@ class AuditLogEntry {
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({
@@ -25,7 +30,10 @@ class AuditLogEntry {
required this.userId,
required this.eventType,
required this.status,
required this.authMethod,
required this.ipAddress,
required this.userAgent,
required this.sessionId,
required this.details,
});
@@ -44,7 +52,10 @@ class AuditLogEntry {
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'] ?? '',
);
}
@@ -105,6 +116,58 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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(() {
@@ -202,6 +265,95 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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 통합로그인';
@@ -220,6 +372,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
@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;
@@ -261,70 +414,48 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
],
),
drawer: Drawer(
child: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('내 정보'),
onTap: () {
Navigator.of(context).pop();
context.push('/profile');
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),
],
),
),
);
},
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: const Text('QR 스캔'),
onTap: () {
Navigator.of(context).pop();
_onScanQR();
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('로그아웃'),
onTap: () async {
Navigator.of(context).pop();
await _logout();
},
),
],
),
),
),
),
body: RefreshIndicator(
onRefresh: _refreshAll,
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = 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(isWide),
],
),
),
);
},
),
],
),
);
}
@@ -631,26 +762,30 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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('IP')),
DataColumn(label: Text('접속환경')),
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))),
DataCell(Text(_authMethodLabel())),
DataCell(Text(statusLabel == '성공' ? '활성' : '실패')),
const DataCell(Text('원격 로그아웃(준비중)', style: TextStyle(color: Colors.grey))),
const DataCell(Text('(준비중)', style: TextStyle(color: Colors.grey))),
]);
}).toList(),
),
@@ -668,6 +803,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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),
@@ -694,18 +831,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
],
),
const SizedBox(height: 6),
Text('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'),
Text('접속일자: ${_formatDateTime(log.timestamp)}'),
Text('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'),
Text('인증수단: ${_authMethodLabel()}'),
Text('관리: 원격 로그아웃(준비중)', style: TextStyle(color: Colors.grey[600])),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text(
'원격 로그아웃 준비중',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
),
Text('접속환경: $deviceLabel'),
_buildAuthMethodLine(log, authMethod),
Text('인증결과: $statusLabel'),
Text('현황: (준비중)', style: TextStyle(color: Colors.grey[600])),
],
),
);