1
0
forked from baron/baron-sso

로그인 이력확인

This commit is contained in:
Lectom C Han
2026-01-30 10:05:38 +09:00
parent 3b0e471471
commit c58572b7cd
10 changed files with 1117 additions and 204 deletions

View File

@@ -12,6 +12,13 @@ class ForgotPasswordScreen extends StatefulWidget {
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final TextEditingController _loginIdController = TextEditingController();
bool _isLoading = false;
bool _drySendEnabled = false;
@override
void initState() {
super.initState();
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
}
Future<void> _handlePasswordReset() async {
final input = _loginIdController.text.trim();
@@ -32,7 +39,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
setState(() => _isLoading = true);
try {
await AuthProxyService.initiatePasswordReset(loginId);
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -59,6 +66,14 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
);
}
bool _parseBoolParam(String? value) {
if (value == null) {
return false;
}
final normalized = value.toLowerCase();
return normalized == 'true' || normalized == '1' || normalized == 'yes';
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -82,6 +97,29 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
),
textAlign: TextAlign.center,
),
if (_drySendEnabled) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFC107)),
),
child: Row(
children: const [
Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
SizedBox(width: 8),
Expanded(
child: Text(
"drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.",
style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
),
),
],
),
),
],
const SizedBox(height: 16),
const Text(
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:descope/descope.dart';
import 'package:go_router/go_router.dart';
@@ -47,6 +46,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Timer? _linkResendTimer;
int _linkExpireSeconds = 0;
Timer? _linkExpireTimer;
bool _verificationOnly = false;
bool _drySendEnabled = false;
@override
void initState() {
@@ -54,6 +55,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
// 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크")
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
_tabController.addListener(_handleTabSelection);
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
// Check for tokens (Path Parameter or Legacy Query Parameter)
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -61,19 +63,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final loginIdParam = uri.queryParameters['loginId'];
final codeParam = uri.queryParameters['code'];
final pendingRefParam = uri.queryParameters['pendingRef'];
if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l') {
final hasShortCodePath = uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
final hasTokenParam = uri.queryParameters.containsKey('t');
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
final hasLoginCode = loginIdParam != null && codeParam != null;
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
if (hasShortCodePath) {
final shortCode = uri.pathSegments[1];
_verifyShortCode(shortCode);
}
if (loginIdParam != null && codeParam != null) {
if (hasLoginCode) {
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
} else if (widget.verificationToken != null) {
_verifyToken(widget.verificationToken!);
} else if (uri.queryParameters.containsKey('t')) {
_verifyToken(uri.queryParameters['t']!);
} else if (hasVerificationToken) {
_verifyToken(widget.verificationToken ?? uri.queryParameters['t']!);
}
_tryCookieSession();
if (!_verificationOnly) {
_tryCookieSession();
}
if (uri.queryParameters.containsKey('redirect_url')) {
_redirectUrl = uri.queryParameters['redirect_url'];
@@ -128,6 +136,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_shortCodeDigitsController.clear();
}
bool _parseBoolParam(String? value) {
if (value == null) {
return false;
}
final normalized = value.toLowerCase();
return normalized == 'true' || normalized == '1' || normalized == 'yes';
}
void _startLinkResendTimer(int seconds) {
_linkResendSeconds = seconds;
_linkResendTimer?.cancel();
@@ -284,40 +300,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
if (res['status'] == 'ok' && res['sessionJwt'] != null) {
if (res['status'] == 'ok') {
timer.cancel();
_qrCountdownTimer?.cancel();
final token = res['sessionJwt'] as String;
final isJwt = token.split('.').length == 3;
if (isJwt) {
final displayName = _getLoginIdFromJwt(token);
// Create User & Session for Descope SDK
final dummyUser = DescopeUser(
'unknown', // userId
[], // loginIds
0, // createdAt
displayName, // name
null, // picture (Uri?)
'', // email
false, // isVerifiedEmail
'', // phone
false, // isVerifiedPhone
{}, // customAttributes
'', // givenName
'', // middleName
'', // familyName
false, // hasPassword
'enabled', // status
[], // roleNames
[], // ssoAppIds
[], // oauthProviders (List<String>)
);
final session = DescopeSession.fromJwt(token, token, dummyUser);
Descope.sessionManager.manageSession(session);
final token = res['sessionJwt'] ?? res['token'];
if (token is String && token.isNotEmpty) {
_completeLoginFromToken(token);
} else {
_showError("로그인 토큰을 확인할 수 없습니다.");
}
_onLoginSuccess(token);
}
} catch (e) {
debugPrint("[QR] Polling error: $e");
@@ -339,26 +330,37 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}";
}
void _completeLoginFromToken(
String token, {
String? provider,
bool closeDialog = false,
}) {
final isJwt = token.split('.').length == 3;
if (isJwt) {
final displayName = _getLoginIdFromJwt(token);
final dummyUser = DescopeUser(
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
);
final session = DescopeSession.fromJwt(token, token, dummyUser);
Descope.sessionManager.manageSession(session);
}
if (!mounted) return;
if (closeDialog && Navigator.canPop(context)) {
Navigator.of(context).pop();
}
_onLoginSuccess(token, provider: provider);
}
Future<void> _verifyToken(String token) async {
debugPrint("[Auth] Starting verification for token: $token");
try {
// Use Backend to verify the token (Backend-Driven Flow)
final res = await AuthProxyService.verifyMagicLink(token);
final jwt = res['token'];
await AuthProxyService.verifyMagicLink(token);
debugPrint("[Auth] Verification successful for token: $token");
if (jwt != null && mounted) {
final displayName = _getLoginIdFromJwt(jwt);
// Create User & Session for Descope SDK to log in this tab
final dummyUser = DescopeUser(
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
);
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
// Refresh Token을 LocalStorage에 저장
Descope.sessionManager.manageSession(session);
// Notify and Go to Dashboard
_onLoginSuccess(jwt);
if (mounted) {
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
}
} catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
@@ -388,17 +390,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
if (jwt != null && mounted) {
final isJwt = (jwt as String).split('.').length == 3;
if (isJwt) {
final displayName = _getLoginIdFromJwt(jwt);
final dummyUser = DescopeUser(
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
);
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
Descope.sessionManager.manageSession(session);
if (_verificationOnly) {
if (mounted) {
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
}
_onLoginSuccess(jwt, provider: res['provider'] as String?);
return;
}
if (jwt is String && jwt.isNotEmpty) {
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
}
} catch (e) {
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
@@ -425,17 +425,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
if (jwt != null && mounted) {
final isJwt = (jwt as String).split('.').length == 3;
if (isJwt) {
final displayName = _getLoginIdFromJwt(jwt);
final dummyUser = DescopeUser(
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
);
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
Descope.sessionManager.manageSession(session);
if (_verificationOnly) {
if (mounted) {
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
}
_onLoginSuccess(jwt, provider: res['provider'] as String?);
return;
}
if (jwt is String && jwt.isNotEmpty) {
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
}
} catch (e) {
debugPrint("[Auth] Short code verification FAILED. Error: $e");
@@ -543,6 +541,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final initResponse = await AuthProxyService.initEnchantedLink(
loginId,
codeOnly: codeOnly,
drySend: _drySendEnabled,
);
final pendingRef = initResponse['pendingRef'];
final mode = (initResponse['mode'] ?? '').toString();
@@ -627,24 +626,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (result['status'] == 'ok') {
final jwt = result['sessionJwt'];
if (jwt != null) {
final token = result['sessionJwt'] ?? result['token'];
if (token is String && token.isNotEmpty) {
debugPrint("[Auth] Polling SUCCESS. Token received.");
final displayName = _getLoginIdFromJwt(jwt);
// Descope SDK 세션 강제 주입
final dummyUser = DescopeUser(
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
_completeLoginFromToken(
token,
provider: result['provider'] as String?,
closeDialog: true,
);
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
Descope.sessionManager.manageSession(session);
if (mounted) {
Navigator.of(context).pop(); // Close Polling Dialog
_onLoginSuccess(jwt);
}
return;
}
debugPrint("[Auth] Polling SUCCESS but token missing.");
if (mounted && Navigator.canPop(context)) {
Navigator.of(context).pop();
}
_showError("로그인 토큰을 확인할 수 없습니다.");
return;
}
} catch (e) {
debugPrint("[Auth] Polling error (attempt $attempts): $e");
@@ -802,13 +799,36 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"Baron SSO",
"Baron 통합로그인",
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (_drySendEnabled) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFC107)),
),
child: Row(
children: const [
Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
SizedBox(width: 8),
Expanded(
child: Text(
"drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.",
style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
),
),
],
),
),
],
const SizedBox(height: 40),
TabBar(

View File

@@ -1,29 +1,227 @@
import 'dart:convert';
import 'package:flutter/material.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 '../../profile/domain/notifiers/profile_notifier.dart';
class DashboardScreen extends ConsumerWidget {
class AuditLogEntry {
final String eventId;
final DateTime timestamp;
final String userId;
final String eventType;
final String status;
final String ipAddress;
final String details;
AuditLogEntry({
required this.eventId,
required this.timestamp,
required this.userId,
required this.eventType,
required this.status,
required this.ipAddress,
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'] ?? '',
ipAddress: json['ip_address'] ?? '',
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});
Future<void> _logout(BuildContext context) async {
// ignore: use_build_context_synchronously
@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(BuildContext context) {
void _onScanQR() {
context.push('/scan');
}
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 _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, WidgetRef ref) {
final profile = ref.watch(profileProvider).value;
Widget build(BuildContext context) {
final profileState = ref.watch(profileProvider);
final profile = profileState.value;
final user = Descope.sessionManager.session?.user;
final userName = user?.name ??
user?.email ??
@@ -32,98 +230,503 @@ class DashboardScreen extends ConsumerWidget {
profile?.email ??
profile?.phone ??
'User';
final department = profile?.department.isNotEmpty == true ? profile!.department : '소속 정보 없음';
final sessionIssuedAt = _getJwtIssuedAt();
return Scaffold(
backgroundColor: Colors.grey[50],
backgroundColor: _subtle,
appBar: AppBar(
title: Text('Baron SSO', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
title: Text(
'Baron 통합로그인',
style: GoogleFonts.outfit(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Colors.white,
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),
onPressed: () => _logout(context),
tooltip: 'Sign Out',
tooltip: '로그아웃',
onPressed: _logout,
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
drawer: Drawer(
child: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green),
const SizedBox(height: 24),
Text(
'로그인 성공!',
style: GoogleFonts.notoSans(
fontSize: 28,
fontWeight: FontWeight.bold,
color: const Color(0xFF1A1F2C),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('내 정보'),
onTap: () {
Navigator.of(context).pop();
context.push('/profile');
},
),
const SizedBox(height: 8),
Text(
'반갑습니다, $userName님',
style: GoogleFonts.notoSans(
fontSize: 16,
color: Colors.grey[600],
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: const Text('QR 스캔'),
onTap: () {
Navigator.of(context).pop();
_onScanQR();
},
),
const SizedBox(height: 48),
// QR Camera Button
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () => _onScanQR(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A1F2C),
foregroundColor: Colors.white,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.qr_code_scanner, size: 28),
const SizedBox(width: 12),
Text(
'QR 스캔하기',
style: GoogleFonts.notoSans(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(width: 40), // Icon size(28) + Spacing(12) to balance the centering
],
),
),
),
const SizedBox(height: 16),
const Text(
'PC 화면의 QR 코드를 스캔하여 로그인하세요.',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
const SizedBox(height: 32),
// My Page Button
OutlinedButton.icon(
onPressed: () => context.push('/profile'),
icon: const Icon(Icons.person),
label: const Text('내 정보 보기'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF1A1F2C),
side: const BorderSide(color: Color(0xFF1A1F2C)),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
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),
],
),
),
);
},
),
),
);
}
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('접속일자')),
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);
return DataRow(cells: [
DataCell(Text(_formatDateTime(log.timestamp))),
DataCell(Text(appLabel)),
DataCell(Text(log.ipAddress.isEmpty ? '-' : log.ipAddress)),
DataCell(Text(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))),
DataCell(Text(_authMethodLabel())),
DataCell(Text(statusLabel == '성공' ? '활성' : '실패')),
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);
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('접속일자: ${_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),
),
),
],
),
);
}).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,
});
}